// 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 };
}