From d887c2662169fce16dface492a6ad0ce7e334dc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 23:11:54 +0000 Subject: [PATCH 1/2] feat: add setup command wiring the CLI into coding agents Installs the bundled agent skill to ~/.claude/skills/polylane-cli/SKILL.md (embedded at codegen time so the published bundle stays network-free) and registers the Polylane MCP server (https://mcp.polylane.com/mcp) in ~/.claude.json. --project targets ./.claude/skills and ./.mcp.json instead. Idempotent and auth-free so the curl installer can run it as its final step. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0149UgXkD5n9z2yKrzCMgCTC --- README.md | 9 +++ codegen/generate-skill.ts | 11 +++ codegen/index.ts | 6 ++ src/commands/index.ts | 2 + src/commands/setup.ts | 160 ++++++++++++++++++++++++++++++++++++++ src/main.ts | 1 + src/registry.ts | 1 + test/resolve.test.ts | 1 + test/setup.test.ts | 149 +++++++++++++++++++++++++++++++++++ 9 files changed, 340 insertions(+) create mode 100644 codegen/generate-skill.ts create mode 100644 src/commands/setup.ts create mode 100644 test/setup.test.ts diff --git a/README.md b/README.md index 2601a55..310d9f0 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,15 @@ POLYLANE_VERSION=v0.1.0 curl -fsSL https://polylane.com/install.sh | bash $env:POLYLANE_VERSION='v0.1.0'; irm https://polylane.com/install.ps1 | iex ``` +### Wire it into your coding agent + +```bash +polylane setup # home directory: ~/.claude/skills + ~/.claude.json +polylane setup --project # this project only: ./.claude/skills + ./.mcp.json +``` + +`setup` installs the bundled [agent skill](skill/SKILL.md) and registers the Polylane MCP server (`https://mcp.polylane.com/mcp`) so agents like Claude Code can drive the platform. It is idempotent: the skill is overwritten with the version bundled in the CLI, and an existing MCP entry is left untouched. The curl installer runs it automatically after install (opt out with `--no-setup`). + ## Quick start ```bash diff --git a/codegen/generate-skill.ts b/codegen/generate-skill.ts new file mode 100644 index 0000000..49e2d2a --- /dev/null +++ b/codegen/generate-skill.ts @@ -0,0 +1,11 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +export function generateSkill(): string { + const skillPath = join(process.cwd(), 'skill', 'SKILL.md'); + const content = readFileSync(skillPath, 'utf-8'); + return `// Auto-generated from skill/SKILL.md — do not edit +/* eslint-disable */ +export const SKILL_MD = ${JSON.stringify(content)}; +`; +} diff --git a/codegen/index.ts b/codegen/index.ts index 51fbbdd..59a933b 100644 --- a/codegen/index.ts +++ b/codegen/index.ts @@ -5,6 +5,7 @@ import { parseSpec } from './parse-spec'; import { generateTypes } from './generate-types'; import { generateClient } from './generate-client'; import { generateCommandMeta } from './generate-commands'; +import { generateSkill } from './generate-skill'; import { loadEnvLocal } from './env-local'; loadEnvLocal(); @@ -14,6 +15,11 @@ const GENERATED_DIR = join(process.cwd(), 'src', 'generated'); export async function generateAll(source?: string): Promise { const specSource = source ?? resolveSpecSource(); const start = Date.now(); + + mkdirSync(GENERATED_DIR, { recursive: true }); + writeFileSync(join(GENERATED_DIR, 'skill.ts'), generateSkill(), 'utf-8'); + process.stderr.write(`[codegen] wrote skill.ts (from skill/SKILL.md)\n`); + process.stderr.write(`[codegen] fetching spec: ${specSource}\n`); const spec = await fetchSpec(specSource); process.stderr.write(`[codegen] parsed ${Object.keys(spec.paths).length} paths\n`); diff --git a/src/commands/index.ts b/src/commands/index.ts index 2ba80dc..b1fe891 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -2,6 +2,7 @@ import { registry } from '../registry'; import { authCommands } from './auth'; import { configCommands } from './config'; import { helpCommand } from './help'; +import { setupCommand } from './setup'; import { updateCommand } from './update'; import { feedCommands } from './feed'; import { issueCommands } from './issue'; @@ -48,6 +49,7 @@ export function registerAllCommands(): void { ...apiCommands, ...telemetryCommands, helpCommand, + setupCommand, updateCommand, ]; diff --git a/src/commands/setup.ts b/src/commands/setup.ts new file mode 100644 index 0000000..46b1f94 --- /dev/null +++ b/src/commands/setup.ts @@ -0,0 +1,160 @@ +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; + +import type { Command } from '../command'; +import type { Config } from '../config/schema'; +import { tryResolveCredential } from '../auth/resolver'; +import { ensureDir } from '../utils/fs'; +import { getArgBoolean } from './helpers'; +import { SKILL_MD } from '../generated/skill'; + +export const MCP_SERVER_NAME = 'polylane'; +export const MCP_SERVER_URL = process.env.POLYLANE_MCP_URL || 'https://mcp.polylane.com/mcp'; +export const SKILL_DIRECTORY_NAME = 'polylane-cli'; + +export type SkillInstallAction = 'created' | 'updated' | 'unchanged'; + +export interface SkillInstallResult { + path: string; + action: SkillInstallAction; +} + +export function installSkill(skillFilePath: string): SkillInstallResult { + if (existsSync(skillFilePath)) { + const current = readFileSync(skillFilePath, 'utf-8'); + if (current === SKILL_MD) { + return { path: skillFilePath, action: 'unchanged' }; + } + writeFileSync(skillFilePath, SKILL_MD, 'utf-8'); + return { path: skillFilePath, action: 'updated' }; + } + ensureDir(dirname(skillFilePath)); + writeFileSync(skillFilePath, SKILL_MD, 'utf-8'); + return { path: skillFilePath, action: 'created' }; +} + +export type McpRegisterAction = 'registered' | 'unchanged' | 'skipped'; + +export interface McpRegisterResult { + path: string; + action: McpRegisterAction; + reason?: string; +} + +export function registerMcpServer(agentConfigPath: string): McpRegisterResult { + let root: Record = {}; + if (existsSync(agentConfigPath)) { + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(agentConfigPath, 'utf-8')); + } catch { + return { + path: agentConfigPath, + action: 'skipped', + reason: 'existing file is not valid JSON', + }; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return { + path: agentConfigPath, + action: 'skipped', + reason: 'existing file is not a JSON object', + }; + } + root = parsed as Record; + } + + const existingServers = root.mcpServers; + const servers: Record = + typeof existingServers === 'object' && existingServers !== null && !Array.isArray(existingServers) + ? (existingServers as Record) + : {}; + root.mcpServers = servers; + + if (servers[MCP_SERVER_NAME] !== undefined) { + return { path: agentConfigPath, action: 'unchanged' }; + } + + servers[MCP_SERVER_NAME] = { type: 'http', url: MCP_SERVER_URL }; + ensureDir(dirname(agentConfigPath)); + writeFileSync(agentConfigPath, JSON.stringify(root, null, 2) + '\n', 'utf-8'); + return { path: agentConfigPath, action: 'registered' }; +} + +export function skillInstallPath(baseDir: string): string { + return join(baseDir, '.claude', 'skills', SKILL_DIRECTORY_NAME, 'SKILL.md'); +} + +export function userAgentConfigPath(baseDir: string): string { + return join(baseDir, '.claude.json'); +} + +export function projectAgentConfigPath(baseDir: string): string { + return join(baseDir, '.mcp.json'); +} + +const SKILL_ACTION_LABEL: Record = { + created: 'Installed agent skill', + updated: 'Updated agent skill', + unchanged: 'Agent skill already up to date', +}; + +const MCP_ACTION_LABEL: Record = { + registered: 'Registered MCP server', + unchanged: 'MCP server already registered', + skipped: 'Skipped MCP registration', +}; + +export const setupCommand: Command = { + name: 'setup', + description: 'Wire the CLI into coding agents (agent skill + MCP server)', + options: [ + { + flag: '--project', + description: 'Install into the current project (./.claude/skills + ./.mcp.json) instead of the home directory', + type: 'boolean', + }, + ], + examples: [ + 'polylane setup', + 'polylane setup --project', + 'polylane setup --dry-run', + ], + async execute(config: Config, _flags, args: Record): Promise { + const project = getArgBoolean(args, 'project') === true; + const baseDir = project ? process.cwd() : homedir(); + const skillPath = skillInstallPath(baseDir); + const agentConfigPath = project + ? projectAgentConfigPath(baseDir) + : userAgentConfigPath(baseDir); + + const say = (line: string): void => { + if (!config.quiet) process.stderr.write(line + '\n'); + }; + + if (config.dryRun) { + say(`Would install agent skill: ${skillPath}`); + say(`Would register MCP server "${MCP_SERVER_NAME}" (${MCP_SERVER_URL}) in: ${agentConfigPath}`); + return; + } + + const skill = installSkill(skillPath); + say(`${SKILL_ACTION_LABEL[skill.action]}: ${skill.path}`); + + const mcp = registerMcpServer(agentConfigPath); + if (mcp.action === 'skipped') { + say(`${MCP_ACTION_LABEL[mcp.action]}: ${mcp.path} (${mcp.reason ?? 'unknown reason'})`); + say(`Register it manually: claude mcp add --transport http ${MCP_SERVER_NAME} ${MCP_SERVER_URL}`); + } else { + say(`${MCP_ACTION_LABEL[mcp.action]}: ${MCP_SERVER_URL} in ${mcp.path}`); + } + + const credential = await tryResolveCredential(config); + if (credential) { + say('Authentication: signed in.'); + } else { + say('Authentication: not signed in. Run `polylane auth login` to authenticate.'); + } + }, +}; diff --git a/src/main.ts b/src/main.ts index 7aff75d..4d4d870 100644 --- a/src/main.ts +++ b/src/main.ts @@ -33,6 +33,7 @@ const NO_AUTH_COMMANDS = new Set([ 'telemetry disable', 'integration catalog', 'help', + 'setup', 'update', 'version', 'api list', diff --git a/src/registry.ts b/src/registry.ts index 1759833..21f9b5b 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -32,6 +32,7 @@ const RESOURCE_ORDER: Record = { cloud: { name: 'cloud', description: 'Cloud accounts (aws, cloudflare, fly, render, vercel)', order: 69 }, workspace: { name: 'workspace', description: 'Workspaces', order: 70 }, auth: { name: 'auth', description: 'Authentication (login, status, logout)', order: 80 }, + setup: { name: 'setup', description: 'Wire the CLI into coding agents (agent skill + MCP server)', order: 85 }, config: { name: 'config', description: 'CLI configuration', order: 90 }, telemetry: { name: 'telemetry', description: 'Anonymous usage telemetry (status/enable/disable)', order: 95 }, api: { name: 'api', description: 'Raw API access (advanced)', order: 100 }, diff --git a/test/resolve.test.ts b/test/resolve.test.ts index cd29091..b585977 100644 --- a/test/resolve.test.ts +++ b/test/resolve.test.ts @@ -54,6 +54,7 @@ describe('command resolution', () => { 'note', 'repo', 'service', + 'setup', 'skill', 'telemetry', 'thread', diff --git a/test/setup.test.ts b/test/setup.test.ts new file mode 100644 index 0000000..66468e3 --- /dev/null +++ b/test/setup.test.ts @@ -0,0 +1,149 @@ +import { describe, it, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { + installSkill, + registerMcpServer, + skillInstallPath, + userAgentConfigPath, + projectAgentConfigPath, + MCP_SERVER_NAME, + MCP_SERVER_URL, +} from '../src/commands/setup'; +import { SKILL_MD } from '../src/generated/skill'; + +let tempDir: string; + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'polylane-setup-test-')); +}); + +afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('skill and agent config paths', () => { + it('places the skill under .claude/skills/polylane-cli', () => { + assert.equal( + skillInstallPath('/home/user'), + join('/home/user', '.claude', 'skills', 'polylane-cli', 'SKILL.md') + ); + }); + + it('places the user agent config at .claude.json', () => { + assert.equal(userAgentConfigPath('/home/user'), join('/home/user', '.claude.json')); + }); + + it('places the project agent config at .mcp.json', () => { + assert.equal(projectAgentConfigPath('/repo'), join('/repo', '.mcp.json')); + }); +}); + +describe('installSkill', () => { + it('creates the skill file with the bundled content', () => { + const path = skillInstallPath(tempDir); + const result = installSkill(path); + assert.equal(result.action, 'created'); + assert.equal(result.path, path); + assert.equal(readFileSync(path, 'utf-8'), SKILL_MD); + }); + + it('reports unchanged when the installed skill matches', () => { + const path = skillInstallPath(tempDir); + installSkill(path); + const result = installSkill(path); + assert.equal(result.action, 'unchanged'); + assert.equal(readFileSync(path, 'utf-8'), SKILL_MD); + }); + + it('overwrites a stale skill file', () => { + const path = skillInstallPath(tempDir); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, 'old skill content', 'utf-8'); + const result = installSkill(path); + assert.equal(result.action, 'updated'); + assert.equal(readFileSync(path, 'utf-8'), SKILL_MD); + }); +}); + +describe('registerMcpServer', () => { + it('creates the config file with the polylane server entry', () => { + const path = join(tempDir, '.claude.json'); + const result = registerMcpServer(path); + assert.equal(result.action, 'registered'); + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as { + mcpServers: Record; + }; + assert.deepEqual(parsed.mcpServers[MCP_SERVER_NAME], { + type: 'http', + url: MCP_SERVER_URL, + }); + }); + + it('preserves unrelated keys and servers', () => { + const path = join(tempDir, '.claude.json'); + writeFileSync( + path, + JSON.stringify({ + theme: 'dark', + mcpServers: { other: { type: 'http', url: 'https://example.com/mcp' } }, + }), + 'utf-8' + ); + const result = registerMcpServer(path); + assert.equal(result.action, 'registered'); + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as { + theme: string; + mcpServers: Record; + }; + assert.equal(parsed.theme, 'dark'); + assert.deepEqual(parsed.mcpServers.other, { type: 'http', url: 'https://example.com/mcp' }); + assert.deepEqual(parsed.mcpServers[MCP_SERVER_NAME], { + type: 'http', + url: MCP_SERVER_URL, + }); + }); + + it('leaves an existing polylane entry untouched', () => { + const path = join(tempDir, '.claude.json'); + const existing = { + mcpServers: { + [MCP_SERVER_NAME]: { + type: 'http', + url: MCP_SERVER_URL, + headers: { 'x-api-key': 'sk_custom' }, + }, + }, + }; + const original = JSON.stringify(existing); + writeFileSync(path, original, 'utf-8'); + const result = registerMcpServer(path); + assert.equal(result.action, 'unchanged'); + assert.equal(readFileSync(path, 'utf-8'), original); + }); + + it('skips and leaves the file alone when it is not valid JSON', () => { + const path = join(tempDir, '.claude.json'); + writeFileSync(path, '{ not json', 'utf-8'); + const result = registerMcpServer(path); + assert.equal(result.action, 'skipped'); + assert.equal(readFileSync(path, 'utf-8'), '{ not json'); + }); + + it('skips when the file holds a JSON array instead of an object', () => { + const path = join(tempDir, '.claude.json'); + writeFileSync(path, '[]', 'utf-8'); + const result = registerMcpServer(path); + assert.equal(result.action, 'skipped'); + assert.equal(readFileSync(path, 'utf-8'), '[]'); + }); + + it('creates parent directories when needed', () => { + const path = join(tempDir, 'nested', '.mcp.json'); + const result = registerMcpServer(path); + assert.equal(result.action, 'registered'); + assert.ok(existsSync(path)); + }); +}); From f8c8fb86424ec41fb4374ab242216401f72f226c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 23:23:59 +0000 Subject: [PATCH 2/2] feat: configure every detected coding agent in setup Replaces the Claude-only wiring with an agent registry grounded in the docs' supported-agent list: Claude Code, Cursor, OpenCode, and Codex CLI get the bundled skill plus MCP registration in their own config formats; Windsurf, Zed, and VS Code get MCP registration only. --agent targets specific agents; detection otherwise picks up what is installed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0149UgXkD5n9z2yKrzCMgCTC --- README.md | 21 ++- src/commands/setup.ts | 354 ++++++++++++++++++++++++++++++++---------- test/setup.test.ts | 288 +++++++++++++++++++++++++--------- 3 files changed, 504 insertions(+), 159 deletions(-) diff --git a/README.md b/README.md index 310d9f0..f5cc58d 100644 --- a/README.md +++ b/README.md @@ -57,14 +57,27 @@ POLYLANE_VERSION=v0.1.0 curl -fsSL https://polylane.com/install.sh | bash $env:POLYLANE_VERSION='v0.1.0'; irm https://polylane.com/install.ps1 | iex ``` -### Wire it into your coding agent +### Wire it into your coding agents ```bash -polylane setup # home directory: ~/.claude/skills + ~/.claude.json -polylane setup --project # this project only: ./.claude/skills + ./.mcp.json +polylane setup # configure every detected agent +polylane setup --agent claude --agent cursor # target specific agents +polylane setup --project # this project instead of the home directory ``` -`setup` installs the bundled [agent skill](skill/SKILL.md) and registers the Polylane MCP server (`https://mcp.polylane.com/mcp`) so agents like Claude Code can drive the platform. It is idempotent: the skill is overwritten with the version bundled in the CLI, and an existing MCP entry is left untouched. The curl installer runs it automatically after install (opt out with `--no-setup`). +`setup` detects installed coding agents and configures each one: it installs the bundled [agent skill](skill/SKILL.md) where the agent supports skills, and registers the Polylane MCP server (`https://mcp.polylane.com/mcp`) in the agent's own config format. + +| Agent | Agent skill | MCP server | +|---|---|---| +| Claude Code | `~/.claude/skills/polylane-cli/` | `~/.claude.json` | +| Cursor | `~/.cursor/skills/polylane-cli/` | `~/.cursor/mcp.json` | +| OpenCode | `~/.config/opencode/skills/polylane-cli/` | `~/.config/opencode/opencode.json` | +| Codex CLI | `~/.codex/skills/polylane-cli/` | `~/.codex/config.toml` | +| Windsurf | — | `~/.codeium/windsurf/mcp_config.json` | +| Zed | — | `~/.config/zed/settings.json` | +| VS Code | — | user profile `mcp.json` | + +It is idempotent: the skill is overwritten with the version bundled in the CLI, an existing MCP entry is left untouched, and unreadable config files are skipped with a manual pointer instead of clobbered. The curl installer runs it automatically after install (opt out with `--no-setup`). ## Quick start diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 46b1f94..af3e55c 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -5,149 +5,347 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import type { Command } from '../command'; import type { Config } from '../config/schema'; import { tryResolveCredential } from '../auth/resolver'; +import { CLIError } from '../errors/base'; +import { ExitCode } from '../errors/codes'; import { ensureDir } from '../utils/fs'; -import { getArgBoolean } from './helpers'; +import { getArgArray, getArgBoolean } from './helpers'; import { SKILL_MD } from '../generated/skill'; export const MCP_SERVER_NAME = 'polylane'; export const MCP_SERVER_URL = process.env.POLYLANE_MCP_URL || 'https://mcp.polylane.com/mcp'; export const SKILL_DIRECTORY_NAME = 'polylane-cli'; -export type SkillInstallAction = 'created' | 'updated' | 'unchanged'; +export type WriteAction = 'created' | 'updated' | 'unchanged' | 'skipped'; -export interface SkillInstallResult { +export interface WriteOutcome { + label: string; path: string; - action: SkillInstallAction; + action: WriteAction; + detail?: string; + needsManualStep?: boolean; } -export function installSkill(skillFilePath: string): SkillInstallResult { - if (existsSync(skillFilePath)) { - const current = readFileSync(skillFilePath, 'utf-8'); +export function writeSkillFile(path: string, dryRun = false): WriteOutcome { + const label = 'agent skill'; + if (existsSync(path)) { + const current = readFileSync(path, 'utf-8'); if (current === SKILL_MD) { - return { path: skillFilePath, action: 'unchanged' }; + return { label, path, action: 'unchanged' }; } - writeFileSync(skillFilePath, SKILL_MD, 'utf-8'); - return { path: skillFilePath, action: 'updated' }; + if (!dryRun) writeFileSync(path, SKILL_MD, 'utf-8'); + return { label, path, action: 'updated' }; } - ensureDir(dirname(skillFilePath)); - writeFileSync(skillFilePath, SKILL_MD, 'utf-8'); - return { path: skillFilePath, action: 'created' }; -} - -export type McpRegisterAction = 'registered' | 'unchanged' | 'skipped'; - -export interface McpRegisterResult { - path: string; - action: McpRegisterAction; - reason?: string; + if (!dryRun) { + ensureDir(dirname(path)); + writeFileSync(path, SKILL_MD, 'utf-8'); + } + return { label, path, action: 'created' }; } -export function registerMcpServer(agentConfigPath: string): McpRegisterResult { +export function upsertJsonEntry( + path: string, + keyPath: string[], + value: unknown, + dryRun = false +): WriteOutcome { + const label = 'MCP server'; + const existed = existsSync(path); let root: Record = {}; - if (existsSync(agentConfigPath)) { + if (existed) { let parsed: unknown; try { - parsed = JSON.parse(readFileSync(agentConfigPath, 'utf-8')); + parsed = JSON.parse(readFileSync(path, 'utf-8')); } catch { - return { - path: agentConfigPath, - action: 'skipped', - reason: 'existing file is not valid JSON', - }; + return { label, path, action: 'skipped', detail: 'existing file is not valid JSON', needsManualStep: true }; } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - return { - path: agentConfigPath, - action: 'skipped', - reason: 'existing file is not a JSON object', - }; + return { label, path, action: 'skipped', detail: 'existing file is not a JSON object', needsManualStep: true }; } root = parsed as Record; } - const existingServers = root.mcpServers; - const servers: Record = - typeof existingServers === 'object' && existingServers !== null && !Array.isArray(existingServers) - ? (existingServers as Record) - : {}; - root.mcpServers = servers; + let node = root; + for (const key of keyPath.slice(0, -1)) { + const child = node[key]; + if (typeof child === 'object' && child !== null && !Array.isArray(child)) { + node = child as Record; + } else if (child === undefined) { + const fresh: Record = {}; + node[key] = fresh; + node = fresh; + } else { + return { label, path, action: 'skipped', detail: `"${key}" is not a JSON object`, needsManualStep: true }; + } + } + + const leaf = keyPath[keyPath.length - 1]!; + if (node[leaf] !== undefined) { + return { label, path, action: 'unchanged' }; + } - if (servers[MCP_SERVER_NAME] !== undefined) { - return { path: agentConfigPath, action: 'unchanged' }; + node[leaf] = value; + if (!dryRun) { + ensureDir(dirname(path)); + writeFileSync(path, JSON.stringify(root, null, 2) + '\n', 'utf-8'); } + return { label, path, action: existed ? 'updated' : 'created' }; +} - servers[MCP_SERVER_NAME] = { type: 'http', url: MCP_SERVER_URL }; - ensureDir(dirname(agentConfigPath)); - writeFileSync(agentConfigPath, JSON.stringify(root, null, 2) + '\n', 'utf-8'); - return { path: agentConfigPath, action: 'registered' }; +export function upsertTomlSection( + path: string, + sectionHeader: string, + sectionBody: string, + dryRun = false +): WriteOutcome { + const label = 'MCP server'; + if (existsSync(path)) { + const current = readFileSync(path, 'utf-8'); + if (current.includes(sectionHeader)) { + return { label, path, action: 'unchanged' }; + } + if (!dryRun) { + const separator = current.endsWith('\n') || current === '' ? '' : '\n'; + writeFileSync(path, `${current}${separator}\n${sectionHeader}\n${sectionBody}`, 'utf-8'); + } + return { label, path, action: 'updated' }; + } + if (!dryRun) { + ensureDir(dirname(path)); + writeFileSync(path, `${sectionHeader}\n${sectionBody}`, 'utf-8'); + } + return { label, path, action: 'created' }; } -export function skillInstallPath(baseDir: string): string { - return join(baseDir, '.claude', 'skills', SKILL_DIRECTORY_NAME, 'SKILL.md'); +export function vscodeUserDirectory(home: string): string { + if (process.platform === 'darwin') { + return join(home, 'Library', 'Application Support', 'Code', 'User'); + } + if (process.platform === 'win32') { + const roaming = process.env.APPDATA || join(home, 'AppData', 'Roaming'); + return join(roaming, 'Code', 'User'); + } + return join(home, '.config', 'Code', 'User'); } -export function userAgentConfigPath(baseDir: string): string { - return join(baseDir, '.claude.json'); +const HTTP_SERVER_ENTRY = { type: 'http', url: MCP_SERVER_URL }; +const CODEX_SECTION_HEADER = `[mcp_servers.${MCP_SERVER_NAME}]`; +const CODEX_SECTION_BODY = `url = "${MCP_SERVER_URL}"\n`; + +export interface AgentSetup { + id: string; + name: string; + detect(home: string): boolean; + user(home: string, dryRun: boolean): WriteOutcome[]; + project?(projectDir: string, dryRun: boolean): WriteOutcome[]; } -export function projectAgentConfigPath(baseDir: string): string { - return join(baseDir, '.mcp.json'); +function skillFile(baseDir: string): string { + return join(baseDir, 'skills', SKILL_DIRECTORY_NAME, 'SKILL.md'); } -const SKILL_ACTION_LABEL: Record = { - created: 'Installed agent skill', - updated: 'Updated agent skill', - unchanged: 'Agent skill already up to date', -}; +export const AGENTS: AgentSetup[] = [ + { + id: 'claude', + name: 'Claude Code', + detect: (home) => existsSync(join(home, '.claude')) || existsSync(join(home, '.claude.json')), + user: (home, dryRun) => [ + writeSkillFile(skillFile(join(home, '.claude')), dryRun), + upsertJsonEntry(join(home, '.claude.json'), ['mcpServers', MCP_SERVER_NAME], HTTP_SERVER_ENTRY, dryRun), + ], + project: (projectDir, dryRun) => [ + writeSkillFile(skillFile(join(projectDir, '.claude')), dryRun), + upsertJsonEntry(join(projectDir, '.mcp.json'), ['mcpServers', MCP_SERVER_NAME], HTTP_SERVER_ENTRY, dryRun), + ], + }, + { + id: 'cursor', + name: 'Cursor', + detect: (home) => existsSync(join(home, '.cursor')), + user: (home, dryRun) => [ + writeSkillFile(skillFile(join(home, '.cursor')), dryRun), + upsertJsonEntry(join(home, '.cursor', 'mcp.json'), ['mcpServers', MCP_SERVER_NAME], HTTP_SERVER_ENTRY, dryRun), + ], + project: (projectDir, dryRun) => [ + writeSkillFile(skillFile(join(projectDir, '.cursor')), dryRun), + upsertJsonEntry(join(projectDir, '.cursor', 'mcp.json'), ['mcpServers', MCP_SERVER_NAME], HTTP_SERVER_ENTRY, dryRun), + ], + }, + { + id: 'opencode', + name: 'OpenCode', + detect: (home) => existsSync(join(home, '.config', 'opencode')), + user: (home, dryRun) => [ + writeSkillFile(skillFile(join(home, '.config', 'opencode')), dryRun), + upsertJsonEntry( + join(home, '.config', 'opencode', 'opencode.json'), + ['mcp', MCP_SERVER_NAME], + { type: 'remote', url: MCP_SERVER_URL }, + dryRun + ), + ], + project: (projectDir, dryRun) => [ + writeSkillFile(skillFile(join(projectDir, '.opencode')), dryRun), + upsertJsonEntry( + join(projectDir, 'opencode.json'), + ['mcp', MCP_SERVER_NAME], + { type: 'remote', url: MCP_SERVER_URL }, + dryRun + ), + ], + }, + { + id: 'codex', + name: 'Codex CLI', + detect: (home) => existsSync(join(home, '.codex')), + user: (home, dryRun) => [ + writeSkillFile(skillFile(join(home, '.codex')), dryRun), + upsertTomlSection(join(home, '.codex', 'config.toml'), CODEX_SECTION_HEADER, CODEX_SECTION_BODY, dryRun), + ], + project: (projectDir, dryRun) => [ + writeSkillFile(skillFile(join(projectDir, '.codex')), dryRun), + { + label: 'MCP server', + path: join(homedir(), '.codex', 'config.toml'), + action: 'skipped', + detail: 'Codex MCP servers are user-level only; run without --project', + }, + ], + }, + { + id: 'windsurf', + name: 'Windsurf', + detect: (home) => existsSync(join(home, '.codeium', 'windsurf')), + user: (home, dryRun) => [ + upsertJsonEntry( + join(home, '.codeium', 'windsurf', 'mcp_config.json'), + ['mcpServers', MCP_SERVER_NAME], + { serverUrl: MCP_SERVER_URL }, + dryRun + ), + ], + }, + { + id: 'zed', + name: 'Zed', + detect: (home) => existsSync(join(home, '.config', 'zed')), + user: (home, dryRun) => [ + upsertJsonEntry( + join(home, '.config', 'zed', 'settings.json'), + ['context_servers', MCP_SERVER_NAME], + { source: 'custom', command: 'npx', args: ['mcp-remote', MCP_SERVER_URL], env: {} }, + dryRun + ), + ], + }, + { + id: 'vscode', + name: 'VS Code', + detect: (home) => existsSync(vscodeUserDirectory(home)), + user: (home, dryRun) => [ + upsertJsonEntry( + join(vscodeUserDirectory(home), 'mcp.json'), + ['servers', MCP_SERVER_NAME], + HTTP_SERVER_ENTRY, + dryRun + ), + ], + project: (projectDir, dryRun) => [ + upsertJsonEntry( + join(projectDir, '.vscode', 'mcp.json'), + ['servers', MCP_SERVER_NAME], + HTTP_SERVER_ENTRY, + dryRun + ), + ], + }, +]; -const MCP_ACTION_LABEL: Record = { - registered: 'Registered MCP server', - unchanged: 'MCP server already registered', - skipped: 'Skipped MCP registration', +const ACTION_LABEL: Record = { + created: 'installed', + updated: 'updated', + unchanged: 'already up to date', + skipped: 'skipped', }; export const setupCommand: Command = { name: 'setup', description: 'Wire the CLI into coding agents (agent skill + MCP server)', options: [ + { + flag: '--agent ', + description: `Configure only this agent, even when not detected; repeatable (${AGENTS.map((a) => a.id).join(', ')})`, + type: 'array', + }, { flag: '--project', - description: 'Install into the current project (./.claude/skills + ./.mcp.json) instead of the home directory', + description: 'Install into the current project (e.g. ./.claude, ./.cursor) instead of the home directory', type: 'boolean', }, ], examples: [ 'polylane setup', + 'polylane setup --agent claude --agent cursor', 'polylane setup --project', 'polylane setup --dry-run', ], async execute(config: Config, _flags, args: Record): Promise { const project = getArgBoolean(args, 'project') === true; - const baseDir = project ? process.cwd() : homedir(); - const skillPath = skillInstallPath(baseDir); - const agentConfigPath = project - ? projectAgentConfigPath(baseDir) - : userAgentConfigPath(baseDir); + const requested = getArgArray(args, 'agent'); + const home = homedir(); + + if (requested) { + const known = new Set(AGENTS.map((a) => a.id)); + for (const id of requested) { + if (!known.has(id)) { + throw new CLIError( + `Unknown agent: "${id}"`, + ExitCode.USAGE, + `Supported agents: ${AGENTS.map((a) => a.id).join(', ')}` + ); + } + } + } const say = (line: string): void => { if (!config.quiet) process.stderr.write(line + '\n'); }; - if (config.dryRun) { - say(`Would install agent skill: ${skillPath}`); - say(`Would register MCP server "${MCP_SERVER_NAME}" (${MCP_SERVER_URL}) in: ${agentConfigPath}`); + const selected = requested + ? AGENTS.filter((a) => requested.includes(a.id)) + : AGENTS.filter((a) => a.detect(home)); + + if (selected.length === 0) { + say('No coding agents detected.'); + say(`Target one explicitly with --agent (${AGENTS.map((a) => a.id).join(', ')}).`); return; } - const skill = installSkill(skillPath); - say(`${SKILL_ACTION_LABEL[skill.action]}: ${skill.path}`); + const verb = config.dryRun ? 'Would configure' : 'Configuring'; + say(`${verb} ${selected.length} coding agent${selected.length === 1 ? '' : 's'}: ${selected.map((a) => a.name).join(', ')}`); - const mcp = registerMcpServer(agentConfigPath); - if (mcp.action === 'skipped') { - say(`${MCP_ACTION_LABEL[mcp.action]}: ${mcp.path} (${mcp.reason ?? 'unknown reason'})`); - say(`Register it manually: claude mcp add --transport http ${MCP_SERVER_NAME} ${MCP_SERVER_URL}`); - } else { - say(`${MCP_ACTION_LABEL[mcp.action]}: ${MCP_SERVER_URL} in ${mcp.path}`); + for (const agent of selected) { + let outcomes: WriteOutcome[]; + if (project) { + if (!agent.project) { + say(`${agent.id}: skipped — no project-level convention; run without --project`); + continue; + } + outcomes = agent.project(process.cwd(), config.dryRun); + } else { + outcomes = agent.user(home, config.dryRun); + } + + for (const outcome of outcomes) { + const changed = outcome.action === 'created' || outcome.action === 'updated'; + const base = + outcome.label === 'MCP server' && changed ? 'registered' : ACTION_LABEL[outcome.action]; + const state = config.dryRun && changed ? `would be ${base}` : base; + const detail = outcome.detail ? ` (${outcome.detail})` : ''; + say(`${agent.id}: ${outcome.label} ${state}: ${outcome.path}${detail}`); + if (outcome.needsManualStep) { + say(`${agent.id}: register it manually — see https://docs.polylane.com/coding-agents/platform-mcp`); + } + } } const credential = await tryResolveCredential(config); diff --git a/test/setup.test.ts b/test/setup.test.ts index 66468e3..6cbcdae 100644 --- a/test/setup.test.ts +++ b/test/setup.test.ts @@ -4,11 +4,10 @@ import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, mkdirSync import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { - installSkill, - registerMcpServer, - skillInstallPath, - userAgentConfigPath, - projectAgentConfigPath, + AGENTS, + writeSkillFile, + upsertJsonEntry, + upsertTomlSection, MCP_SERVER_NAME, MCP_SERVER_URL, } from '../src/commands/setup'; @@ -24,66 +23,55 @@ afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); }); -describe('skill and agent config paths', () => { - it('places the skill under .claude/skills/polylane-cli', () => { - assert.equal( - skillInstallPath('/home/user'), - join('/home/user', '.claude', 'skills', 'polylane-cli', 'SKILL.md') - ); - }); - - it('places the user agent config at .claude.json', () => { - assert.equal(userAgentConfigPath('/home/user'), join('/home/user', '.claude.json')); - }); +function agent(id: string) { + const found = AGENTS.find((a) => a.id === id); + assert.ok(found, `agent ${id} is defined`); + return found; +} - it('places the project agent config at .mcp.json', () => { - assert.equal(projectAgentConfigPath('/repo'), join('/repo', '.mcp.json')); - }); -}); - -describe('installSkill', () => { +describe('writeSkillFile', () => { it('creates the skill file with the bundled content', () => { - const path = skillInstallPath(tempDir); - const result = installSkill(path); + const path = join(tempDir, '.claude', 'skills', 'polylane-cli', 'SKILL.md'); + const result = writeSkillFile(path); assert.equal(result.action, 'created'); - assert.equal(result.path, path); assert.equal(readFileSync(path, 'utf-8'), SKILL_MD); }); it('reports unchanged when the installed skill matches', () => { - const path = skillInstallPath(tempDir); - installSkill(path); - const result = installSkill(path); - assert.equal(result.action, 'unchanged'); - assert.equal(readFileSync(path, 'utf-8'), SKILL_MD); + const path = join(tempDir, 'SKILL.md'); + writeSkillFile(path); + assert.equal(writeSkillFile(path).action, 'unchanged'); }); it('overwrites a stale skill file', () => { - const path = skillInstallPath(tempDir); - mkdirSync(dirname(path), { recursive: true }); + const path = join(tempDir, 'SKILL.md'); writeFileSync(path, 'old skill content', 'utf-8'); - const result = installSkill(path); + const result = writeSkillFile(path); assert.equal(result.action, 'updated'); assert.equal(readFileSync(path, 'utf-8'), SKILL_MD); }); + + it('does not write in dry-run mode', () => { + const path = join(tempDir, 'SKILL.md'); + const result = writeSkillFile(path, true); + assert.equal(result.action, 'created'); + assert.equal(existsSync(path), false); + }); }); -describe('registerMcpServer', () => { - it('creates the config file with the polylane server entry', () => { - const path = join(tempDir, '.claude.json'); - const result = registerMcpServer(path); - assert.equal(result.action, 'registered'); +describe('upsertJsonEntry', () => { + it('creates the file and nested key path', () => { + const path = join(tempDir, 'nested', '.claude.json'); + const result = upsertJsonEntry(path, ['mcpServers', MCP_SERVER_NAME], { type: 'http', url: MCP_SERVER_URL }); + assert.equal(result.action, 'created'); const parsed = JSON.parse(readFileSync(path, 'utf-8')) as { - mcpServers: Record; + mcpServers: Record; }; - assert.deepEqual(parsed.mcpServers[MCP_SERVER_NAME], { - type: 'http', - url: MCP_SERVER_URL, - }); + assert.deepEqual(parsed.mcpServers[MCP_SERVER_NAME], { type: 'http', url: MCP_SERVER_URL }); }); it('preserves unrelated keys and servers', () => { - const path = join(tempDir, '.claude.json'); + const path = join(tempDir, 'config.json'); writeFileSync( path, JSON.stringify({ @@ -92,58 +80,204 @@ describe('registerMcpServer', () => { }), 'utf-8' ); - const result = registerMcpServer(path); - assert.equal(result.action, 'registered'); + const result = upsertJsonEntry(path, ['mcpServers', MCP_SERVER_NAME], { type: 'http', url: MCP_SERVER_URL }); + assert.equal(result.action, 'updated'); const parsed = JSON.parse(readFileSync(path, 'utf-8')) as { theme: string; - mcpServers: Record; + mcpServers: Record; }; assert.equal(parsed.theme, 'dark'); assert.deepEqual(parsed.mcpServers.other, { type: 'http', url: 'https://example.com/mcp' }); - assert.deepEqual(parsed.mcpServers[MCP_SERVER_NAME], { - type: 'http', - url: MCP_SERVER_URL, - }); + assert.deepEqual(parsed.mcpServers[MCP_SERVER_NAME], { type: 'http', url: MCP_SERVER_URL }); }); - it('leaves an existing polylane entry untouched', () => { - const path = join(tempDir, '.claude.json'); - const existing = { - mcpServers: { - [MCP_SERVER_NAME]: { - type: 'http', - url: MCP_SERVER_URL, - headers: { 'x-api-key': 'sk_custom' }, - }, - }, - }; - const original = JSON.stringify(existing); + it('leaves an existing entry untouched', () => { + const path = join(tempDir, 'config.json'); + const original = JSON.stringify({ + mcpServers: { [MCP_SERVER_NAME]: { type: 'http', url: MCP_SERVER_URL, headers: { 'x-api-key': 'sk_custom' } } }, + }); writeFileSync(path, original, 'utf-8'); - const result = registerMcpServer(path); + const result = upsertJsonEntry(path, ['mcpServers', MCP_SERVER_NAME], { type: 'http', url: MCP_SERVER_URL }); assert.equal(result.action, 'unchanged'); assert.equal(readFileSync(path, 'utf-8'), original); }); it('skips and leaves the file alone when it is not valid JSON', () => { - const path = join(tempDir, '.claude.json'); - writeFileSync(path, '{ not json', 'utf-8'); - const result = registerMcpServer(path); + const path = join(tempDir, 'settings.json'); + writeFileSync(path, '// zed settings\n{ "theme": "dark", }', 'utf-8'); + const result = upsertJsonEntry(path, ['context_servers', MCP_SERVER_NAME], {}); assert.equal(result.action, 'skipped'); - assert.equal(readFileSync(path, 'utf-8'), '{ not json'); + assert.ok(result.detail); + assert.equal(readFileSync(path, 'utf-8'), '// zed settings\n{ "theme": "dark", }'); }); it('skips when the file holds a JSON array instead of an object', () => { - const path = join(tempDir, '.claude.json'); + const path = join(tempDir, 'config.json'); writeFileSync(path, '[]', 'utf-8'); - const result = registerMcpServer(path); - assert.equal(result.action, 'skipped'); - assert.equal(readFileSync(path, 'utf-8'), '[]'); + assert.equal(upsertJsonEntry(path, ['mcpServers', MCP_SERVER_NAME], {}).action, 'skipped'); + }); + + it('skips when an intermediate key is not an object', () => { + const path = join(tempDir, 'config.json'); + writeFileSync(path, JSON.stringify({ mcpServers: 'oops' }), 'utf-8'); + assert.equal(upsertJsonEntry(path, ['mcpServers', MCP_SERVER_NAME], {}).action, 'skipped'); + }); + + it('does not write in dry-run mode', () => { + const path = join(tempDir, 'config.json'); + const result = upsertJsonEntry(path, ['mcpServers', MCP_SERVER_NAME], {}, true); + assert.equal(result.action, 'created'); + assert.equal(existsSync(path), false); + }); +}); + +describe('upsertTomlSection', () => { + const header = `[mcp_servers.${MCP_SERVER_NAME}]`; + const body = `url = "${MCP_SERVER_URL}"\n`; + + it('creates the file with the section', () => { + const path = join(tempDir, '.codex', 'config.toml'); + const result = upsertTomlSection(path, header, body); + assert.equal(result.action, 'created'); + assert.equal(readFileSync(path, 'utf-8'), `${header}\n${body}`); + }); + + it('appends to an existing file without touching its content', () => { + const path = join(tempDir, 'config.toml'); + writeFileSync(path, 'model = "gpt-5"\n', 'utf-8'); + const result = upsertTomlSection(path, header, body); + assert.equal(result.action, 'updated'); + const content = readFileSync(path, 'utf-8'); + assert.ok(content.startsWith('model = "gpt-5"\n')); + assert.ok(content.includes(`${header}\n${body}`)); + }); + + it('reports unchanged when the section is already present', () => { + const path = join(tempDir, 'config.toml'); + upsertTomlSection(path, header, body); + const before = readFileSync(path, 'utf-8'); + assert.equal(upsertTomlSection(path, header, body).action, 'unchanged'); + assert.equal(readFileSync(path, 'utf-8'), before); + }); +}); + +describe('agent definitions', () => { + it('covers the supported agent list', () => { + assert.deepEqual( + AGENTS.map((a) => a.id), + ['claude', 'cursor', 'opencode', 'codex', 'windsurf', 'zed', 'vscode'] + ); + }); + + it('detects agents from their home directories', () => { + assert.equal(agent('claude').detect(tempDir), false); + mkdirSync(join(tempDir, '.claude'), { recursive: true }); + assert.equal(agent('claude').detect(tempDir), true); + + assert.equal(agent('codex').detect(tempDir), false); + mkdirSync(join(tempDir, '.codex'), { recursive: true }); + assert.equal(agent('codex').detect(tempDir), true); + + assert.equal(agent('windsurf').detect(tempDir), false); + mkdirSync(join(tempDir, '.codeium', 'windsurf'), { recursive: true }); + assert.equal(agent('windsurf').detect(tempDir), true); + }); + + it('detects claude from ~/.claude.json alone', () => { + writeFileSync(join(tempDir, '.claude.json'), '{}', 'utf-8'); + assert.equal(agent('claude').detect(tempDir), true); + }); + + it('configures claude with a skill and an MCP entry', () => { + const outcomes = agent('claude').user(tempDir, false); + assert.equal(readFileSync(join(tempDir, '.claude', 'skills', 'polylane-cli', 'SKILL.md'), 'utf-8'), SKILL_MD); + const parsed = JSON.parse(readFileSync(join(tempDir, '.claude.json'), 'utf-8')) as { + mcpServers: Record; + }; + assert.deepEqual(parsed.mcpServers[MCP_SERVER_NAME], { type: 'http', url: MCP_SERVER_URL }); + assert.equal(outcomes.every((o) => o.action === 'created'), true); + }); + + it('configures cursor with a skill and an MCP entry', () => { + agent('cursor').user(tempDir, false); + assert.equal(readFileSync(join(tempDir, '.cursor', 'skills', 'polylane-cli', 'SKILL.md'), 'utf-8'), SKILL_MD); + const parsed = JSON.parse(readFileSync(join(tempDir, '.cursor', 'mcp.json'), 'utf-8')) as { + mcpServers: Record; + }; + assert.deepEqual(parsed.mcpServers[MCP_SERVER_NAME], { type: 'http', url: MCP_SERVER_URL }); + }); + + it('configures opencode with a skill and a remote MCP entry', () => { + agent('opencode').user(tempDir, false); + assert.equal( + readFileSync(join(tempDir, '.config', 'opencode', 'skills', 'polylane-cli', 'SKILL.md'), 'utf-8'), + SKILL_MD + ); + const parsed = JSON.parse(readFileSync(join(tempDir, '.config', 'opencode', 'opencode.json'), 'utf-8')) as { + mcp: Record; + }; + assert.deepEqual(parsed.mcp[MCP_SERVER_NAME], { type: 'remote', url: MCP_SERVER_URL }); + }); + + it('configures codex with a skill and a config.toml section', () => { + agent('codex').user(tempDir, false); + assert.equal(readFileSync(join(tempDir, '.codex', 'skills', 'polylane-cli', 'SKILL.md'), 'utf-8'), SKILL_MD); + const toml = readFileSync(join(tempDir, '.codex', 'config.toml'), 'utf-8'); + assert.ok(toml.includes(`[mcp_servers.${MCP_SERVER_NAME}]`)); + assert.ok(toml.includes(`url = "${MCP_SERVER_URL}"`)); + }); + + it('configures windsurf with a serverUrl MCP entry only', () => { + const outcomes = agent('windsurf').user(tempDir, false); + assert.equal(outcomes.length, 1); + const parsed = JSON.parse( + readFileSync(join(tempDir, '.codeium', 'windsurf', 'mcp_config.json'), 'utf-8') + ) as { mcpServers: Record }; + assert.deepEqual(parsed.mcpServers[MCP_SERVER_NAME], { serverUrl: MCP_SERVER_URL }); + }); + + it('configures zed with an mcp-remote context server', () => { + agent('zed').user(tempDir, false); + const parsed = JSON.parse(readFileSync(join(tempDir, '.config', 'zed', 'settings.json'), 'utf-8')) as { + context_servers: Record; + }; + assert.deepEqual(parsed.context_servers[MCP_SERVER_NAME], { + source: 'custom', + command: 'npx', + args: ['mcp-remote', MCP_SERVER_URL], + env: {}, + }); + }); + + it('is idempotent for every agent', () => { + for (const definition of AGENTS.filter((a) => a.id !== 'vscode')) { + definition.user(tempDir, false); + const second = definition.user(tempDir, false); + assert.equal( + second.every((o) => o.action === 'unchanged'), + true, + `${definition.id} second run is a no-op` + ); + } + }); + + it('writes project-scope config for claude', () => { + const project = agent('claude').project; + assert.ok(project); + project(tempDir, false); + assert.ok(existsSync(join(tempDir, '.claude', 'skills', 'polylane-cli', 'SKILL.md'))); + const parsed = JSON.parse(readFileSync(join(tempDir, '.mcp.json'), 'utf-8')) as { + mcpServers: Record; + }; + assert.deepEqual(parsed.mcpServers[MCP_SERVER_NAME], { type: 'http', url: MCP_SERVER_URL }); }); - it('creates parent directories when needed', () => { - const path = join(tempDir, 'nested', '.mcp.json'); - const result = registerMcpServer(path); - assert.equal(result.action, 'registered'); - assert.ok(existsSync(path)); + it('reports codex project MCP as user-level only', () => { + const project = agent('codex').project; + assert.ok(project); + const outcomes = project(tempDir, false); + const mcp = outcomes.find((o) => o.label === 'MCP server'); + assert.ok(mcp); + assert.equal(mcp.action, 'skipped'); }); });