// Where each AI coding tool looks for Agent Skills and MCP servers in a project, and pure merge helpers that add the // storebound entry without touching anything else in those files. Formats checked against each tool's docs in // October 2026 (see docs/project.md "Agent support"). export type AgentId = 'claude' | 'codex' | 'cursor' | 'copilot' | 'windsurf' | 'gemini' | 'opencode'; export interface McpCommand { command: string; args: string[] } export interface AgentTarget { label: string; /** Project-relative skills folder (Agent Skills standard: //SKILL.md). */ skillsDir: string; /** MCP config file; "~/" = the user's home directory (outside the project). */ mcpFile: (env: { home: string; xdg?: string; exists: (p: string) => boolean }) => string; format: 'mcpServers' | 'vscode' | 'codex-toml' | 'opencode'; note?: string; } export const AGENTS: Record = { claude: { label: 'Claude Code', skillsDir: '.claude/skills', mcpFile: () => '.mcp.json', format: 'mcpServers', note: 'or install the plugin: /plugin marketplace add , /plugin install storebound@crtkafa' }, codex: { label: 'OpenAI Codex', skillsDir: '.agents/skills', mcpFile: () => '.codex/config.toml', format: 'codex-toml', note: 'Codex reads a project .codex/config.toml only for trusted projects' }, cursor: { label: 'Cursor', skillsDir: '.agents/skills', mcpFile: () => '.cursor/mcp.json', format: 'mcpServers' }, copilot: { label: 'VS Code / GitHub Copilot', skillsDir: '.github/skills', mcpFile: () => '.vscode/mcp.json', format: 'vscode' }, windsurf: { label: 'Windsurf (Devin Desktop)', skillsDir: '.agents/skills', format: 'mcpServers', mcpFile: e => (e.exists(`${e.home}/.codeium/windsurf/mcp_config.json`) ? '~/.codeium/windsurf/mcp_config.json' : `${e.xdg ? e.xdg : '~/.config'}/devin/mcp_config.json`), note: 'MCP config is global (in your home directory); older installs keep it in ~/.codeium/windsurf/' }, gemini: { label: 'Gemini CLI', skillsDir: '.gemini/skills', mcpFile: () => '.gemini/settings.json', format: 'mcpServers', note: 'or install the extension: gemini extensions install ' }, opencode: { label: 'OpenCode', skillsDir: '.opencode/skills', mcpFile: () => 'opencode.json', format: 'opencode' }, }; export const isAgent = (s: string): s is AgentId => s in AGENTS; export interface MergeResult { text: string; changed: boolean } /** JSON config: sets .storebound and leaves every other key alone. Throws on invalid JSON. */ export function mergeJsonServer(existing: string | null, format: 'mcpServers' | 'vscode' | 'opencode', cmd: McpCommand): MergeResult { let doc: Record = {}; if (existing && existing.trim()) { try { doc = JSON.parse(existing) as Record; } catch (e) { throw new Error(`not valid JSON, left untouched (${(e as Error).message})`); } if (!doc || typeof doc !== 'object' || Array.isArray(doc)) throw new Error('not a JSON object, left untouched'); } const key = format === 'vscode' ? 'servers' : format === 'opencode' ? 'mcp' : 'mcpServers'; const entry = format === 'vscode' ? { type: 'stdio', command: cmd.command, args: cmd.args } : format === 'opencode' ? { type: 'local', command: [cmd.command, ...cmd.args], enabled: true } : { command: cmd.command, args: cmd.args }; if (format === 'opencode' && !existing?.trim()) doc.$schema = 'https://opencode.ai/config.json'; const servers = (doc[key] && typeof doc[key] === 'object' ? doc[key] : {}) as Record; const before = JSON.stringify(servers.storebound ?? null); servers.storebound = entry; doc[key] = servers; const text = JSON.stringify(doc, null, 2) + '\n'; return { text, changed: before !== JSON.stringify(entry) || !existing }; } const tomlStr = (s: string) => JSON.stringify(s); /** Codex config.toml: replaces the [mcp_servers.storebound] table (and its sub-tables) or appends it. */ export function mergeCodexToml(existing: string | null, cmd: McpCommand): MergeResult { const block = `[mcp_servers.storebound]\ncommand = ${tomlStr(cmd.command)}\nargs = [${cmd.args.map(tomlStr).join(', ')}]\n`; const src = existing ?? ''; const lines = src.split('\n'); const isOurs = (l: string) => /^\s*\[\s*mcp_servers\.(storebound|"storebound")(\.[^\]]+)?\s*\]\s*$/.test(l); const isHeader = (l: string) => /^\s*\[/.test(l); const out: string[] = []; let skipping = false; let found = false; let oldBlock = ''; for (const l of lines) { if (isHeader(l)) { if (isOurs(l)) { skipping = true; if (!found) { found = true; out.push('\u0000'); } oldBlock += l + '\n'; continue; } skipping = false; } if (skipping) { oldBlock += l + '\n'; continue; } out.push(l); } if (found) { const text = out.join('\n').replace('\u0000', block.trimEnd() + '\n').replace(/\n{3,}/g, '\n\n'); return { text: text.endsWith('\n') ? text : text + '\n', changed: oldBlock.trim() !== block.trim() }; } const base = src.trimEnd(); return { text: (base ? base + '\n\n' : '') + block, changed: true }; }