From c35aa760e61794a09401285aa4e54f8dbe0be610 Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com> Date: Thu, 14 May 2026 19:09:48 +0200 Subject: [PATCH] feat(cli): support Claude local MCP setup scope --- packages/cli/src/commands/setup-commands.ts | 15 ++++++++- packages/cli/src/index.test.ts | 33 +++++++++++++++++++ packages/cli/src/setup-agents.test.ts | 36 ++++++++++++++++++++- packages/cli/src/setup-agents.ts | 5 ++- 4 files changed, 86 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/setup-commands.ts b/packages/cli/src/commands/setup-commands.ts index 4f6f0c32..d09f8149 100644 --- a/packages/cli/src/commands/setup-commands.ts +++ b/packages/cli/src/commands/setup-commands.ts @@ -90,6 +90,7 @@ function shouldShowSetupEntryMenu( agents?: boolean; target?: string; global?: boolean; + local?: boolean; skipAgents?: boolean; yes?: boolean; input?: boolean; @@ -163,6 +164,7 @@ function shouldShowSetupEntryMenu( 'agents', 'target', 'global', + 'local', 'skipAgents', 'yes', 'input', @@ -223,6 +225,7 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo ]), ) .option('--global', 'Install agent integration into the global target scope', false) + .option('--local', 'Install Claude Code MCP config into the private per-project ~/.claude.json scope', false) .addOption(new Option('--skip-agents', 'Leave agent integration incomplete for now').hideHelp().default(false)) .option('--yes', 'Accept safe defaults in non-interactive setup', false) .option('--no-input', 'Disable interactive terminal input') @@ -392,9 +395,19 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo context.setExitCode(1); return; } + if (options.local && options.global) { + context.io.stderr.write('Choose only one agent scope: --local or --global.\n'); + context.setExitCode(1); + return; + } + if (options.local && options.target && options.target !== 'claude-code') { + context.io.stderr.write('--local is only supported with --target claude-code.\n'); + context.setExitCode(1); + return; + } const mode = options.new ? 'new' : options.existing ? 'existing' : 'auto'; - const resolvedAgentScope = options.global ? 'global' : 'project'; + const resolvedAgentScope = options.local ? 'local' : options.global ? 'global' : 'project'; await runSetupArgs(context, { command: 'run', projectDir: resolveCommandProjectDir(command), diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts index fff5fb09..72152913 100644 --- a/packages/cli/src/index.test.ts +++ b/packages/cli/src/index.test.ts @@ -440,6 +440,7 @@ describe('runKtxCli', () => { expect(stdout).toContain('--agents'); expect(stdout).toContain('--target '); expect(stdout).toContain('--global'); + expect(stdout).toContain('--local'); expect(stdout).toContain('--yes'); expect(stdout).toContain('--no-input'); expect(stdout).toContain('Global Options:'); @@ -1286,6 +1287,38 @@ describe('runKtxCli', () => { ); }); + it('rejects --local with non-Claude targets', async () => { + const setup = vi.fn(async () => 0); + const setupIo = makeIo(); + + await expect( + runKtxCli( + ['--project-dir', tempDir, 'setup', '--agents', '--target', 'cursor', '--local', '--no-input'], + setupIo.io, + { setup }, + ), + ).resolves.toBe(1); + + expect(setupIo.stderr()).toContain('--local is only supported with --target claude-code'); + expect(setup).not.toHaveBeenCalled(); + }); + + it('rejects --local and --global together', async () => { + const setup = vi.fn(async () => 0); + const setupIo = makeIo(); + + await expect( + runKtxCli( + ['--project-dir', tempDir, 'setup', '--agents', '--target', 'claude-code', '--local', '--global', '--no-input'], + setupIo.io, + { setup }, + ), + ).resolves.toBe(1); + + expect(setupIo.stderr()).toContain('Choose only one agent scope: --local or --global.'); + expect(setup).not.toHaveBeenCalled(); + }); + it('rejects source-path with source-git-url', async () => { const setup = vi.fn(async () => 0); const testIo = makeIo(); diff --git a/packages/cli/src/setup-agents.test.ts b/packages/cli/src/setup-agents.test.ts index e8a894a2..e7117beb 100644 --- a/packages/cli/src/setup-agents.test.ts +++ b/packages/cli/src/setup-agents.test.ts @@ -287,7 +287,41 @@ describe('setup agents', () => { expect(rendered).not.toContain('secret-token'); expect(io.stdout()).toContain('Run `ktx mcp start` to enable the configured KTX MCP server.'); } finally { - process.env.KTX_MCP_TOKEN = previousToken; + if (previousToken === undefined) { + delete process.env.KTX_MCP_TOKEN; + } else { + process.env.KTX_MCP_TOKEN = previousToken; + } + } + }); + + it('writes Claude Code local MCP config under the project key in ~/.claude.json', async () => { + const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-')); + const previousHome = process.env.HOME; + process.env.HOME = home; + try { + const io = makeIo(); + await runKtxSetupAgentsStep( + { + projectDir: tempDir, + inputMode: 'disabled', + yes: true, + agents: true, + target: 'claude-code', + scope: 'local', + mode: 'cli', + skipAgents: false, + }, + io.io, + ); + + const config = JSON.parse(await readFile(join(home, '.claude.json'), 'utf-8')) as { + projects: Record; + }; + expect(config.projects[tempDir].mcpServers.ktx).toEqual({ type: 'http', url: 'http://localhost:7878/mcp' }); + } finally { + process.env.HOME = previousHome; + await rm(home, { recursive: true, force: true }); } }); diff --git a/packages/cli/src/setup-agents.ts b/packages/cli/src/setup-agents.ts index e5e02c3b..a065fc41 100644 --- a/packages/cli/src/setup-agents.ts +++ b/packages/cli/src/setup-agents.ts @@ -16,7 +16,7 @@ import { import { readKtxMcpDaemonStatus } from './managed-mcp-daemon.js'; export type KtxAgentTarget = 'claude-code' | 'codex' | 'cursor' | 'opencode' | 'universal'; -export type KtxAgentScope = 'project' | 'global'; +export type KtxAgentScope = 'project' | 'global' | 'local'; export type KtxAgentInstallMode = 'cli'; export interface KtxSetupAgentsArgs { @@ -174,6 +174,9 @@ function claudeConfigPath(projectDir: string, scope: KtxAgentScope): { path: str if (scope === 'global') { return { path: join(home, '.claude.json'), jsonPath: ['mcpServers', 'ktx'] }; } + if (scope === 'local') { + return { path: join(home, '.claude.json'), jsonPath: ['projects', resolve(projectDir), 'mcpServers', 'ktx'] }; + } return { path: join(resolve(projectDir), '.mcp.json'), jsonPath: ['mcpServers', 'ktx'] }; }