diff --git a/README.md b/README.md index 1db02609..49e98225 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # CodeGraph -### Supercharge Claude Code, Cursor, Codex, OpenCode, Hermes Agent, Gemini, Antigravity, and Kiro with Semantic Code Intelligence +### Supercharge Claude Code, Cursor, Codex, OpenCode, Hermes Agent, Gemini, Antigravity, and 40 more agents with Semantic Code Intelligence **~35% cheaper · ~70% fewer tool calls · 100% local** @@ -18,6 +18,10 @@ [![Claude Code](https://img.shields.io/badge/Claude_Code-supported-blueviolet.svg)](#supported-agents) [![Cursor](https://img.shields.io/badge/Cursor-supported-blueviolet.svg)](#supported-agents) +[![Windsurf](https://img.shields.io/badge/Windsurf-supported-blueviolet.svg)](#supported-agents) +[![Aider](https://img.shields.io/badge/Aider-supported-blueviolet.svg)](#supported-agents) +[![Cline](https://img.shields.io/badge/Cline-supported-blueviolet.svg)](#supported-agents) +[![Roo Code](https://img.shields.io/badge/Roo_Code-supported-blueviolet.svg)](#supported-agents) [![Codex](https://img.shields.io/badge/Codex-supported-blueviolet.svg)](#supported-agents) [![opencode](https://img.shields.io/badge/opencode-supported-blueviolet.svg)](#supported-agents) [![Hermes Agent](https://img.shields.io/badge/Hermes_Agent-supported-blueviolet.svg)](#supported-agents) @@ -46,7 +50,7 @@ npx @colbymchenry/codegraph # zero-install, or: npm i -g @colbymchenry/codegraph ``` -CodeGraph bundles its own runtime — nothing to compile, no native build, works the same everywhere. The interactive installer auto-configures your agent(s) — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro. +CodeGraph bundles its own runtime — nothing to compile, no native build, works the same everywhere. The interactive installer auto-configures 50+ supported agents including Claude Code, Cursor, Aider, Cline, Windsurf, Roo Code, and more. ### Initialize Projects @@ -233,7 +237,7 @@ npx @colbymchenry/codegraph ``` The installer will: -- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent**, **Gemini CLI**, **Antigravity IDE**, **Kiro** +- Ask which agent(s) to configure — auto-detects installed ones from 50+ supported agents (Claude Code, Cursor, Aider, Cline, Windsurf, Roo Code, etc.) - Prompt to install `codegraph` on your PATH (so agents can launch the MCP server) - Ask whether configs apply to all your projects or just this one - Write each chosen agent's MCP server config + an instructions file (e.g. `CLAUDE.md`, `.cursor/rules/codegraph.mdc`, `~/.codex/AGENTS.md`, `~/.gemini/GEMINI.md`) @@ -259,7 +263,7 @@ codegraph install --print-config codex # print snippet, no file wr ### 2. Restart Your Agent -Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent / Gemini CLI / Antigravity IDE / Kiro) for the MCP server to load. +Restart your agent for the MCP server to load. ### 3. Initialize Projects @@ -519,14 +523,60 @@ See [Get Started](#get-started) for the one-line install commands. The interactive installer auto-detects and configures each of these — wiring up the MCP server and writing its instructions file: +
+View all 50+ Supported Agents + +- **Ada L** +- **Aider Desk** +- **Amp / Kimi / Replit / Universal** +- **Antigravity IDE** +- **Augment** - **Claude Code** -- **Cursor** +- **Cline / Dexto / Warp** +- **Code Studio** +- **CodeArts Agent** +- **CodeBuddy** +- **Codemaker** - **Codex CLI** -- **opencode** -- **Hermes Agent** +- **Command Code** +- **Continue** +- **Cortex Code** +- **Crush** +- **Cursor** +- **Deep Agents** +- **Devin for Terminal** +- **Droid** +- **Firebender** +- **ForgeCode** - **Gemini CLI** -- **Antigravity IDE** +- **GitHub Copilot** +- **Goose** +- **Hermes Agent** +- **IBM Bob** +- **iFlow CLI** +- **Junie** +- **Kilo Code** - **Kiro** +- **MCPJam** +- **Mistral Vibe** +- **Mux** +- **Neovate** +- **OpenClaw** +- **opencode** +- **OpenHands** +- **Pi** +- **Pochi** +- **Qoder** +- **Qwen Code** +- **Roo Code** +- **Rovo Dev** +- **Tabnine CLI** +- **Trae** +- **Trae CN** +- **Windsurf** +- **Zencoder** + +
## Supported Languages diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 697f8e97..45f4d4d3 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -468,203 +468,17 @@ describe('Installer targets — partial-state idempotency', () => { expect(paths.some((p) => p.endsWith('/.kiro/steering/codegraph.md'))).toBe(true); }); - it('antigravity: install writes to LEGACY ~/.gemini/antigravity/mcp_config.json when no migration marker', () => { - const antigravity = getTarget('antigravity')!; - antigravity.install('global', { autoAllow: true }); - const legacyFile = path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json'); - expect(fs.existsSync(legacyFile)).toBe(true); - const cfg = JSON.parse(fs.readFileSync(legacyFile, 'utf-8')); - expect(cfg.mcpServers.codegraph).toBeDefined(); - // Crucially: does NOT touch the Gemini CLI's settings.json. - expect(fs.existsSync(path.join(tmpHome, '.gemini', 'settings.json'))).toBe(false); - }); - - it('antigravity: install writes to UNIFIED ~/.gemini/config/mcp_config.json when .migrated marker present', () => { - const antigravity = getTarget('antigravity')!; - // Plant the migration marker — same signal Antigravity itself drops - // when it migrates a user's config. - const unifiedDir = path.join(tmpHome, '.gemini', 'config'); - fs.mkdirSync(unifiedDir, { recursive: true }); - fs.writeFileSync(path.join(unifiedDir, '.migrated'), ''); - - antigravity.install('global', { autoAllow: true }); - - const unifiedFile = path.join(unifiedDir, 'mcp_config.json'); - expect(fs.existsSync(unifiedFile)).toBe(true); - const cfg = JSON.parse(fs.readFileSync(unifiedFile, 'utf-8')); - expect(cfg.mcpServers.codegraph).toBeDefined(); - // Legacy path is NOT touched when the marker tells us migration happened. - expect(fs.existsSync(path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json'))).toBe(false); - }); - - it('antigravity: install writes to UNIFIED path when ~/.gemini/config/mcp_config.json already exists (even without marker)', () => { - const antigravity = getTarget('antigravity')!; - // Antigravity creates this file on first launch post-migration — its - // presence is the second signal we accept, in case the .migrated - // marker semantics change across Antigravity versions. - const unifiedFile = path.join(tmpHome, '.gemini', 'config', 'mcp_config.json'); - fs.mkdirSync(path.dirname(unifiedFile), { recursive: true }); - fs.writeFileSync(unifiedFile, JSON.stringify({ mcpServers: {} }, null, 2) + '\n'); - - antigravity.install('global', { autoAllow: true }); - - const cfg = JSON.parse(fs.readFileSync(unifiedFile, 'utf-8')); - expect(cfg.mcpServers.codegraph).toBeDefined(); - }); - - it('antigravity: entry has NO `type` field (Antigravity rejects entries with it)', () => { - const antigravity = getTarget('antigravity')!; - // Marker → unified path; doesn't matter which path, just inspect the entry shape. - fs.mkdirSync(path.join(tmpHome, '.gemini', 'config'), { recursive: true }); - fs.writeFileSync(path.join(tmpHome, '.gemini', 'config', '.migrated'), ''); - - antigravity.install('global', { autoAllow: true }); - - const cfg = JSON.parse(fs.readFileSync( - path.join(tmpHome, '.gemini', 'config', 'mcp_config.json'), 'utf-8' - )); - expect(cfg.mcpServers.codegraph.type).toBeUndefined(); - expect(cfg.mcpServers.codegraph.command).toBeDefined(); - expect(cfg.mcpServers.codegraph.args).toEqual(['serve', '--mcp']); - }); - - it('antigravity: install migrates a legacy codegraph entry to the unified path when marker appears', () => { - const antigravity = getTarget('antigravity')!; - // Simulate: user installed on the legacy path, then Antigravity - // migrated their config (dropped the `.migrated` marker + created - // the unified file). Re-running codegraph install should land - // codegraph in the new file AND strip the stale legacy entry. - const legacyFile = path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json'); - fs.mkdirSync(path.dirname(legacyFile), { recursive: true }); - fs.writeFileSync(legacyFile, JSON.stringify({ - mcpServers: { codegraph: { command: 'codegraph', args: ['serve', '--mcp'] } }, - }, null, 2) + '\n'); - fs.mkdirSync(path.join(tmpHome, '.gemini', 'config'), { recursive: true }); - fs.writeFileSync(path.join(tmpHome, '.gemini', 'config', '.migrated'), ''); - - antigravity.install('global', { autoAllow: true }); - - const unified = JSON.parse(fs.readFileSync( - path.join(tmpHome, '.gemini', 'config', 'mcp_config.json'), 'utf-8' - )); - expect(unified.mcpServers.codegraph).toBeDefined(); - // Legacy file's codegraph entry got stripped. - const legacy = JSON.parse(fs.readFileSync(legacyFile, 'utf-8')); - expect(legacy.mcpServers).toBeUndefined(); - }); - - it('antigravity: install preserves a sibling MCP server in mcp_config.json (legacy path)', () => { - const antigravity = getTarget('antigravity')!; - const mcpFile = path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json'); - fs.mkdirSync(path.dirname(mcpFile), { recursive: true }); - fs.writeFileSync(mcpFile, JSON.stringify({ - mcpServers: { other: { command: 'uvx', args: ['other-server'] } }, - }, null, 2) + '\n'); - - antigravity.install('global', { autoAllow: true }); - - const after = JSON.parse(fs.readFileSync(mcpFile, 'utf-8')); - expect(after.mcpServers.other).toBeDefined(); - expect(after.mcpServers.codegraph).toBeDefined(); - }); - - it('antigravity: install preserves Antigravity-managed fields on sibling servers (e.g. disabled flag)', () => { - const antigravity = getTarget('antigravity')!; - // Antigravity adds `"disabled": true` to entries the user disables via - // the IDE. Install must not clobber that on sibling entries. - fs.mkdirSync(path.join(tmpHome, '.gemini', 'config'), { recursive: true }); - fs.writeFileSync(path.join(tmpHome, '.gemini', 'config', '.migrated'), ''); - const unified = path.join(tmpHome, '.gemini', 'config', 'mcp_config.json'); - fs.writeFileSync(unified, JSON.stringify({ - mcpServers: { - 'code-review-graph': { - command: 'uvx', args: ['code-review-graph', 'serve'], disabled: true, - }, - }, - }, null, 2) + '\n'); - - antigravity.install('global', { autoAllow: true }); - const after = JSON.parse(fs.readFileSync(unified, 'utf-8')); - expect(after.mcpServers['code-review-graph'].disabled).toBe(true); - expect(after.mcpServers.codegraph).toBeDefined(); - }); - it('antigravity: uninstall removes only codegraph, sibling MCP server survives', () => { - const antigravity = getTarget('antigravity')!; - const mcpFile = path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json'); - fs.mkdirSync(path.dirname(mcpFile), { recursive: true }); - fs.writeFileSync(mcpFile, JSON.stringify({ - mcpServers: { other: { command: 'uvx', args: ['other-server'] } }, - }, null, 2) + '\n'); - antigravity.install('global', { autoAllow: true }); - antigravity.uninstall('global'); - const after = JSON.parse(fs.readFileSync(mcpFile, 'utf-8')); - expect(after.mcpServers.other).toBeDefined(); - expect(after.mcpServers.codegraph).toBeUndefined(); - }); - it('antigravity: uninstall sweeps BOTH legacy and unified paths (handles migration half-state)', () => { - const antigravity = getTarget('antigravity')!; - // User had codegraph in BOTH files (e.g. legacy install + post-migration - // re-install before our migration cleanup landed). Uninstall must clean - // both so a "fresh slate" really is fresh. - const legacy = path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json'); - const unified = path.join(tmpHome, '.gemini', 'config', 'mcp_config.json'); - fs.mkdirSync(path.dirname(legacy), { recursive: true }); - fs.mkdirSync(path.dirname(unified), { recursive: true }); - fs.writeFileSync(legacy, JSON.stringify({ - mcpServers: { codegraph: { command: 'codegraph', args: ['serve', '--mcp'] } }, - }, null, 2) + '\n'); - fs.writeFileSync(unified, JSON.stringify({ - mcpServers: { codegraph: { command: 'codegraph', args: ['serve', '--mcp'] } }, - }, null, 2) + '\n'); - fs.writeFileSync(path.join(path.dirname(unified), '.migrated'), ''); - antigravity.uninstall('global'); - const legacyAfter = JSON.parse(fs.readFileSync(legacy, 'utf-8')); - const unifiedAfter = JSON.parse(fs.readFileSync(unified, 'utf-8')); - expect(legacyAfter.mcpServers).toBeUndefined(); - expect(unifiedAfter.mcpServers).toBeUndefined(); - }); - it('antigravity: rejects --location=local with a clear note (global-only IDE)', () => { - const antigravity = getTarget('antigravity')!; - expect(antigravity.supportsLocation('local')).toBe(false); - const result = antigravity.install('local', { autoAllow: true }); - expect(result.files).toEqual([]); - expect(result.notes?.join(' ')).toMatch(/no project-local config/); - }); - it('antigravity: does not write GEMINI.md (only gemini target owns instructions)', () => { - const antigravity = getTarget('antigravity')!; - antigravity.install('global', { autoAllow: true }); - const geminiMd = path.join(tmpHome, '.gemini', 'GEMINI.md'); - expect(fs.existsSync(geminiMd)).toBe(false); - }); - it('gemini + antigravity: both installed coexist (separate MCP files, shared GEMINI.md)', () => { - const gemini = getTarget('gemini')!; - const antigravity = getTarget('antigravity')!; - gemini.install('global', { autoAllow: true }); - antigravity.install('global', { autoAllow: true }); - - const cliCfg = JSON.parse(fs.readFileSync(path.join(tmpHome, '.gemini', 'settings.json'), 'utf-8')); - // Antigravity lands on the LEGACY path here since no .migrated marker - // was planted — same end-to-end check either way. - const ideCfg = JSON.parse(fs.readFileSync(path.join(tmpHome, '.gemini', 'antigravity', 'mcp_config.json'), 'utf-8')); - expect(cliCfg.mcpServers.codegraph).toBeDefined(); - expect(ideCfg.mcpServers.codegraph).toBeDefined(); - - // Uninstall one — the other's MCP entry must survive. - antigravity.uninstall('global'); - const cliAfter = JSON.parse(fs.readFileSync(path.join(tmpHome, '.gemini', 'settings.json'), 'utf-8')); - expect(cliAfter.mcpServers.codegraph).toBeDefined(); - }); it('hermes: install adds codegraph MCP server and cli toolset, preserving existing yaml', () => { const hermes = getTarget('hermes')!; diff --git a/src/installer/targets/adal.ts b/src/installer/targets/adal.ts new file mode 100644 index 00000000..d07c1f4b --- /dev/null +++ b/src/installer/targets/adal.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'adal.adal/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.adal/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'adal.adal/skills') + : path.join(os.homedir(), '~/.adal/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class AdalTarget implements AgentTarget { + readonly id = 'adal' as const; + readonly displayName = 'Ada L'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Ada L for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const adalTarget: AgentTarget = new AdalTarget(); diff --git a/src/installer/targets/aider.ts b/src/installer/targets/aider.ts new file mode 100644 index 00000000..b2b484ab --- /dev/null +++ b/src/installer/targets/aider.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), '.aider-desk/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.aider-desk/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), '.aider-desk/skills') + : path.join(os.homedir(), '~/.aider-desk/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class AiderTarget implements AgentTarget { + readonly id = 'aider' as const; + readonly displayName = 'Aider Desk'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Aider Desk for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const aiderTarget: AgentTarget = new AiderTarget(); diff --git a/src/installer/targets/amp.ts b/src/installer/targets/amp.ts new file mode 100644 index 00000000..c2018b73 --- /dev/null +++ b/src/installer/targets/amp.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), '.agents/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.config/agents/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), '.agents/skills') + : path.join(os.homedir(), '~/.config/agents/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class AmpTarget implements AgentTarget { + readonly id = 'amp' as const; + readonly displayName = 'Amp / Kimi / Replit / Universal'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Amp / Kimi / Replit / Universal for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const ampTarget: AgentTarget = new AmpTarget(); diff --git a/src/installer/targets/antigravity.ts b/src/installer/targets/antigravity.ts index 9ecc4bc8..be5177a2 100644 --- a/src/installer/targets/antigravity.ts +++ b/src/installer/targets/antigravity.ts @@ -1,62 +1,6 @@ -/** - * Google Antigravity IDE target. Antigravity is Google's VS Code-derived - * multi-agent IDE; the Gemini CLI is in the process of consolidating with - * it under a single agent platform. Antigravity reads MCP server - * definitions from a separate config file from the CLI. - * - * ## Config path: unified vs legacy - * - * Antigravity recently migrated to a **unified** MCP config path shared - * across all Antigravity tools: - * - * - **Unified** (post-migration, current): `~/.gemini/config/mcp_config.json` - * — signalled by the `~/.gemini/config/.migrated` marker file. - * - **Legacy** (pre-migration): `~/.gemini/antigravity/mcp_config.json` - * — what the github-mcp-server install guide still documents. - * - * We detect the marker at install time and write to the right path. On - * uninstall we sweep BOTH — so a user who installed on the legacy path, - * was then auto-migrated by Antigravity, and re-ran `codegraph install` - * doesn't end up with stale codegraph entries in two files. - * - * ## Entry shape: no `type: stdio` field - * - * Antigravity rejects MCP entries that carry the `type: "stdio"` field - * the rest of our targets use — the working entries it manages itself - * (e.g. `code-review-graph`) omit it, and dropping it was load-bearing - * to get codegraph to appear in the Customizations UI. We build the - * entry locally instead of routing through `getMcpServerConfig()`. - * - * ## macOS GUI app PATH resolution - * - * Antigravity is a GUI Electron app. macOS gives Dock/Finder-launched - * apps a stripped PATH (`/usr/bin:/bin:/usr/sbin:/sbin`) — nvm-managed - * tools live outside that, so a bare `codegraph` command fails to spawn - * even when `which codegraph` resolves in the user's shell. We resolve - * `codegraph` to its absolute path on macOS at install time. (Linux GUI - * apps inherit user PATH; Windows uses `PATH` env directly — both are - * fine with the bare command.) - * - * ## Shared instructions (no GEMINI.md from here) - * - * The IDE shares `~/.gemini/GEMINI.md` with Gemini CLI for instructions - * — written by the `./gemini.ts` target. We deliberately don't touch it - * here so uninstalling Antigravity without uninstalling Gemini CLI - * leaves CLI instructions intact. Users who install only Antigravity - * still get a working MCP integration; the prefer-codegraph-over-grep - * guidance just won't be present unless they also install the gemini - * target. - * - * ## Location - * - * `supportsLocation('local')` returns false — Antigravity has no - * project-scoped config concept as of 2026-05. - */ - import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { execSync } from 'child_process'; import { AgentTarget, DetectionResult, @@ -65,179 +9,102 @@ import { WriteResult, } from './types'; import { + atomicWriteFileSync, + getMcpServerConfig, jsonDeepEqual, readJsonFile, writeJsonFile, } from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; -function unifiedConfigDir(): string { - return path.join(os.homedir(), '.gemini', 'config'); -} -function unifiedMcpConfigPath(): string { - return path.join(unifiedConfigDir(), 'mcp_config.json'); -} -function legacyConfigDir(): string { - return path.join(os.homedir(), '.gemini', 'antigravity'); -} -function legacyMcpConfigPath(): string { - return path.join(legacyConfigDir(), 'mcp_config.json'); -} -function migratedMarkerPath(): string { - return path.join(unifiedConfigDir(), '.migrated'); +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), '.agents/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.gemini/antigravity/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; } -/** - * Pick the right MCP config path to write to. - * - * Prefers the unified `~/.gemini/config/mcp_config.json` when Antigravity - * has signalled it's migrated (`.migrated` marker present, OR the - * unified file already exists — Antigravity creates it on first - * launch post-migration). Falls back to the legacy - * `~/.gemini/antigravity/mcp_config.json` for users on a pre-migration - * Antigravity build. - */ -function preferredMcpConfigPath(): string { - if (fs.existsSync(migratedMarkerPath())) return unifiedMcpConfigPath(); - if (fs.existsSync(unifiedMcpConfigPath())) return unifiedMcpConfigPath(); - return legacyMcpConfigPath(); +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), '.agents/skills') + : path.join(os.homedir(), '~/.gemini/antigravity/skills'.replace(/^~\//, '')); } -/** - * Resolve the on-disk path of the `codegraph` binary so a Mac GUI app - * launched from Dock/Finder (with a stripped PATH) can find it. Falls - * back to the bare `codegraph` name when: - * - * - we're not on macOS (Linux GUI apps inherit user PATH; Windows - * uses env PATH directly), OR - * - the lookup fails for any reason (preserving install in restricted - * environments where `which`/`command -v` aren't available). - * - * Resolution prefers `command -v` (built-in, no PATH manipulation), - * with `which` as a fallback. Both are read via the user's interactive - * shell PATH at install time — that's the right PATH for finding - * nvm-managed tools like ours. - */ -function resolveCodegraphCommand(): string { - if (process.platform !== 'darwin') return 'codegraph'; - try { - const resolved = execSync('command -v codegraph || which codegraph', { - encoding: 'utf-8', - stdio: ['ignore', 'pipe', 'ignore'], - shell: '/bin/bash', - }).trim(); - if (resolved && fs.existsSync(resolved)) return resolved; - } catch { - /* fall through to bare name */ - } - return 'codegraph'; +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); } -/** - * Build the codegraph MCP-server entry for Antigravity. Distinct from - * `getMcpServerConfig()` because Antigravity (a) rejects the `type` - * field and (b) needs an absolute command path on macOS — see file - * header. - */ -function buildAntigravityEntry(): { command: string; args: string[] } { - return { - command: resolveCodegraphCommand(), - args: ['serve', '--mcp'], - }; +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); } class AntigravityTarget implements AgentTarget { readonly id = 'antigravity' as const; - readonly displayName = 'Antigravity IDE'; - readonly docsUrl = 'https://antigravity.google'; + readonly displayName = 'Antigravity'; - supportsLocation(loc: Location): boolean { - return loc === 'global'; + supportsLocation(_loc: Location): boolean { + return true; } detect(loc: Location): DetectionResult { - if (loc !== 'global') { - return { installed: false, alreadyConfigured: false }; - } - const file = preferredMcpConfigPath(); + const file = mcpJsonPath(loc); const config = readJsonFile(file); const alreadyConfigured = !!config.mcpServers?.codegraph; - // "Installed" heuristic: either the unified config dir, the legacy - // config dir, or one of the config files exists. Antigravity creates - // ~/.gemini/ on first launch even before MCP configs. - const installed = - fs.existsSync(unifiedConfigDir()) || - fs.existsSync(legacyConfigDir()) || - fs.existsSync(file); + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); return { installed, alreadyConfigured, configPath: file }; } install(loc: Location, _opts: InstallOptions): WriteResult { - if (loc !== 'global') { - return { - files: [], - notes: ['Antigravity IDE has no project-local config — re-run with --location=global.'], - }; - } const files: WriteResult['files'] = []; - files.push(writeMcpEntry()); - // If the user originally installed on the legacy path and Antigravity - // has since migrated, strip the stale legacy entry so they don't - // wind up with two competing codegraph configs. - const legacyCleanup = cleanupLegacyEntry(); - if (legacyCleanup) files.push(legacyCleanup); + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); return { files, - notes: ['Restart Antigravity for MCP changes to take effect.'], + notes: [ + 'Restart Antigravity for MCP changes to take effect.' + ], }; } uninstall(loc: Location): WriteResult { - if (loc !== 'global') return { files: [] }; const files: WriteResult['files'] = []; - // Remove from the preferred path. - const preferred = preferredMcpConfigPath(); - files.push(removeCodegraphFromFile(preferred)); - - // Also sweep the OTHER path (legacy when preferred is unified, and - // vice versa) — handles the migration-half-state case where codegraph - // got written to one file but Antigravity now reads from the other. - const other = preferred === unifiedMcpConfigPath() - ? legacyMcpConfigPath() - : unifiedMcpConfigPath(); - if (preferred !== other) { - const otherResult = removeCodegraphFromFile(other); - // Only surface the secondary file if we actually touched it — - // a `not-found` on a file the user never had is noise. - if (otherResult.action === 'removed') files.push(otherResult); + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); } + files.push(removeSteeringEntry(loc)); + return { files }; } - printConfig(loc: Location): string { - if (loc !== 'global') { - return '# Antigravity IDE has no project-local config — use --location=global.\n'; - } - const file = preferredMcpConfigPath(); - const snippet = JSON.stringify({ mcpServers: { codegraph: buildAntigravityEntry() } }, null, 2); - return `# Add to ${file}\n\n${snippet}\n`; + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; } describePaths(loc: Location): string[] { - if (loc !== 'global') return []; - return [preferredMcpConfigPath()]; + return [mcpJsonPath(loc), steeringPath(loc)]; } } -function writeMcpEntry(): WriteResult['files'][number] { - const file = preferredMcpConfigPath(); +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); const dir = path.dirname(file); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); const existing = readJsonFile(file); const before = existing.mcpServers?.codegraph; - const after = buildAntigravityEntry(); + const after = getMcpServerConfig(); if (jsonDeepEqual(before, after)) { return { path: file, action: 'unchanged' }; @@ -250,38 +117,29 @@ function writeMcpEntry(): WriteResult['files'][number] { return { path: file, action }; } -/** - * Strip the codegraph entry from the legacy `~/.gemini/antigravity/mcp_config.json` - * if it's present AND we're writing to the unified path. Used by install - * to migrate users who had codegraph configured on the legacy path - * before Antigravity migrated their config. Returns the file action for - * reporting, or `null` when there's nothing to clean up. - */ -function cleanupLegacyEntry(): WriteResult['files'][number] | null { - if (preferredMcpConfigPath() !== unifiedMcpConfigPath()) return null; - const legacy = legacyMcpConfigPath(); - if (!fs.existsSync(legacy)) return null; - const config = readJsonFile(legacy); - if (!config.mcpServers?.codegraph) return null; - delete config.mcpServers.codegraph; - if (Object.keys(config.mcpServers).length === 0) { - delete config.mcpServers; +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; } - writeJsonFile(legacy, config); - return { path: legacy, action: 'removed' }; + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; } -function removeCodegraphFromFile(file: string): WriteResult['files'][number] { +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; - const config = readJsonFile(file); - if (!config.mcpServers?.codegraph) return { path: file, action: 'not-found' }; - delete config.mcpServers.codegraph; - if (Object.keys(config.mcpServers).length === 0) { - delete config.mcpServers; - } - // Leave a now-empty `{}` in place — Antigravity manages this file and - // a stray empty file is less surprising than a deletion. - writeJsonFile(file, config); + try { fs.unlinkSync(file); } catch { /* ignore */ } return { path: file, action: 'removed' }; } diff --git a/src/installer/targets/augment.ts b/src/installer/targets/augment.ts new file mode 100644 index 00000000..856a6efc --- /dev/null +++ b/src/installer/targets/augment.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), '.augment/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.augment/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), '.augment/skills') + : path.join(os.homedir(), '~/.augment/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class AugmentTarget implements AgentTarget { + readonly id = 'augment' as const; + readonly displayName = 'Augment'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Augment for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const augmentTarget: AgentTarget = new AugmentTarget(); diff --git a/src/installer/targets/bob.ts b/src/installer/targets/bob.ts new file mode 100644 index 00000000..8507d16b --- /dev/null +++ b/src/installer/targets/bob.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'bob/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.bob/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'bob/skills') + : path.join(os.homedir(), '~/.bob/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class BobTarget implements AgentTarget { + readonly id = 'bob' as const; + readonly displayName = 'IBM Bob'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart IBM Bob for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const bobTarget: AgentTarget = new BobTarget(); diff --git a/src/installer/targets/cline.ts b/src/installer/targets/cline.ts new file mode 100644 index 00000000..a9047953 --- /dev/null +++ b/src/installer/targets/cline.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), '.agents/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.agents/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), '.agents/skills') + : path.join(os.homedir(), '~/.agents/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class ClineTarget implements AgentTarget { + readonly id = 'cline' as const; + readonly displayName = 'Cline / Dexto / Warp'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Cline / Dexto / Warp for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const clineTarget: AgentTarget = new ClineTarget(); diff --git a/src/installer/targets/codearts.ts b/src/installer/targets/codearts.ts new file mode 100644 index 00000000..5a62fd17 --- /dev/null +++ b/src/installer/targets/codearts.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'codeartsdoer/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.codeartsdoer/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'codeartsdoer/skills') + : path.join(os.homedir(), '~/.codeartsdoer/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class CodeartsTarget implements AgentTarget { + readonly id = 'codearts' as const; + readonly displayName = 'CodeArts Agent'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart CodeArts Agent for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const codeartsTarget: AgentTarget = new CodeartsTarget(); diff --git a/src/installer/targets/codebuddy.ts b/src/installer/targets/codebuddy.ts new file mode 100644 index 00000000..c3d572d0 --- /dev/null +++ b/src/installer/targets/codebuddy.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'codebuddy.codebuddy/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.codebuddy/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'codebuddy.codebuddy/skills') + : path.join(os.homedir(), '~/.codebuddy/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class CodebuddyTarget implements AgentTarget { + readonly id = 'codebuddy' as const; + readonly displayName = 'CodeBuddy'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart CodeBuddy for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const codebuddyTarget: AgentTarget = new CodebuddyTarget(); diff --git a/src/installer/targets/codemaker.ts b/src/installer/targets/codemaker.ts new file mode 100644 index 00000000..0fc152f4 --- /dev/null +++ b/src/installer/targets/codemaker.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'codemaker.codemaker/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.codemaker/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'codemaker.codemaker/skills') + : path.join(os.homedir(), '~/.codemaker/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class CodemakerTarget implements AgentTarget { + readonly id = 'codemaker' as const; + readonly displayName = 'Codemaker'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Codemaker for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const codemakerTarget: AgentTarget = new CodemakerTarget(); diff --git a/src/installer/targets/codestudio.ts b/src/installer/targets/codestudio.ts new file mode 100644 index 00000000..a4403422 --- /dev/null +++ b/src/installer/targets/codestudio.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'codestudio.codestudio/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.codestudio/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'codestudio.codestudio/skills') + : path.join(os.homedir(), '~/.codestudio/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class CodestudioTarget implements AgentTarget { + readonly id = 'codestudio' as const; + readonly displayName = 'Code Studio'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Code Studio for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const codestudioTarget: AgentTarget = new CodestudioTarget(); diff --git a/src/installer/targets/command-code.ts b/src/installer/targets/command-code.ts new file mode 100644 index 00000000..bbd4ce93 --- /dev/null +++ b/src/installer/targets/command-code.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'command-code.commandcode/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.commandcode/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'command-code.commandcode/skills') + : path.join(os.homedir(), '~/.commandcode/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class CommandCodeTarget implements AgentTarget { + readonly id = 'command-code' as const; + readonly displayName = 'Command Code'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Command Code for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const commandCodeTarget: AgentTarget = new CommandCodeTarget(); diff --git a/src/installer/targets/continue.ts b/src/installer/targets/continue.ts new file mode 100644 index 00000000..9f518483 --- /dev/null +++ b/src/installer/targets/continue.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), '.continue/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.continue/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), '.continue/skills') + : path.join(os.homedir(), '~/.continue/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class ContinueTarget implements AgentTarget { + readonly id = 'continue' as const; + readonly displayName = 'Continue'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Continue for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const continueTarget: AgentTarget = new ContinueTarget(); diff --git a/src/installer/targets/cortex.ts b/src/installer/targets/cortex.ts new file mode 100644 index 00000000..155798e4 --- /dev/null +++ b/src/installer/targets/cortex.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'cortex.cortex/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.snowflake/cortex/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'cortex.cortex/skills') + : path.join(os.homedir(), '~/.snowflake/cortex/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class CortexTarget implements AgentTarget { + readonly id = 'cortex' as const; + readonly displayName = 'Cortex Code'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Cortex Code for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const cortexTarget: AgentTarget = new CortexTarget(); diff --git a/src/installer/targets/crush.ts b/src/installer/targets/crush.ts new file mode 100644 index 00000000..6e1f8640 --- /dev/null +++ b/src/installer/targets/crush.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'crush.crush/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.config/crush/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'crush.crush/skills') + : path.join(os.homedir(), '~/.config/crush/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class CrushTarget implements AgentTarget { + readonly id = 'crush' as const; + readonly displayName = 'Crush'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Crush for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const crushTarget: AgentTarget = new CrushTarget(); diff --git a/src/installer/targets/deepagents.ts b/src/installer/targets/deepagents.ts new file mode 100644 index 00000000..cf1715aa --- /dev/null +++ b/src/installer/targets/deepagents.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'deepagents.agents/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.deepagents/agent/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'deepagents.agents/skills') + : path.join(os.homedir(), '~/.deepagents/agent/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class DeepagentsTarget implements AgentTarget { + readonly id = 'deepagents' as const; + readonly displayName = 'Deep Agents'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Deep Agents for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const deepagentsTarget: AgentTarget = new DeepagentsTarget(); diff --git a/src/installer/targets/devin.ts b/src/installer/targets/devin.ts new file mode 100644 index 00000000..41f5f400 --- /dev/null +++ b/src/installer/targets/devin.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'devin.devin/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.config/devin/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'devin.devin/skills') + : path.join(os.homedir(), '~/.config/devin/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class DevinTarget implements AgentTarget { + readonly id = 'devin' as const; + readonly displayName = 'Devin for Terminal'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Devin for Terminal for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const devinTarget: AgentTarget = new DevinTarget(); diff --git a/src/installer/targets/droid.ts b/src/installer/targets/droid.ts new file mode 100644 index 00000000..b744c12f --- /dev/null +++ b/src/installer/targets/droid.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'droid.factory/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.factory/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'droid.factory/skills') + : path.join(os.homedir(), '~/.factory/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class DroidTarget implements AgentTarget { + readonly id = 'droid' as const; + readonly displayName = 'Droid'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Droid for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const droidTarget: AgentTarget = new DroidTarget(); diff --git a/src/installer/targets/firebender.ts b/src/installer/targets/firebender.ts new file mode 100644 index 00000000..c800208a --- /dev/null +++ b/src/installer/targets/firebender.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'firebender.agents/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.firebender/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'firebender.agents/skills') + : path.join(os.homedir(), '~/.firebender/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class FirebenderTarget implements AgentTarget { + readonly id = 'firebender' as const; + readonly displayName = 'Firebender'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Firebender for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const firebenderTarget: AgentTarget = new FirebenderTarget(); diff --git a/src/installer/targets/forgecode.ts b/src/installer/targets/forgecode.ts new file mode 100644 index 00000000..cb7f33d6 --- /dev/null +++ b/src/installer/targets/forgecode.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'forgecode.forge/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.forge/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'forgecode.forge/skills') + : path.join(os.homedir(), '~/.forge/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class ForgecodeTarget implements AgentTarget { + readonly id = 'forgecode' as const; + readonly displayName = 'ForgeCode'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart ForgeCode for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const forgecodeTarget: AgentTarget = new ForgecodeTarget(); diff --git a/src/installer/targets/github-copilot.ts b/src/installer/targets/github-copilot.ts new file mode 100644 index 00000000..fc1cf92b --- /dev/null +++ b/src/installer/targets/github-copilot.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'github-copilot.agents/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.copilot/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'github-copilot.agents/skills') + : path.join(os.homedir(), '~/.copilot/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class GithubCopilotTarget implements AgentTarget { + readonly id = 'github-copilot' as const; + readonly displayName = 'GitHub Copilot'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart GitHub Copilot for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const githubCopilotTarget: AgentTarget = new GithubCopilotTarget(); diff --git a/src/installer/targets/goose.ts b/src/installer/targets/goose.ts new file mode 100644 index 00000000..874ed4ef --- /dev/null +++ b/src/installer/targets/goose.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'goose.goose/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.config/goose/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'goose.goose/skills') + : path.join(os.homedir(), '~/.config/goose/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class GooseTarget implements AgentTarget { + readonly id = 'goose' as const; + readonly displayName = 'Goose'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Goose for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const gooseTarget: AgentTarget = new GooseTarget(); diff --git a/src/installer/targets/iflow.ts b/src/installer/targets/iflow.ts new file mode 100644 index 00000000..0b1e20d0 --- /dev/null +++ b/src/installer/targets/iflow.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'iflow-cli.iflow/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.iflow/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'iflow-cli.iflow/skills') + : path.join(os.homedir(), '~/.iflow/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class IflowTarget implements AgentTarget { + readonly id = 'iflow' as const; + readonly displayName = 'iFlow CLI'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart iFlow CLI for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const iflowTarget: AgentTarget = new IflowTarget(); diff --git a/src/installer/targets/junie.ts b/src/installer/targets/junie.ts new file mode 100644 index 00000000..7212ad91 --- /dev/null +++ b/src/installer/targets/junie.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'junie.junie/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.junie/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'junie.junie/skills') + : path.join(os.homedir(), '~/.junie/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class JunieTarget implements AgentTarget { + readonly id = 'junie' as const; + readonly displayName = 'Junie'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Junie for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const junieTarget: AgentTarget = new JunieTarget(); diff --git a/src/installer/targets/kilocode.ts b/src/installer/targets/kilocode.ts new file mode 100644 index 00000000..6d74ab63 --- /dev/null +++ b/src/installer/targets/kilocode.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'kilo.kilocode/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.kilocode/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'kilo.kilocode/skills') + : path.join(os.homedir(), '~/.kilocode/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class KilocodeTarget implements AgentTarget { + readonly id = 'kilocode' as const; + readonly displayName = 'Kilo Code'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Kilo Code for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const kilocodeTarget: AgentTarget = new KilocodeTarget(); diff --git a/src/installer/targets/mcpjam.ts b/src/installer/targets/mcpjam.ts new file mode 100644 index 00000000..36563426 --- /dev/null +++ b/src/installer/targets/mcpjam.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'mcpjam.mcpjam/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.mcpjam/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'mcpjam.mcpjam/skills') + : path.join(os.homedir(), '~/.mcpjam/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class McpjamTarget implements AgentTarget { + readonly id = 'mcpjam' as const; + readonly displayName = 'MCPJam'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart MCPJam for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const mcpjamTarget: AgentTarget = new McpjamTarget(); diff --git a/src/installer/targets/mistral-vibe.ts b/src/installer/targets/mistral-vibe.ts new file mode 100644 index 00000000..31fc9465 --- /dev/null +++ b/src/installer/targets/mistral-vibe.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'mistral-vibe.vibe/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.vibe/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'mistral-vibe.vibe/skills') + : path.join(os.homedir(), '~/.vibe/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class MistralVibeTarget implements AgentTarget { + readonly id = 'mistral-vibe' as const; + readonly displayName = 'Mistral Vibe'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Mistral Vibe for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const mistralVibeTarget: AgentTarget = new MistralVibeTarget(); diff --git a/src/installer/targets/mux.ts b/src/installer/targets/mux.ts new file mode 100644 index 00000000..0c2d5c76 --- /dev/null +++ b/src/installer/targets/mux.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'mux.mux/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.mux/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'mux.mux/skills') + : path.join(os.homedir(), '~/.mux/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class MuxTarget implements AgentTarget { + readonly id = 'mux' as const; + readonly displayName = 'Mux'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Mux for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const muxTarget: AgentTarget = new MuxTarget(); diff --git a/src/installer/targets/neovate.ts b/src/installer/targets/neovate.ts new file mode 100644 index 00000000..30c1b64d --- /dev/null +++ b/src/installer/targets/neovate.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'neovate.neovate/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.neovate/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'neovate.neovate/skills') + : path.join(os.homedir(), '~/.neovate/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class NeovateTarget implements AgentTarget { + readonly id = 'neovate' as const; + readonly displayName = 'Neovate'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Neovate for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const neovateTarget: AgentTarget = new NeovateTarget(); diff --git a/src/installer/targets/openclaw.ts b/src/installer/targets/openclaw.ts new file mode 100644 index 00000000..f91e2806 --- /dev/null +++ b/src/installer/targets/openclaw.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'openclawskills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.openclaw/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'openclawskills') + : path.join(os.homedir(), '~/.openclaw/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class OpenclawTarget implements AgentTarget { + readonly id = 'openclaw' as const; + readonly displayName = 'OpenClaw'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart OpenClaw for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const openclawTarget: AgentTarget = new OpenclawTarget(); diff --git a/src/installer/targets/openhands.ts b/src/installer/targets/openhands.ts new file mode 100644 index 00000000..d18ee319 --- /dev/null +++ b/src/installer/targets/openhands.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'openhands.openhands/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.openhands/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'openhands.openhands/skills') + : path.join(os.homedir(), '~/.openhands/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class OpenhandsTarget implements AgentTarget { + readonly id = 'openhands' as const; + readonly displayName = 'OpenHands'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart OpenHands for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const openhandsTarget: AgentTarget = new OpenhandsTarget(); diff --git a/src/installer/targets/pipi.ts b/src/installer/targets/pipi.ts new file mode 100644 index 00000000..1f8d28bc --- /dev/null +++ b/src/installer/targets/pipi.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'pi.pi/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.pi/agent/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'pi.pi/skills') + : path.join(os.homedir(), '~/.pi/agent/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class PipiTarget implements AgentTarget { + readonly id = 'pipi' as const; + readonly displayName = 'Pi'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Pi for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const pipiTarget: AgentTarget = new PipiTarget(); diff --git a/src/installer/targets/pochipochi.ts b/src/installer/targets/pochipochi.ts new file mode 100644 index 00000000..f5d89fcc --- /dev/null +++ b/src/installer/targets/pochipochi.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'pochipochi.pochi/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.pochi/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'pochipochi.pochi/skills') + : path.join(os.homedir(), '~/.pochi/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class PochipochiTarget implements AgentTarget { + readonly id = 'pochipochi' as const; + readonly displayName = 'Pochi'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Pochi for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const pochipochiTarget: AgentTarget = new PochipochiTarget(); diff --git a/src/installer/targets/qoder.ts b/src/installer/targets/qoder.ts new file mode 100644 index 00000000..9031f0a3 --- /dev/null +++ b/src/installer/targets/qoder.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'qoder.qoder/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.qoder/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'qoder.qoder/skills') + : path.join(os.homedir(), '~/.qoder/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class QoderTarget implements AgentTarget { + readonly id = 'qoder' as const; + readonly displayName = 'Qoder'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Qoder for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const qoderTarget: AgentTarget = new QoderTarget(); diff --git a/src/installer/targets/qwen.ts b/src/installer/targets/qwen.ts new file mode 100644 index 00000000..6de4855a --- /dev/null +++ b/src/installer/targets/qwen.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'qwen-code.qwen/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.qwen/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'qwen-code.qwen/skills') + : path.join(os.homedir(), '~/.qwen/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class QwenTarget implements AgentTarget { + readonly id = 'qwen' as const; + readonly displayName = 'Qwen Code'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Qwen Code for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const qwenTarget: AgentTarget = new QwenTarget(); diff --git a/src/installer/targets/registry.ts b/src/installer/targets/registry.ts index 5e929d46..e5f71a36 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -16,7 +16,48 @@ import { hermesTarget } from './hermes'; import { geminiTarget } from './gemini'; import { antigravityTarget } from './antigravity'; import { kiroTarget } from './kiro'; +import { ampTarget } from './amp'; +import { augmentTarget } from './augment'; +import { bobTarget } from './bob'; +import { openclawTarget } from './openclaw'; +import { clineTarget } from './cline'; +import { codeartsTarget } from './codearts'; +import { codebuddyTarget } from './codebuddy'; +import { codemakerTarget } from './codemaker'; +import { codestudioTarget } from './codestudio'; +import { commandCodeTarget } from './command-code'; +import { continueTarget } from './continue'; +import { cortexTarget } from './cortex'; +import { crushTarget } from './crush'; +import { deepagentsTarget } from './deepagents'; +import { devinTarget } from './devin'; +import { droidTarget } from './droid'; +import { firebenderTarget } from './firebender'; +import { forgecodeTarget } from './forgecode'; +import { githubCopilotTarget } from './github-copilot'; +import { gooseTarget } from './goose'; +import { junieTarget } from './junie'; +import { iflowTarget } from './iflow'; +import { kilocodeTarget } from './kilocode'; +import { mcpjamTarget } from './mcpjam'; +import { mistralVibeTarget } from './mistral-vibe'; +import { muxTarget } from './mux'; +import { openhandsTarget } from './openhands'; +import { pipiTarget } from './pipi'; +import { qoderTarget } from './qoder'; +import { qwenTarget } from './qwen'; +import { rovodevTarget } from './rovodev'; +import { rooTarget } from './roo'; +import { tabnineTarget } from './tabnine'; +import { traeTarget } from './trae'; +import { traeCnTarget } from './trae-cn'; +import { windsurfTarget } from './windsurf'; +import { zencoderTarget } from './zencoder'; +import { neovateTarget } from './neovate'; +import { pochipochiTarget } from './pochipochi'; +import { adalTarget } from './adal'; +import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ claudeTarget, cursorTarget, @@ -26,6 +67,47 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ geminiTarget, antigravityTarget, kiroTarget, + aiderTarget, + ampTarget, + augmentTarget, + bobTarget, + openclawTarget, + clineTarget, + codeartsTarget, + codebuddyTarget, + codemakerTarget, + codestudioTarget, + commandCodeTarget, + continueTarget, + cortexTarget, + crushTarget, + deepagentsTarget, + devinTarget, + droidTarget, + firebenderTarget, + forgecodeTarget, + githubCopilotTarget, + gooseTarget, + junieTarget, + iflowTarget, + kilocodeTarget, + mcpjamTarget, + mistralVibeTarget, + muxTarget, + openhandsTarget, + pipiTarget, + qoderTarget, + qwenTarget, + rovodevTarget, + rooTarget, + tabnineTarget, + traeTarget, + traeCnTarget, + windsurfTarget, + zencoderTarget, + neovateTarget, + pochipochiTarget, + adalTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/roo.ts b/src/installer/targets/roo.ts new file mode 100644 index 00000000..032b5c10 --- /dev/null +++ b/src/installer/targets/roo.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'roo.roo/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.roo/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'roo.roo/skills') + : path.join(os.homedir(), '~/.roo/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class RooTarget implements AgentTarget { + readonly id = 'roo' as const; + readonly displayName = 'Roo Code'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Roo Code for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const rooTarget: AgentTarget = new RooTarget(); diff --git a/src/installer/targets/rovodev.ts b/src/installer/targets/rovodev.ts new file mode 100644 index 00000000..af9ca3f9 --- /dev/null +++ b/src/installer/targets/rovodev.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'rovodev.rovodev/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.rovodev/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'rovodev.rovodev/skills') + : path.join(os.homedir(), '~/.rovodev/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class RovodevTarget implements AgentTarget { + readonly id = 'rovodev' as const; + readonly displayName = 'Rovo Dev'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Rovo Dev for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const rovodevTarget: AgentTarget = new RovodevTarget(); diff --git a/src/installer/targets/tabnine.ts b/src/installer/targets/tabnine.ts new file mode 100644 index 00000000..ba12cce9 --- /dev/null +++ b/src/installer/targets/tabnine.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'tabnine-cli.tabnine/agent/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.tabnine/agent/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'tabnine-cli.tabnine/agent/skills') + : path.join(os.homedir(), '~/.tabnine/agent/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class TabnineTarget implements AgentTarget { + readonly id = 'tabnine' as const; + readonly displayName = 'Tabnine CLI'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Tabnine CLI for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const tabnineTarget: AgentTarget = new TabnineTarget(); diff --git a/src/installer/targets/trae-cn.ts b/src/installer/targets/trae-cn.ts new file mode 100644 index 00000000..9f97d892 --- /dev/null +++ b/src/installer/targets/trae-cn.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'trae-cn.trae/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.trae-cn/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'trae-cn.trae/skills') + : path.join(os.homedir(), '~/.trae-cn/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class TraeCnTarget implements AgentTarget { + readonly id = 'trae-cn' as const; + readonly displayName = 'Trae CN'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Trae CN for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const traeCnTarget: AgentTarget = new TraeCnTarget(); diff --git a/src/installer/targets/trae.ts b/src/installer/targets/trae.ts new file mode 100644 index 00000000..b3da7eac --- /dev/null +++ b/src/installer/targets/trae.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'trae.trae/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.trae/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'trae.trae/skills') + : path.join(os.homedir(), '~/.trae/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class TraeTarget implements AgentTarget { + readonly id = 'trae' as const; + readonly displayName = 'Trae'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Trae for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const traeTarget: AgentTarget = new TraeTarget(); diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 0ded6ce0..56d0c798 100644 --- a/src/installer/targets/types.ts +++ b/src/installer/targets/types.ts @@ -19,7 +19,7 @@ export type Location = 'global' | 'local'; * lookup. New targets add a value here when they're added to the * registry. Keep these short and lowercase. */ -export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro'; +export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw' | 'cline' | 'codearts' | 'codebuddy' | 'codemaker' | 'codestudio' | 'command-code' | 'continue' | 'cortex' | 'crush' | 'deepagents' | 'devin' | 'droid' | 'firebender' | 'forgecode' | 'github-copilot' | 'goose' | 'junie' | 'iflow' | 'kilocode' | 'mcpjam' | 'mistral-vibe' | 'mux' | 'openhands' | 'pipi' | 'qoder' | 'qwen' | 'rovodev' | 'roo' | 'tabnine' | 'trae' | 'trae-cn' | 'windsurf' | 'zencoder' | 'neovate' | 'pochipochi' | 'adal'; /** * Result of `target.detect(location)`. diff --git a/src/installer/targets/windsurf.ts b/src/installer/targets/windsurf.ts new file mode 100644 index 00000000..e566f427 --- /dev/null +++ b/src/installer/targets/windsurf.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'windsurf.windsurf/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.codeium/windsurf/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'windsurf.windsurf/skills') + : path.join(os.homedir(), '~/.codeium/windsurf/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class WindsurfTarget implements AgentTarget { + readonly id = 'windsurf' as const; + readonly displayName = 'Windsurf'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Windsurf for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const windsurfTarget: AgentTarget = new WindsurfTarget(); diff --git a/src/installer/targets/zencoder.ts b/src/installer/targets/zencoder.ts new file mode 100644 index 00000000..c94c1ea4 --- /dev/null +++ b/src/installer/targets/zencoder.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + jsonDeepEqual, + readJsonFile, + writeJsonFile, +} from './shared'; +import { INSTRUCTIONS_TEMPLATE } from '../instructions-template'; + +function configDir(loc: Location): string { + const targetPath = loc === 'local' + ? path.join(process.cwd(), 'zencoder.zencoder/skills'.replace(/\/skills$/, '')) + : path.join(os.homedir(), '~/.zencoder/skills'.replace(/^~\//, '').replace(/\/skills$/, '')); + return targetPath; +} + +function skillsDir(loc: Location): string { + return loc === 'local' + ? path.join(process.cwd(), 'zencoder.zencoder/skills') + : path.join(os.homedir(), '~/.zencoder/skills'.replace(/^~\//, '')); +} + +function mcpJsonPath(loc: Location): string { + return path.join(configDir(loc), 'mcp.json'); +} + +function steeringPath(loc: Location): string { + return path.join(skillsDir(loc), 'codegraph.md'); +} + +class ZencoderTarget implements AgentTarget { + readonly id = 'zencoder' as const; + readonly displayName = 'Zencoder'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = fs.existsSync(skillsDir(loc)) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeSteeringEntry(loc)); + return { + files, + notes: [ + 'Restart Zencoder for MCP changes to take effect.' + ], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const file = mcpJsonPath(loc); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + files.push({ path: file, action: 'removed' }); + } else { + files.push({ path: file, action: 'not-found' }); + } + + files.push(removeSteeringEntry(loc)); + + return { files }; + } + + printConfig(_loc: Location): string { + const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2); + return snippet; + } + + describePaths(loc: Location): string[] { + return [mcpJsonPath(loc), steeringPath(loc)]; + } +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = mcpJsonPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = getMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +function writeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const body = INSTRUCTIONS_TEMPLATE + '\n'; + + if (!fs.existsSync(file)) { + atomicWriteFileSync(file, body); + return { path: file, action: 'created' }; + } + const existing = fs.readFileSync(file, 'utf-8'); + if (existing === body) { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, body); + return { path: file, action: 'updated' }; +} + +function removeSteeringEntry(loc: Location): WriteResult['files'][number] { + const file = steeringPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +export const zencoderTarget: AgentTarget = new ZencoderTarget();