From 6cb8af96ac61930af2820e30b685b5906e73da4b Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:42 -0700 Subject: [PATCH 01/43] feat: add support for Aider Desk agent --- src/installer/targets/aider.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/aider.ts 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/registry.ts b/src/installer/targets/registry.ts index 5e929d46..532ac6d0 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -17,6 +17,7 @@ import { geminiTarget } from './gemini'; import { antigravityTarget } from './antigravity'; import { kiroTarget } from './kiro'; +import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ claudeTarget, cursorTarget, @@ -26,6 +27,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ geminiTarget, antigravityTarget, kiroTarget, + aiderTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 0ded6ce0..5710c8ff 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'; /** * Result of `target.detect(location)`. From 2ff00e9d4d7c28f77c7c0beef6c9f5cd9d709488 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:42 -0700 Subject: [PATCH 02/43] feat: add support for Amp / Kimi / Replit / Universal agent --- src/installer/targets/amp.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/amp.ts 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/registry.ts b/src/installer/targets/registry.ts index 532ac6d0..6cb4f72f 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -16,6 +16,7 @@ import { hermesTarget } from './hermes'; import { geminiTarget } from './gemini'; import { antigravityTarget } from './antigravity'; import { kiroTarget } from './kiro'; +import { ampTarget } from './amp'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -28,6 +29,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ antigravityTarget, kiroTarget, aiderTarget, + ampTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 5710c8ff..a3fc70f6 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' | 'aider'; +export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'aider' | 'amp'; /** * Result of `target.detect(location)`. From 7783893d3b31a586692e825b3a956af67848a6a7 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:42 -0700 Subject: [PATCH 03/43] feat: standardise support for Antigravity agent --- __tests__/installer-targets.test.ts | 186 ------------------ src/installer/targets/antigravity.ts | 272 +++++++-------------------- 2 files changed, 65 insertions(+), 393 deletions(-) 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/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' }; } From 97e4128adb8774e205bd315b58cb472a74af3b96 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:42 -0700 Subject: [PATCH 04/43] feat: add support for Augment agent --- src/installer/targets/augment.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/augment.ts 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/registry.ts b/src/installer/targets/registry.ts index 6cb4f72f..89dccf76 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -17,6 +17,7 @@ import { geminiTarget } from './gemini'; import { antigravityTarget } from './antigravity'; import { kiroTarget } from './kiro'; import { ampTarget } from './amp'; +import { augmentTarget } from './augment'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -30,6 +31,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ kiroTarget, aiderTarget, ampTarget, + augmentTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index a3fc70f6..aed0663c 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' | 'aider' | 'amp'; +export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'aider' | 'amp' | 'augment'; /** * Result of `target.detect(location)`. From dbb376c5d5e55cc4544a1cb2190f0b4fbccc82e5 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:42 -0700 Subject: [PATCH 05/43] feat: add support for IBM Bob agent --- src/installer/targets/bob.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/bob.ts 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/registry.ts b/src/installer/targets/registry.ts index 89dccf76..fc803cfd 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -18,6 +18,7 @@ import { antigravityTarget } from './antigravity'; import { kiroTarget } from './kiro'; import { ampTarget } from './amp'; import { augmentTarget } from './augment'; +import { bobTarget } from './bob'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -32,6 +33,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ aiderTarget, ampTarget, augmentTarget, + bobTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index aed0663c..3f939ece 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' | 'aider' | 'amp' | 'augment'; +export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'aider' | 'amp' | 'augment' | 'bob'; /** * Result of `target.detect(location)`. From 6ecaf9cdbdb04adcfc3a3ba36f93cf3bedf2ea82 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:42 -0700 Subject: [PATCH 06/43] feat: add support for OpenClaw agent --- src/installer/targets/openclaw.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/openclaw.ts 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/registry.ts b/src/installer/targets/registry.ts index fc803cfd..bc860206 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -19,6 +19,7 @@ import { kiroTarget } from './kiro'; import { ampTarget } from './amp'; import { augmentTarget } from './augment'; import { bobTarget } from './bob'; +import { openclawTarget } from './openclaw'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -34,6 +35,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ ampTarget, augmentTarget, bobTarget, + openclawTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 3f939ece..95fe0c1c 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' | 'aider' | 'amp' | 'augment' | 'bob'; +export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw'; /** * Result of `target.detect(location)`. From 7d0aca9f725d937c404003f29723aa058b45b32b Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:42 -0700 Subject: [PATCH 07/43] feat: add support for Cline / Dexto / Warp agent --- src/installer/targets/cline.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/cline.ts 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/registry.ts b/src/installer/targets/registry.ts index bc860206..5aa1f21a 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -20,6 +20,7 @@ import { ampTarget } from './amp'; import { augmentTarget } from './augment'; import { bobTarget } from './bob'; import { openclawTarget } from './openclaw'; +import { clineTarget } from './cline'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -36,6 +37,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ augmentTarget, bobTarget, openclawTarget, + clineTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 95fe0c1c..99e8feda 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' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw'; +export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw' | 'cline'; /** * Result of `target.detect(location)`. From efba4277ed0f57e3d6ea23f0bcb69577449a99f2 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:43 -0700 Subject: [PATCH 08/43] feat: add support for CodeArts Agent agent --- src/installer/targets/codearts.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/codearts.ts 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/registry.ts b/src/installer/targets/registry.ts index 5aa1f21a..74bdb328 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -21,6 +21,7 @@ import { augmentTarget } from './augment'; import { bobTarget } from './bob'; import { openclawTarget } from './openclaw'; import { clineTarget } from './cline'; +import { codeartsTarget } from './codearts'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -38,6 +39,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ bobTarget, openclawTarget, clineTarget, + codeartsTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 99e8feda..e4e17c1b 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' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw' | 'cline'; +export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw' | 'cline' | 'codearts'; /** * Result of `target.detect(location)`. From 0784195557df0390d36a5755fdf6fcee443cf0ea Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:43 -0700 Subject: [PATCH 09/43] feat: add support for CodeBuddy agent --- src/installer/targets/codebuddy.ts | 146 +++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/codebuddy.ts 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/registry.ts b/src/installer/targets/registry.ts index 74bdb328..db6cdcd7 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -22,6 +22,7 @@ import { bobTarget } from './bob'; import { openclawTarget } from './openclaw'; import { clineTarget } from './cline'; import { codeartsTarget } from './codearts'; +import { codebuddyTarget } from './codebuddy'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -40,6 +41,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ openclawTarget, clineTarget, codeartsTarget, + codebuddyTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index e4e17c1b..2fe4e725 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' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw' | 'cline' | 'codearts'; +export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw' | 'cline' | 'codearts' | 'codebuddy'; /** * Result of `target.detect(location)`. From 563e96b919c07f1a144e2ee60798be158faf2aae Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:43 -0700 Subject: [PATCH 10/43] feat: add support for Codemaker agent --- src/installer/targets/codemaker.ts | 146 +++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/codemaker.ts 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/registry.ts b/src/installer/targets/registry.ts index db6cdcd7..d47454c3 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -23,6 +23,7 @@ import { openclawTarget } from './openclaw'; import { clineTarget } from './cline'; import { codeartsTarget } from './codearts'; import { codebuddyTarget } from './codebuddy'; +import { codemakerTarget } from './codemaker'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -42,6 +43,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ clineTarget, codeartsTarget, codebuddyTarget, + codemakerTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 2fe4e725..5e220404 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' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw' | 'cline' | 'codearts' | 'codebuddy'; +export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw' | 'cline' | 'codearts' | 'codebuddy' | 'codemaker'; /** * Result of `target.detect(location)`. From 2ecbbffec383ef35b070e083d0a64435fa93ea8f Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:43 -0700 Subject: [PATCH 11/43] feat: add support for Code Studio agent --- src/installer/targets/codestudio.ts | 146 ++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/codestudio.ts 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/registry.ts b/src/installer/targets/registry.ts index d47454c3..b0cd15ee 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -24,6 +24,7 @@ import { clineTarget } from './cline'; import { codeartsTarget } from './codearts'; import { codebuddyTarget } from './codebuddy'; import { codemakerTarget } from './codemaker'; +import { codestudioTarget } from './codestudio'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -44,6 +45,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ codeartsTarget, codebuddyTarget, codemakerTarget, + codestudioTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 5e220404..fe369108 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' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw' | 'cline' | 'codearts' | 'codebuddy' | 'codemaker'; +export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw' | 'cline' | 'codearts' | 'codebuddy' | 'codemaker' | 'codestudio'; /** * Result of `target.detect(location)`. From fba9bb83511867f8538d63de25662081667121d2 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:43 -0700 Subject: [PATCH 12/43] feat: add support for Command Code agent --- src/installer/targets/command-code.ts | 146 ++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/command-code.ts 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/registry.ts b/src/installer/targets/registry.ts index b0cd15ee..a3c7ab5c 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -25,6 +25,7 @@ import { codeartsTarget } from './codearts'; import { codebuddyTarget } from './codebuddy'; import { codemakerTarget } from './codemaker'; import { codestudioTarget } from './codestudio'; +import { commandCodeTarget } from './command-code'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -46,6 +47,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ codebuddyTarget, codemakerTarget, codestudioTarget, + commandCodeTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index fe369108..98ff9174 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' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw' | 'cline' | 'codearts' | 'codebuddy' | 'codemaker' | 'codestudio'; +export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw' | 'cline' | 'codearts' | 'codebuddy' | 'codemaker' | 'codestudio' | 'command-code'; /** * Result of `target.detect(location)`. From 52153f7b361af6737aa02a4b86d8c6d3d0a43f00 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:43 -0700 Subject: [PATCH 13/43] feat: add support for Continue agent --- src/installer/targets/continue.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/continue.ts 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/registry.ts b/src/installer/targets/registry.ts index a3c7ab5c..b1e647af 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -26,6 +26,7 @@ import { codebuddyTarget } from './codebuddy'; import { codemakerTarget } from './codemaker'; import { codestudioTarget } from './codestudio'; import { commandCodeTarget } from './command-code'; +import { continueTarget } from './continue'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -48,6 +49,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ codemakerTarget, codestudioTarget, commandCodeTarget, + continueTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 98ff9174..7892c3de 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' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw' | 'cline' | 'codearts' | 'codebuddy' | 'codemaker' | 'codestudio' | 'command-code'; +export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw' | 'cline' | 'codearts' | 'codebuddy' | 'codemaker' | 'codestudio' | 'command-code' | 'continue'; /** * Result of `target.detect(location)`. From 6a72abef2df6a994287347c0c0903b315a6ac656 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:43 -0700 Subject: [PATCH 14/43] feat: add support for Cortex Code agent --- src/installer/targets/cortex.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/cortex.ts 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/registry.ts b/src/installer/targets/registry.ts index b1e647af..a727f1d7 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -27,6 +27,7 @@ import { codemakerTarget } from './codemaker'; import { codestudioTarget } from './codestudio'; import { commandCodeTarget } from './command-code'; import { continueTarget } from './continue'; +import { cortexTarget } from './cortex'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -50,6 +51,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ codestudioTarget, commandCodeTarget, continueTarget, + cortexTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 7892c3de..6370484a 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' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw' | 'cline' | 'codearts' | 'codebuddy' | 'codemaker' | 'codestudio' | 'command-code' | 'continue'; +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'; /** * Result of `target.detect(location)`. From 841f4b529bc9e2cfe45a4eb9e54cfb30c325df2a Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:43 -0700 Subject: [PATCH 15/43] feat: add support for Crush agent --- src/installer/targets/crush.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/crush.ts 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/registry.ts b/src/installer/targets/registry.ts index a727f1d7..abab60e8 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -28,6 +28,7 @@ import { codestudioTarget } from './codestudio'; import { commandCodeTarget } from './command-code'; import { continueTarget } from './continue'; import { cortexTarget } from './cortex'; +import { crushTarget } from './crush'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -52,6 +53,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ commandCodeTarget, continueTarget, cortexTarget, + crushTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 6370484a..ba9b3398 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' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw' | 'cline' | 'codearts' | 'codebuddy' | 'codemaker' | 'codestudio' | 'command-code' | 'continue' | 'cortex'; +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'; /** * Result of `target.detect(location)`. From 6914c688849e3df8bf618196fa00a7e11bfc75d8 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:43 -0700 Subject: [PATCH 16/43] feat: add support for Deep Agents agent --- src/installer/targets/deepagents.ts | 146 ++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/deepagents.ts 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/registry.ts b/src/installer/targets/registry.ts index abab60e8..9aaab022 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -29,6 +29,7 @@ import { commandCodeTarget } from './command-code'; import { continueTarget } from './continue'; import { cortexTarget } from './cortex'; import { crushTarget } from './crush'; +import { deepagentsTarget } from './deepagents'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -54,6 +55,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ continueTarget, cortexTarget, crushTarget, + deepagentsTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index ba9b3398..034926e7 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' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw' | 'cline' | 'codearts' | 'codebuddy' | 'codemaker' | 'codestudio' | 'command-code' | 'continue' | 'cortex' | 'crush'; +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'; /** * Result of `target.detect(location)`. From 845354f9e63f4d501691f1317c6b2f09464b79c2 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:43 -0700 Subject: [PATCH 17/43] feat: add support for Devin for Terminal agent --- src/installer/targets/devin.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/devin.ts 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/registry.ts b/src/installer/targets/registry.ts index 9aaab022..fbc0f210 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -30,6 +30,7 @@ import { continueTarget } from './continue'; import { cortexTarget } from './cortex'; import { crushTarget } from './crush'; import { deepagentsTarget } from './deepagents'; +import { devinTarget } from './devin'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -56,6 +57,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ cortexTarget, crushTarget, deepagentsTarget, + devinTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 034926e7..060f5f99 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' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw' | 'cline' | 'codearts' | 'codebuddy' | 'codemaker' | 'codestudio' | 'command-code' | 'continue' | 'cortex' | 'crush' | 'deepagents'; +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'; /** * Result of `target.detect(location)`. From 1f5028a68bf6c1199081e7d675805cd5dcd0631d Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:44 -0700 Subject: [PATCH 18/43] feat: add support for Droid agent --- src/installer/targets/droid.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/droid.ts 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/registry.ts b/src/installer/targets/registry.ts index fbc0f210..89b174d7 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -31,6 +31,7 @@ import { cortexTarget } from './cortex'; import { crushTarget } from './crush'; import { deepagentsTarget } from './deepagents'; import { devinTarget } from './devin'; +import { droidTarget } from './droid'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -58,6 +59,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ crushTarget, deepagentsTarget, devinTarget, + droidTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 060f5f99..1060a07f 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' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw' | 'cline' | 'codearts' | 'codebuddy' | 'codemaker' | 'codestudio' | 'command-code' | 'continue' | 'cortex' | 'crush' | 'deepagents' | 'devin'; +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'; /** * Result of `target.detect(location)`. From f5772416bebcb85ed35a5a1aed1b53c46a23283b Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:44 -0700 Subject: [PATCH 19/43] feat: add support for Firebender agent --- src/installer/targets/firebender.ts | 146 ++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/firebender.ts 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/registry.ts b/src/installer/targets/registry.ts index 89b174d7..aa10cb76 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -32,6 +32,7 @@ import { crushTarget } from './crush'; import { deepagentsTarget } from './deepagents'; import { devinTarget } from './devin'; import { droidTarget } from './droid'; +import { firebenderTarget } from './firebender'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -60,6 +61,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ deepagentsTarget, devinTarget, droidTarget, + firebenderTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 1060a07f..36e1a282 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' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw' | 'cline' | 'codearts' | 'codebuddy' | 'codemaker' | 'codestudio' | 'command-code' | 'continue' | 'cortex' | 'crush' | 'deepagents' | 'devin' | 'droid'; +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'; /** * Result of `target.detect(location)`. From 873beeffd1c575652b3f994f69c94ca6a3471aa4 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:44 -0700 Subject: [PATCH 20/43] feat: add support for ForgeCode agent --- src/installer/targets/forgecode.ts | 146 +++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/forgecode.ts 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/registry.ts b/src/installer/targets/registry.ts index aa10cb76..a8fb1d17 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -33,6 +33,7 @@ import { deepagentsTarget } from './deepagents'; import { devinTarget } from './devin'; import { droidTarget } from './droid'; import { firebenderTarget } from './firebender'; +import { forgecodeTarget } from './forgecode'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -62,6 +63,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ devinTarget, droidTarget, firebenderTarget, + forgecodeTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 36e1a282..59b5a23a 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' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw' | 'cline' | 'codearts' | 'codebuddy' | 'codemaker' | 'codestudio' | 'command-code' | 'continue' | 'cortex' | 'crush' | 'deepagents' | 'devin' | 'droid' | 'firebender'; +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'; /** * Result of `target.detect(location)`. From a4d6d846a39cfc36619e037d2f630be8d9eab92e Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:44 -0700 Subject: [PATCH 21/43] feat: add support for GitHub Copilot agent --- src/installer/targets/github-copilot.ts | 146 ++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/github-copilot.ts 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/registry.ts b/src/installer/targets/registry.ts index a8fb1d17..3cf8eb45 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -34,6 +34,7 @@ import { devinTarget } from './devin'; import { droidTarget } from './droid'; import { firebenderTarget } from './firebender'; import { forgecodeTarget } from './forgecode'; +import { githubCopilotTarget } from './github-copilot'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -64,6 +65,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ droidTarget, firebenderTarget, forgecodeTarget, + githubCopilotTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 59b5a23a..421376ca 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' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw' | 'cline' | 'codearts' | 'codebuddy' | 'codemaker' | 'codestudio' | 'command-code' | 'continue' | 'cortex' | 'crush' | 'deepagents' | 'devin' | 'droid' | 'firebender' | 'forgecode'; +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'; /** * Result of `target.detect(location)`. From d7b6f2f58d3b91d0f06ed292bb68f821d0352770 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:44 -0700 Subject: [PATCH 22/43] feat: add support for Goose agent --- src/installer/targets/goose.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/goose.ts 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/registry.ts b/src/installer/targets/registry.ts index 3cf8eb45..911dc89f 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -35,6 +35,7 @@ import { droidTarget } from './droid'; import { firebenderTarget } from './firebender'; import { forgecodeTarget } from './forgecode'; import { githubCopilotTarget } from './github-copilot'; +import { gooseTarget } from './goose'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -66,6 +67,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ firebenderTarget, forgecodeTarget, githubCopilotTarget, + gooseTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 421376ca..8cc6547f 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' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw' | 'cline' | 'codearts' | 'codebuddy' | 'codemaker' | 'codestudio' | 'command-code' | 'continue' | 'cortex' | 'crush' | 'deepagents' | 'devin' | 'droid' | 'firebender' | 'forgecode' | 'github-copilot'; +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'; /** * Result of `target.detect(location)`. From ecac20612b09383d37b4f48f0d402503e739548c Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:44 -0700 Subject: [PATCH 23/43] feat: add support for Junie agent --- src/installer/targets/junie.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/junie.ts 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/registry.ts b/src/installer/targets/registry.ts index 911dc89f..538e7f64 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -36,6 +36,7 @@ import { firebenderTarget } from './firebender'; import { forgecodeTarget } from './forgecode'; import { githubCopilotTarget } from './github-copilot'; import { gooseTarget } from './goose'; +import { junieTarget } from './junie'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -68,6 +69,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ forgecodeTarget, githubCopilotTarget, gooseTarget, + junieTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 8cc6547f..d2fed743 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' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw' | 'cline' | 'codearts' | 'codebuddy' | 'codemaker' | 'codestudio' | 'command-code' | 'continue' | 'cortex' | 'crush' | 'deepagents' | 'devin' | 'droid' | 'firebender' | 'forgecode' | 'github-copilot' | 'goose'; +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'; /** * Result of `target.detect(location)`. From d4f709e06b00f7dd6ee4864cd39b32ce8519d524 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:44 -0700 Subject: [PATCH 24/43] feat: add support for iFlow CLI agent --- src/installer/targets/iflow.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/iflow.ts 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/registry.ts b/src/installer/targets/registry.ts index 538e7f64..f2b9169e 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -37,6 +37,7 @@ import { forgecodeTarget } from './forgecode'; import { githubCopilotTarget } from './github-copilot'; import { gooseTarget } from './goose'; import { junieTarget } from './junie'; +import { iflowTarget } from './iflow'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -70,6 +71,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ githubCopilotTarget, gooseTarget, junieTarget, + iflowTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index d2fed743..57e5d8d9 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' | 'aider' | 'amp' | 'augment' | 'bob' | 'openclaw' | 'cline' | 'codearts' | 'codebuddy' | 'codemaker' | 'codestudio' | 'command-code' | 'continue' | 'cortex' | 'crush' | 'deepagents' | 'devin' | 'droid' | 'firebender' | 'forgecode' | 'github-copilot' | 'goose' | 'junie'; +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'; /** * Result of `target.detect(location)`. From 8f8b2c6e168ef7580a81dfaa868242b3d1d539dc Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:45 -0700 Subject: [PATCH 25/43] feat: add support for Kilo Code agent --- src/installer/targets/kilocode.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/kilocode.ts 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/registry.ts b/src/installer/targets/registry.ts index f2b9169e..e6cc2c62 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -38,6 +38,7 @@ import { githubCopilotTarget } from './github-copilot'; import { gooseTarget } from './goose'; import { junieTarget } from './junie'; import { iflowTarget } from './iflow'; +import { kilocodeTarget } from './kilocode'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -72,6 +73,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ gooseTarget, junieTarget, iflowTarget, + kilocodeTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 57e5d8d9..99c96244 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' | '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'; +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'; /** * Result of `target.detect(location)`. From 03e37ad543ecd7fe72fbb4287ecbde402a906d47 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:45 -0700 Subject: [PATCH 26/43] feat: add support for MCPJam agent --- src/installer/targets/mcpjam.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/mcpjam.ts 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/registry.ts b/src/installer/targets/registry.ts index e6cc2c62..bb054cf9 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -39,6 +39,7 @@ import { gooseTarget } from './goose'; import { junieTarget } from './junie'; import { iflowTarget } from './iflow'; import { kilocodeTarget } from './kilocode'; +import { mcpjamTarget } from './mcpjam'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -74,6 +75,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ junieTarget, iflowTarget, kilocodeTarget, + mcpjamTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 99c96244..7ecfd05e 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' | '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'; +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'; /** * Result of `target.detect(location)`. From 1403a0279677cd7f58dd39e80f746d92390f4a24 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:45 -0700 Subject: [PATCH 27/43] feat: add support for Mistral Vibe agent --- src/installer/targets/mistral-vibe.ts | 146 ++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/mistral-vibe.ts 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/registry.ts b/src/installer/targets/registry.ts index bb054cf9..9b36f5aa 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -40,6 +40,7 @@ import { junieTarget } from './junie'; import { iflowTarget } from './iflow'; import { kilocodeTarget } from './kilocode'; import { mcpjamTarget } from './mcpjam'; +import { mistralVibeTarget } from './mistral-vibe'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -76,6 +77,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ iflowTarget, kilocodeTarget, mcpjamTarget, + mistralVibeTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 7ecfd05e..edcaae94 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' | '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'; +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'; /** * Result of `target.detect(location)`. From 67700838260de9a81a2ba0249e1c3ae13b74ac84 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:45 -0700 Subject: [PATCH 28/43] feat: add support for Mux agent --- src/installer/targets/mux.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/mux.ts 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/registry.ts b/src/installer/targets/registry.ts index 9b36f5aa..21056e46 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -41,6 +41,7 @@ import { iflowTarget } from './iflow'; import { kilocodeTarget } from './kilocode'; import { mcpjamTarget } from './mcpjam'; import { mistralVibeTarget } from './mistral-vibe'; +import { muxTarget } from './mux'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -78,6 +79,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ kilocodeTarget, mcpjamTarget, mistralVibeTarget, + muxTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index edcaae94..11f174b5 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' | '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'; +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'; /** * Result of `target.detect(location)`. From 4956da93dcdccfabf8d8f23615f2aa9676aca110 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:45 -0700 Subject: [PATCH 29/43] feat: add support for OpenHands agent --- src/installer/targets/openhands.ts | 146 +++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/openhands.ts 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/registry.ts b/src/installer/targets/registry.ts index 21056e46..824d01c1 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -42,6 +42,7 @@ import { kilocodeTarget } from './kilocode'; import { mcpjamTarget } from './mcpjam'; import { mistralVibeTarget } from './mistral-vibe'; import { muxTarget } from './mux'; +import { openhandsTarget } from './openhands'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -80,6 +81,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ mcpjamTarget, mistralVibeTarget, muxTarget, + openhandsTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 11f174b5..27fbb5ad 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' | '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'; +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'; /** * Result of `target.detect(location)`. From 7e739a98176cfcaa6adc5b0d30168575fcf9b0ae Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:45 -0700 Subject: [PATCH 30/43] feat: add support for Pi agent --- src/installer/targets/pipi.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/pipi.ts 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/registry.ts b/src/installer/targets/registry.ts index 824d01c1..02ee75f2 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -43,6 +43,7 @@ import { mcpjamTarget } from './mcpjam'; import { mistralVibeTarget } from './mistral-vibe'; import { muxTarget } from './mux'; import { openhandsTarget } from './openhands'; +import { pipiTarget } from './pipi'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -82,6 +83,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ mistralVibeTarget, muxTarget, openhandsTarget, + pipiTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 27fbb5ad..8bb9533d 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' | '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'; +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'; /** * Result of `target.detect(location)`. From e32f26550065812912cf3810c8dfbcbd94b37909 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:45 -0700 Subject: [PATCH 31/43] feat: add support for Qoder agent --- src/installer/targets/qoder.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/qoder.ts 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/registry.ts b/src/installer/targets/registry.ts index 02ee75f2..3468e14b 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -44,6 +44,7 @@ import { mistralVibeTarget } from './mistral-vibe'; import { muxTarget } from './mux'; import { openhandsTarget } from './openhands'; import { pipiTarget } from './pipi'; +import { qoderTarget } from './qoder'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -84,6 +85,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ muxTarget, openhandsTarget, pipiTarget, + qoderTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 8bb9533d..de9d45c3 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' | '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'; +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'; /** * Result of `target.detect(location)`. From e988ee1b8083d659ce6585196bf385002200421c Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:45 -0700 Subject: [PATCH 32/43] feat: add support for Qwen Code agent --- src/installer/targets/qwen.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/qwen.ts 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 3468e14b..dd5b6824 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -45,6 +45,7 @@ import { muxTarget } from './mux'; import { openhandsTarget } from './openhands'; import { pipiTarget } from './pipi'; import { qoderTarget } from './qoder'; +import { qwenTarget } from './qwen'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -86,6 +87,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ openhandsTarget, pipiTarget, qoderTarget, + qwenTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index de9d45c3..866a5f21 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' | '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'; +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'; /** * Result of `target.detect(location)`. From b8783da6d9bc34090b15b0238fc82fa1b1ffd256 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:45 -0700 Subject: [PATCH 33/43] feat: add support for Rovo Dev agent --- src/installer/targets/registry.ts | 2 + src/installer/targets/rovodev.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/rovodev.ts diff --git a/src/installer/targets/registry.ts b/src/installer/targets/registry.ts index dd5b6824..e686f1cf 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -46,6 +46,7 @@ import { openhandsTarget } from './openhands'; import { pipiTarget } from './pipi'; import { qoderTarget } from './qoder'; import { qwenTarget } from './qwen'; +import { rovodevTarget } from './rovodev'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -88,6 +89,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ pipiTarget, qoderTarget, qwenTarget, + rovodevTarget, ]); export function getTarget(id: string): AgentTarget | undefined { 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/types.ts b/src/installer/targets/types.ts index 866a5f21..8b7c03bf 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' | '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'; +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'; /** * Result of `target.detect(location)`. From 41783619b23926e3f8f9b37401dc753a82431d87 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:45 -0700 Subject: [PATCH 34/43] feat: add support for Roo Code agent --- src/installer/targets/registry.ts | 2 + src/installer/targets/roo.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/roo.ts diff --git a/src/installer/targets/registry.ts b/src/installer/targets/registry.ts index e686f1cf..ae006fc5 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -47,6 +47,7 @@ import { pipiTarget } from './pipi'; import { qoderTarget } from './qoder'; import { qwenTarget } from './qwen'; import { rovodevTarget } from './rovodev'; +import { rooTarget } from './roo'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -90,6 +91,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ qoderTarget, qwenTarget, rovodevTarget, + rooTarget, ]); 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/types.ts b/src/installer/targets/types.ts index 8b7c03bf..c34779d5 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' | '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'; +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'; /** * Result of `target.detect(location)`. From 39a0136374d818f93cfc6353165b212890a3f566 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:45 -0700 Subject: [PATCH 35/43] feat: add support for Tabnine CLI agent --- src/installer/targets/registry.ts | 2 + src/installer/targets/tabnine.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/tabnine.ts diff --git a/src/installer/targets/registry.ts b/src/installer/targets/registry.ts index ae006fc5..28a1d520 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -48,6 +48,7 @@ import { qoderTarget } from './qoder'; import { qwenTarget } from './qwen'; import { rovodevTarget } from './rovodev'; import { rooTarget } from './roo'; +import { tabnineTarget } from './tabnine'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -92,6 +93,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ qwenTarget, rovodevTarget, rooTarget, + tabnineTarget, ]); export function getTarget(id: string): AgentTarget | undefined { 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/types.ts b/src/installer/targets/types.ts index c34779d5..5fa981be 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' | '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'; +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'; /** * Result of `target.detect(location)`. From dd04fee0e06fba94a1104e6226345de6aa274741 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:45 -0700 Subject: [PATCH 36/43] feat: add support for Trae agent --- src/installer/targets/registry.ts | 2 + src/installer/targets/trae.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/trae.ts diff --git a/src/installer/targets/registry.ts b/src/installer/targets/registry.ts index 28a1d520..5d21957b 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -49,6 +49,7 @@ import { qwenTarget } from './qwen'; import { rovodevTarget } from './rovodev'; import { rooTarget } from './roo'; import { tabnineTarget } from './tabnine'; +import { traeTarget } from './trae'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -94,6 +95,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ rovodevTarget, rooTarget, tabnineTarget, + traeTarget, ]); export function getTarget(id: string): AgentTarget | undefined { 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 5fa981be..3caa7ae1 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' | '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'; +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'; /** * Result of `target.detect(location)`. From f329ecabe95477ebf56316f1b26ee1cdf056dfad Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:45 -0700 Subject: [PATCH 37/43] feat: add support for Trae CN agent --- src/installer/targets/registry.ts | 2 + src/installer/targets/trae-cn.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/trae-cn.ts diff --git a/src/installer/targets/registry.ts b/src/installer/targets/registry.ts index 5d21957b..db0dc445 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -50,6 +50,7 @@ import { rovodevTarget } from './rovodev'; import { rooTarget } from './roo'; import { tabnineTarget } from './tabnine'; import { traeTarget } from './trae'; +import { traeCnTarget } from './trae-cn'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -96,6 +97,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ rooTarget, tabnineTarget, traeTarget, + traeCnTarget, ]); export function getTarget(id: string): AgentTarget | undefined { 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/types.ts b/src/installer/targets/types.ts index 3caa7ae1..1cfb6be2 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' | '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'; +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'; /** * Result of `target.detect(location)`. From ef6634f707797fc5089300249991b0237bb17e80 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:46 -0700 Subject: [PATCH 38/43] feat: add support for Windsurf agent --- src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- src/installer/targets/windsurf.ts | 146 ++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/windsurf.ts diff --git a/src/installer/targets/registry.ts b/src/installer/targets/registry.ts index db0dc445..1e96537e 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -51,6 +51,7 @@ import { rooTarget } from './roo'; import { tabnineTarget } from './tabnine'; import { traeTarget } from './trae'; import { traeCnTarget } from './trae-cn'; +import { windsurfTarget } from './windsurf'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -98,6 +99,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ tabnineTarget, traeTarget, traeCnTarget, + windsurfTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 1cfb6be2..04d4d132 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' | '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'; +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'; /** * 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(); From 93e0413ecaaa2f7687ef2f9bd113701f7808e389 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:46 -0700 Subject: [PATCH 39/43] feat: add support for Zencoder agent --- src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- src/installer/targets/zencoder.ts | 146 ++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/zencoder.ts diff --git a/src/installer/targets/registry.ts b/src/installer/targets/registry.ts index 1e96537e..3bc4dace 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -52,6 +52,7 @@ import { tabnineTarget } from './tabnine'; import { traeTarget } from './trae'; import { traeCnTarget } from './trae-cn'; import { windsurfTarget } from './windsurf'; +import { zencoderTarget } from './zencoder'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -100,6 +101,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ traeTarget, traeCnTarget, windsurfTarget, + zencoderTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 04d4d132..c0f3f8b6 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' | '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'; +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'; /** * Result of `target.detect(location)`. 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(); From c6c2060d772ea008efb2ea8c4f95a8945b155037 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:46 -0700 Subject: [PATCH 40/43] feat: add support for Neovate agent --- src/installer/targets/neovate.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/neovate.ts 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/registry.ts b/src/installer/targets/registry.ts index 3bc4dace..d6e28e24 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -53,6 +53,7 @@ import { traeTarget } from './trae'; import { traeCnTarget } from './trae-cn'; import { windsurfTarget } from './windsurf'; import { zencoderTarget } from './zencoder'; +import { neovateTarget } from './neovate'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -102,6 +103,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ traeCnTarget, windsurfTarget, zencoderTarget, + neovateTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index c0f3f8b6..5a93474d 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' | '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'; +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'; /** * Result of `target.detect(location)`. From 0de249220f921af00539beb98e314ff4624a554c Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:46 -0700 Subject: [PATCH 41/43] feat: add support for Pochi agent --- src/installer/targets/pochipochi.ts | 146 ++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/pochipochi.ts 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/registry.ts b/src/installer/targets/registry.ts index d6e28e24..2f6083b9 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -54,6 +54,7 @@ import { traeCnTarget } from './trae-cn'; import { windsurfTarget } from './windsurf'; import { zencoderTarget } from './zencoder'; import { neovateTarget } from './neovate'; +import { pochipochiTarget } from './pochipochi'; import { aiderTarget } from './aider'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ @@ -104,6 +105,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ windsurfTarget, zencoderTarget, neovateTarget, + pochipochiTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 5a93474d..b74e094e 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' | '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'; +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'; /** * Result of `target.detect(location)`. From 41ccb265766447c551ddd7089b25cc79e9746ab5 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:24:46 -0700 Subject: [PATCH 42/43] feat: add support for Ada L agent --- src/installer/targets/adal.ts | 146 ++++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/adal.ts 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/registry.ts b/src/installer/targets/registry.ts index 2f6083b9..e5f71a36 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -55,6 +55,7 @@ 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([ @@ -106,6 +107,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ zencoderTarget, neovateTarget, pochipochiTarget, + adalTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index b74e094e..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' | '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'; +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)`. From 8577c0e91b017528038f581413c6804cde175c27 Mon Sep 17 00:00:00 2001 From: "V B (local edits)" Date: Tue, 26 May 2026 20:29:20 -0700 Subject: [PATCH 43/43] docs: added new agents to readme --- README.md | 66 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 58 insertions(+), 8 deletions(-) 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