diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts new file mode 100644 index 0000000..d8ed4a7 --- /dev/null +++ b/src/commands/mcp.ts @@ -0,0 +1,124 @@ +// `insta mcp install` — write the insta-cloud remote MCP server into each coding agent's own +// config format. Claude Code is NOT handled here — it has a real registry CLI (`claude mcp add`, +// see setup.ts registerMcp); these are the config-file agents. All entries are OAuth (no +// credential written): each client discovers the platform AS via RFC 9728 and runs the browser +// flow on first use. +import { promises as fs } from 'node:fs' +import { existsSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { info } from '../util.js' +import { DEFAULT_MCP_URL, MCP_SERVER_NAME, registerMcp } from './setup.js' + +export const MCP_AGENT_TARGETS = ['cursor', 'codex', 'opencode', 'copilot', 'factory-droid'] as const +export type McpAgent = (typeof MCP_AGENT_TARGETS)[number] + +export function configPath(slug: McpAgent, home: string): string { + switch (slug) { + case 'cursor': return path.join(home, '.cursor', 'mcp.json') + case 'codex': return path.join(home, '.codex', 'config.toml') + case 'opencode': return path.join(home, '.config', 'opencode', 'opencode.json') + case 'copilot': return path.join(home, '.copilot', 'mcp-config.json') + case 'factory-droid': return path.join(home, '.factory', 'mcp.json') + } +} + +// An agent counts as "on this machine" when its config dir already exists — we configure what's +// installed, never scaffold a tool the user doesn't have. +export function detectAgents(home: string): McpAgent[] { + return MCP_AGENT_TARGETS.filter((slug) => existsSync(path.dirname(configPath(slug, home)))) +} + +// Merge our entry into existing JSON config. Returns null (skip, leave file alone) when the +// existing content isn't valid JSON — never clobber a config we can't parse. +export function renderJsonConfig(slug: McpAgent, existing: string | null, url: string): string | null { + let root: any = {} + if (existing && existing.trim()) { + try { root = JSON.parse(existing) } catch { return null } + if (typeof root !== 'object' || root === null || Array.isArray(root)) return null + } + if (slug === 'opencode') { + // OpenCode: `mcp` key, `type: "remote"` schema (docs.opencode.ai). + root.mcp = { ...(root.mcp ?? {}), [MCP_SERVER_NAME]: { type: 'remote', url, enabled: true } } + root.$schema ??= 'https://opencode.ai/config.json' + } else { + const entry = + slug === 'cursor' ? { url } // Cursor auto-detects HTTP from `url` + : slug === 'copilot' ? { type: 'http', url, tools: ['*'] } + : { type: 'http', url, disabled: false } // factory-droid + root.mcpServers = { ...(root.mcpServers ?? {}), [MCP_SERVER_NAME]: entry } + } + return JSON.stringify(root, null, 2) + '\n' +} + +// Codex config is TOML. Appending a complete `[mcp_servers.]` table is always valid at +// EOF, so we avoid a TOML parser: string-detect for idempotency, append for install. +export function renderCodexConfig(existing: string | null, url: string): string | null { + const base = existing ?? '' + if (base.includes(`[mcp_servers.${MCP_SERVER_NAME}]`)) return null // already configured + const sep = base.length && !base.endsWith('\n') ? '\n' : '' + return `${base}${sep}\n[mcp_servers.${MCP_SERVER_NAME}]\nurl = "${url}"\n` +} + +// Install for one agent. Returns 'installed' | 'already' | 'skipped' (unparseable config). +export async function installFor(slug: McpAgent, home: string, url: string): Promise<'installed' | 'already' | 'skipped'> { + const file = configPath(slug, home) + let existing: string | null = null + try { existing = await fs.readFile(file, 'utf8') } catch { existing = null } + if (slug === 'codex') { + const next = renderCodexConfig(existing, url) + if (next === null) return 'already' + await fs.mkdir(path.dirname(file), { recursive: true }) + await fs.writeFile(file, next) + return 'installed' + } + if (existing) { + try { + const root = JSON.parse(existing) + const entry = slug === 'opencode' ? root?.mcp?.[MCP_SERVER_NAME] : root?.mcpServers?.[MCP_SERVER_NAME] + if (entry) return 'already' + } catch { /* fall through to renderJsonConfig, which refuses to clobber */ } + } + const next = renderJsonConfig(slug, existing, url) + if (next === null) return 'skipped' + await fs.mkdir(path.dirname(file), { recursive: true }) + await fs.writeFile(file, next) + return 'installed' +} + +const AGENT_LABELS: Record = { + cursor: 'Cursor', codex: 'OpenAI Codex', opencode: 'OpenCode', copilot: 'GitHub Copilot', 'factory-droid': 'Factory Droid', +} + +// Configure every detected config-file agent (or one forced via `agent`). Returns the labels of +// agents now configured (installed or already present) for the caller's summary line. +export async function installAgentConfigs(agent?: string, home: string = os.homedir()): Promise { + const url = process.env.INSTA_MCP_URL || DEFAULT_MCP_URL + const targets = agent + ? (MCP_AGENT_TARGETS as readonly string[]).includes(agent) ? [agent as McpAgent] : [] + : detectAgents(home) + if (agent && targets.length === 0) { + info(`unknown --agent "${agent}" — supported: claude-code, ${MCP_AGENT_TARGETS.join(', ')}`) + return [] + } + const done: string[] = [] + for (const slug of targets) { + const result = await installFor(slug, home, url) + if (result === 'skipped') info(` ${AGENT_LABELS[slug]}: existing config at ${configPath(slug, home)} isn't valid JSON — add ${MCP_SERVER_NAME} manually`) + else done.push(AGENT_LABELS[slug]) + } + return done +} + +// `insta mcp install [--agent ] [--mcp-token]` — claude-code goes through its registry CLI +// (registerMcp); everything else is a config-file write. No --agent = claude-code + all detected. +export async function mcpInstall(opts: { agent?: string; mcpToken?: boolean }): Promise { + if (!opts.agent || opts.agent === 'claude-code') { + await registerMcp(undefined, undefined, !!opts.mcpToken) + if (opts.agent) return + } + const done = await installAgentConfigs(opts.agent) + if (done.length) info(`✓ MCP — configured for ${done.join(', ')} (restart those tools to pick it up)`) + else if (opts.agent) { /* messages already printed */ } + else info(' no other MCP-capable agents detected (supported: cursor, codex, opencode, copilot, factory-droid)') +} diff --git a/src/commands/setup.ts b/src/commands/setup.ts index b1a08fb..f37402f 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -6,7 +6,10 @@ // Stack skills (neon/tigris/better-auth) intentionally stay per-project: their presence in a // project doubles as its stack manifest — that install happens on `project create|link`. import { spawn } from 'node:child_process' +import os from 'node:os' +import { ApiClient } from '../api.js' import { info } from '../util.js' +import { installAgentConfigs } from './mcp.js' // The `skills` tool we shell out to prints a clack UI: a frame-by-frame clone spinner, an // "Installing to all N agents" banner, a full N-line install-path box, and a third-party @@ -94,7 +97,63 @@ const defaultRunner: Runner = (cmd, args) => // (Claude Code, Codex, Cursor, OpenCode, Copilot, …); --copy = real files, not cache symlinks. export const SETUP_ARGS = ['skills', 'add', 'InsForge/insta-skills', '-s', 'insta', '-a', '*', '-g', '-y', '--copy'] -export async function setupAgent(opts: { yes?: boolean }, run: Runner = defaultRunner): Promise { +// ---- remote MCP registration ---- + +export const MCP_SERVER_NAME = 'insta-cloud' +export const DEFAULT_MCP_URL = 'https://mcp.instacloud.com/mcp' + +// Headless fallback only (`--mcp-token`): the MCP config outlives the CLI's refreshable +// session, so a static-header registration needs a durable `insta_` API token — minted once, +// named after this machine. Returns null when not logged in (or the mint fails); the caller +// prints the login hint. +export type TokenMinter = () => Promise +const defaultMinter: TokenMinter = async () => { + try { + const api = await ApiClient.load() + if (!api.config.accessToken) return null + const { token } = await api.request<{ token?: string }>('POST', '/tokens', { name: `mcp-${os.hostname()}` }) + return token ?? null + } catch { return null } +} + +// Register the insta-cloud remote MCP server with Claude Code (user scope, so it follows the +// machine like the skill install above). Default is OAuth: register with NO credential — the +// platform's Better Auth MCP authorization server is discovered via RFC 9728 and Claude runs +// the browser flow on first `/mcp` use, so no static token ever lands on disk. `--mcp-token` +// is the headless fallback (CI, no browser): mint a durable token into the header instead. +// Idempotent — an existing registration is left alone. Best-effort: the skill install is the +// primary outcome; agents without an MCP registry are covered by the skill alone. +export async function registerMcp(run: Runner = defaultRunner, mint: TokenMinter = defaultMinter, useToken = false): Promise { + const url = process.env.INSTA_MCP_URL || DEFAULT_MCP_URL + if (!(await run('claude', ['--version'])).ok) return // no Claude Code on this machine + if ((await run('claude', ['mcp', 'get', MCP_SERVER_NAME])).ok) { + info(`✓ MCP — ${MCP_SERVER_NAME} already registered with Claude Code`) + return + } + const args = ['mcp', 'add', '--transport', 'http', '--scope', 'user', MCP_SERVER_NAME, url] + if (useToken) { + const token = await mint() + if (!token) { + info(' MCP not registered (--mcp-token needs a login) — run `insta login`, then `insta setup agent --mcp-token` again') + return + } + args.push('--header', `Authorization: Bearer ${token}`) + } + const res = await run('claude', args) + if (res.ok) { + info(`✓ MCP — ${MCP_SERVER_NAME} registered with Claude Code (\`claude mcp list\` to verify)`) + if (!useToken) info(' first use: run `/mcp` in Claude Code and authorize in the browser (headless machines: `insta setup agent --mcp-token`)') + } else { + info(` MCP registration failed — add manually:\n claude mcp add --transport http ${MCP_SERVER_NAME} ${url}`) + } +} + +export async function setupAgent( + opts: { yes?: boolean; mcpToken?: boolean }, + run: Runner = defaultRunner, + mint?: TokenMinter, + installConfigs: (agent?: string) => Promise = installAgentConfigs, +): Promise { if (!opts.yes && !process.stdout.isTTY) { info('non-interactive shell — assuming -y') } @@ -115,4 +174,7 @@ export async function setupAgent(opts: { yes?: boolean }, run: Runner = defaultR } info(summarizeInstall(res.output ?? '')) info(' every coding agent on this machine now knows InstaCloud (review skills before use — they run with full permissions).') + await registerMcp(run, mint, !!opts.mcpToken) + const others = await installConfigs() + if (others.length) info(`✓ MCP — also configured for ${others.join(', ')} (restart those tools to pick it up)`) } diff --git a/src/index.ts b/src/index.ts index 921b5c9..895154b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { ApiError } from './api.js' import { die } from './util.js' import * as auth from './commands/auth.js' import * as setup from './commands/setup.js' +import * as mcp from './commands/mcp.js' import * as runCmd from './commands/run.js' import * as org from './commands/org.js' import * as project from './commands/project.js' @@ -68,8 +69,16 @@ program.command('run [args...]').description('Run a command with the branc const setupCmd = program.command('setup').description('Set up this machine for InstaCloud agent workflows') setupCmd.command('agent').description('Install the insta skill user-globally for all coding agents') .option('-y, --yes', 'non-interactive') + .option('--mcp-token', 'register the MCP server with a minted insta_ API token instead of OAuth (headless machines / CI)') .action(guard((o) => setup.setupAgent(o))) +// ---- MCP server integration ---- +const mcpCmd = program.command('mcp').description('insta-cloud remote MCP server integration') +mcpCmd.command('install').description('Register the remote MCP server with coding agents (default: Claude Code + all detected)') + .option('--agent ', 'one agent: claude-code, cursor, codex, opencode, copilot, factory-droid') + .option('--mcp-token', 'claude-code only: minted insta_ API token instead of OAuth (headless machines / CI)') + .action(guard((o) => mcp.mcpInstall(o))) + // ---- org ---- const orgCmd = program.command('org').description('Manage organizations') orgCmd.command('list').option('--json').action(guard((o) => org.orgList(o))) diff --git a/test/mcp-install.test.ts b/test/mcp-install.test.ts new file mode 100644 index 0000000..111bce6 --- /dev/null +++ b/test/mcp-install.test.ts @@ -0,0 +1,80 @@ +import { test, expect } from 'vitest' +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { configPath, detectAgents, installFor, renderCodexConfig } from '../src/commands/mcp.js' +import { MCP_SERVER_NAME, DEFAULT_MCP_URL } from '../src/commands/setup.js' + +async function tmpHome(): Promise { return fs.mkdtemp(path.join(os.tmpdir(), 'insta-mcp-test-')) } + +test('cursor: fresh install writes mcpServers entry with just a url (OAuth, no credential)', async () => { + const home = await tmpHome() + expect(await installFor('cursor', home, DEFAULT_MCP_URL)).toBe('installed') + const cfg = JSON.parse(await fs.readFile(configPath('cursor', home), 'utf8')) + expect(cfg.mcpServers[MCP_SERVER_NAME]).toEqual({ url: DEFAULT_MCP_URL }) +}) + +test('cursor: merge preserves existing servers and is idempotent', async () => { + const home = await tmpHome() + const file = configPath('cursor', home) + await fs.mkdir(path.dirname(file), { recursive: true }) + await fs.writeFile(file, JSON.stringify({ mcpServers: { other: { url: 'https://x' } } })) + expect(await installFor('cursor', home, DEFAULT_MCP_URL)).toBe('installed') + expect(await installFor('cursor', home, DEFAULT_MCP_URL)).toBe('already') + const cfg = JSON.parse(await fs.readFile(file, 'utf8')) + expect(cfg.mcpServers.other).toEqual({ url: 'https://x' }) + expect(cfg.mcpServers[MCP_SERVER_NAME].url).toBe(DEFAULT_MCP_URL) +}) + +test('opencode: remote entry under `mcp` with $schema default', async () => { + const home = await tmpHome() + expect(await installFor('opencode', home, DEFAULT_MCP_URL)).toBe('installed') + const cfg = JSON.parse(await fs.readFile(configPath('opencode', home), 'utf8')) + expect(cfg.mcp[MCP_SERVER_NAME]).toEqual({ type: 'remote', url: DEFAULT_MCP_URL, enabled: true }) + expect(cfg.$schema).toBe('https://opencode.ai/config.json') +}) + +test('copilot and factory-droid: http entries with their extra fields', async () => { + const home = await tmpHome() + await installFor('copilot', home, DEFAULT_MCP_URL) + await installFor('factory-droid', home, DEFAULT_MCP_URL) + const cop = JSON.parse(await fs.readFile(configPath('copilot', home), 'utf8')) + const fac = JSON.parse(await fs.readFile(configPath('factory-droid', home), 'utf8')) + expect(cop.mcpServers[MCP_SERVER_NAME]).toEqual({ type: 'http', url: DEFAULT_MCP_URL, tools: ['*'] }) + expect(fac.mcpServers[MCP_SERVER_NAME]).toEqual({ type: 'http', url: DEFAULT_MCP_URL, disabled: false }) +}) + +test('codex: TOML table appended once, existing content preserved', async () => { + const home = await tmpHome() + const file = configPath('codex', home) + await fs.mkdir(path.dirname(file), { recursive: true }) + await fs.writeFile(file, 'model = "gpt-5"\n') + expect(await installFor('codex', home, DEFAULT_MCP_URL)).toBe('installed') + expect(await installFor('codex', home, DEFAULT_MCP_URL)).toBe('already') + const out = await fs.readFile(file, 'utf8') + expect(out).toContain('model = "gpt-5"') + expect(out).toContain(`[mcp_servers.${MCP_SERVER_NAME}]`) + expect(out).toContain(`url = "${DEFAULT_MCP_URL}"`) +}) + +test('renderCodexConfig appends a newline separator when the file lacks one', () => { + const out = renderCodexConfig('a = 1', 'https://u')! + expect(out.startsWith('a = 1\n')).toBe(true) +}) + +test('unparseable JSON config is skipped, never clobbered', async () => { + const home = await tmpHome() + const file = configPath('cursor', home) + await fs.mkdir(path.dirname(file), { recursive: true }) + await fs.writeFile(file, '{ this is not json') + expect(await installFor('cursor', home, DEFAULT_MCP_URL)).toBe('skipped') + expect(await fs.readFile(file, 'utf8')).toBe('{ this is not json') +}) + +test('detectAgents only reports agents whose config dir exists', async () => { + const home = await tmpHome() + expect(detectAgents(home)).toEqual([]) + await fs.mkdir(path.join(home, '.cursor'), { recursive: true }) + await fs.mkdir(path.join(home, '.codex'), { recursive: true }) + expect(detectAgents(home)).toEqual(['cursor', 'codex']) +}) diff --git a/test/setup-agent.test.ts b/test/setup-agent.test.ts index 7cb5922..d8c4d6a 100644 --- a/test/setup-agent.test.ts +++ b/test/setup-agent.test.ts @@ -1,10 +1,9 @@ import { test, expect } from 'vitest' -import { setupAgent, SETUP_ARGS } from '../src/commands/setup.js' +import { setupAgent, registerMcp, SETUP_ARGS, MCP_SERVER_NAME, DEFAULT_MCP_URL } from '../src/commands/setup.js' test('setup agent installs the insta skill user-globally for all agents', async () => { const runs: string[][] = [] - await setupAgent({ yes: true }, async (_cmd, args) => { runs.push(args); return { ok: true, output: '' } }) - expect(runs).toHaveLength(1) + await setupAgent({ yes: true }, async (_cmd, args) => { runs.push(args); return { ok: true, output: '' } }, undefined, async () => []) expect(runs[0]).toEqual(SETUP_ARGS) expect(SETUP_ARGS).toContain('-g') // user-level, not per-project expect(SETUP_ARGS).toContain('*') // every agent dir @@ -14,7 +13,70 @@ test('setup agent installs the insta skill user-globally for all agents', async test('failed install sets exit code and prints the manual fallback', async () => { const prev = process.exitCode - await setupAgent({ yes: true }, async () => ({ ok: false, output: '' })) + const runs: string[][] = [] + await setupAgent({ yes: true }, async (_cmd, args) => { runs.push(args); return { ok: false, output: '' } }, undefined, async () => []) expect(process.exitCode).toBe(1) + expect(runs).toHaveLength(1) // MCP registration is skipped when the skill install fails process.exitCode = prev }) + +test('setup agent skips MCP registration cleanly when there is no claude binary', async () => { + const cmds: string[] = [] + await setupAgent({ yes: true }, async (cmd) => { + cmds.push(cmd) + if (cmd === 'claude') return { ok: false, output: '' } // `claude --version` fails => not installed + return { ok: true, output: '' } + }, undefined, async () => []) + expect(cmds.filter((c) => c === 'claude')).toHaveLength(1) // only the version probe +}) + +test('registerMcp is idempotent — an existing registration is left alone (no token minted)', async () => { + const runs: string[][] = [] + let minted = 0 + await registerMcp( + async (_cmd, args) => { runs.push(args); return { ok: true, output: '' } }, + async () => { minted++; return 'insta_x_y' }, + ) + expect(runs.map((a) => a.join(' '))).toEqual(['--version', `mcp get ${MCP_SERVER_NAME}`]) + expect(minted).toBe(0) +}) + +test('registerMcp defaults to OAuth: adds the server with NO auth header and mints nothing', async () => { + const runs: string[][] = [] + let minted = 0 + await registerMcp( + async (_cmd, args) => { + runs.push(args) + // version probe ok; `mcp get` says not registered; `mcp add` ok + return { ok: !(args[0] === 'mcp' && args[1] === 'get'), output: '' } + }, + async () => { minted++; return 'insta_x_y' }, + ) + const add = runs.find((a) => a[0] === 'mcp' && a[1] === 'add')! + expect(add).toBeDefined() + expect(add.join(' ')).toContain(`--transport http --scope user ${MCP_SERVER_NAME} ${DEFAULT_MCP_URL}`) + expect(add).not.toContain('--header') // OAuth flow — no static credential on disk + expect(minted).toBe(0) +}) + +test('registerMcp --mcp-token mints a durable token into the Authorization header (headless)', async () => { + const runs: string[][] = [] + await registerMcp( + async (_cmd, args) => { runs.push(args); return { ok: !(args[0] === 'mcp' && args[1] === 'get'), output: '' } }, + async () => 'insta_abc_secret', + true, + ) + const add = runs.find((a) => a[0] === 'mcp' && a[1] === 'add')! + expect(add.join(' ')).toContain(`--transport http --scope user ${MCP_SERVER_NAME} ${DEFAULT_MCP_URL}`) + expect(add.join(' ')).toContain('Authorization: Bearer insta_abc_secret') +}) + +test('registerMcp --mcp-token prints the login hint instead of registering when no token can be minted', async () => { + const runs: string[][] = [] + await registerMcp( + async (_cmd, args) => { runs.push(args); return { ok: !(args[0] === 'mcp' && args[1] === 'get'), output: '' } }, + async () => null, // not logged in + true, + ) + expect(runs.some((a) => a[0] === 'mcp' && a[1] === 'add')).toBe(false) +})