diff --git a/docs-site/content/docs/cli-reference/ktx-setup.mdx b/docs-site/content/docs/cli-reference/ktx-setup.mdx index 51fed155..7b66ed81 100644 --- a/docs-site/content/docs/cli-reference/ktx-setup.mdx +++ b/docs-site/content/docs/cli-reference/ktx-setup.mdx @@ -29,6 +29,7 @@ below. | `--agents` | Install agent configuration and rules only | `false` | | `--target ` | Agent target: `claude-code`, `claude-desktop`, `codex`, `cursor`, `opencode`, or `universal` | - | | `--global` | Install agent integration into the global target scope for `claude-code` or `codex` | `false` | +| `--install-dir ` | Directory to install project-scoped agent config into. Defaults to the ktx project directory; resolved against the current directory and created if missing. Use it to install `.claude/`, `.mcp.json`, and rules where you open your agent (e.g. `--install-dir .`). Mutually exclusive with `--global` and `--local` | ktx project dir | | `--yes` | Accept project creation and runtime install defaults where setup asks for confirmation | `false` | | `--no-input` | Disable interactive terminal input | - | diff --git a/docs-site/content/docs/integrations/agent-clients.mdx b/docs-site/content/docs/integrations/agent-clients.mdx index 46a1ec8b..1ef75d22 100644 --- a/docs-site/content/docs/integrations/agent-clients.mdx +++ b/docs-site/content/docs/integrations/agent-clients.mdx @@ -68,19 +68,30 @@ If you choose an install mode, it then asks which targets to install: └ ``` -When every selected target supports both project and global setup, the command -also asks where to install supported agent config: +When at least one selected target supports project-scoped setup, the command +asks where to install agent config: ```txt -◆ Where should ktx install supported agent config? +◆ Where should ktx install agent config? │ │ ktx project: /path/to/your/ktx-project │ -│ ○ Project scope (ktx project directory) +│ ○ ktx project directory /path/to/your/ktx-project +│ ○ Current directory /path/to/where/you/ran/ktx +│ ○ Custom directory… (enter a path) │ ○ Global scope (user config) └ ``` +The first three choices write project-scoped files (`.claude/`, `.mcp.json`, +`.cursor/`, skills, and rules) into the chosen directory while still pointing +them at this ktx project. Use **Current directory** or **Custom directory…** +when you open your coding agent from somewhere other than the ktx project +directory. **Current directory** is hidden when it is already the ktx project +directory, and **Global scope** appears only when every selected target +supports global setup. Non-interactive runs pass `--install-dir ` (for +example `--install-dir .`) for the same result. + ## Generated files **ktx** writes MCP client configuration and analytics guidance by default. It writes diff --git a/packages/cli/src/commands/setup-commands.ts b/packages/cli/src/commands/setup-commands.ts index 27a65b85..a37b7eb6 100644 --- a/packages/cli/src/commands/setup-commands.ts +++ b/packages/cli/src/commands/setup-commands.ts @@ -89,6 +89,7 @@ function shouldShowSetupEntryMenu( target?: string; global?: boolean; local?: boolean; + installDir?: string; skipAgents?: boolean; yes?: boolean; input?: boolean; @@ -159,6 +160,7 @@ function shouldShowSetupEntryMenu( 'target', 'global', 'local', + 'installDir', 'skipAgents', 'yes', 'input', @@ -217,6 +219,10 @@ 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) + .option( + '--install-dir ', + 'Directory to install project-scoped agent config into (defaults to the ktx project directory)', + ) .addOption(new Option('--skip-agents', 'Leave agent integration incomplete for now').hideHelp().default(false)) .option('--yes', 'Accept project creation and runtime install defaults where setup confirms', false) .option('--no-input', 'Disable interactive terminal input') @@ -394,6 +400,16 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo context.setExitCode(1); return; } + if (options.installDir && (options.global || options.local)) { + context.io.stderr.write('Choose either --install-dir or a scope flag (--global / --local), not both.\n'); + context.setExitCode(1); + return; + } + if (options.installDir && options.target === 'claude-desktop') { + context.io.stderr.write('--install-dir does not apply to --target claude-desktop, which is always global.\n'); + context.setExitCode(1); + return; + } const creatingDatabaseConnection = options.database.length > 0 || options.databaseUrl !== undefined; if (creatingDatabaseConnection && options.databaseConnectionId.length > 1) { @@ -412,6 +428,7 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo agents: options.agents === true, ...(options.target ? { target: options.target } : {}), agentScope: resolvedAgentScope, + ...(options.installDir ? { installRoot: options.installDir } : {}), skipAgents: options.skipAgents === true, inputMode: options.input === false ? 'disabled' : 'auto', ...(debugEnabled ? { debug: true } : {}), diff --git a/packages/cli/src/setup-agents.ts b/packages/cli/src/setup-agents.ts index 376ce7d7..ea803ad3 100644 --- a/packages/cli/src/setup-agents.ts +++ b/packages/cli/src/setup-agents.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs'; -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { styleText } from 'node:util'; @@ -35,13 +35,26 @@ export interface KtxSetupAgentsArgs { mode: KtxAgentInstallMode; skipAgents: boolean; showNextActions?: boolean; + installRoot?: string; + cwd?: string; } +/** The directory project-scoped agent files land in; equals projectDir unless an install root is chosen. */ +interface KtxAgentInstall { + target: KtxAgentTarget; + scope: KtxAgentScope; + mode: KtxAgentInstallMode; + installRoot: string; +} + +/** Install shape for formatting helpers; installRoot falls back to projectDir when absent. */ +type KtxAgentInstallLike = Omit & { installRoot?: string }; + export type KtxSetupAgentsResult = | { status: 'ready'; projectDir: string; - installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>; + installs: KtxAgentInstall[]; nextActions?: string; } | { status: 'skipped'; projectDir: string } @@ -53,7 +66,7 @@ export interface KtxAgentInstallManifest { version: 1; projectDir: string; installedAt: string; - installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>; + installs: KtxAgentInstall[]; entries: Array< | { kind: 'file'; @@ -258,7 +271,11 @@ function universalMcpSnippet(endpoint: KtxMcpEndpointInfo): string { ].join('\n'); } -function claudeConfigPath(projectDir: string, scope: KtxAgentScope): { path: string; jsonPath: string[] } { +function claudeConfigPath( + projectDir: string, + installRoot: string, + scope: KtxAgentScope, +): { path: string; jsonPath: string[] } { const home = process.env.HOME ?? ''; if (scope === 'global') { return { path: join(home, '.claude.json'), jsonPath: ['mcpServers', 'ktx'] }; @@ -266,13 +283,13 @@ function claudeConfigPath(projectDir: string, scope: KtxAgentScope): { path: str 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'] }; + return { path: join(resolve(installRoot), '.mcp.json'), jsonPath: ['mcpServers', 'ktx'] }; } -function cursorConfigPath(projectDir: string, scope: KtxAgentScope): { path: string; jsonPath: string[] } { +function cursorConfigPath(installRoot: string, scope: KtxAgentScope): { path: string; jsonPath: string[] } { const home = process.env.HOME ?? ''; return { - path: scope === 'global' ? join(home, '.cursor/mcp.json') : join(resolve(projectDir), '.cursor/mcp.json'), + path: scope === 'global' ? join(home, '.cursor/mcp.json') : join(resolve(installRoot), '.cursor/mcp.json'), jsonPath: ['mcpServers', 'ktx'], }; } @@ -311,6 +328,7 @@ function claudeDesktopMcpEntry(input: { projectDir: string; env?: NodeJS.Process async function installMcpClientConfig(input: { projectDir: string; + installRoot: string; target: KtxAgentTarget; scope: KtxAgentScope; }): Promise { @@ -335,11 +353,11 @@ async function installMcpClientConfig(input: { } if (input.target === 'claude-code') { - const config = claudeConfigPath(input.projectDir, input.scope); + const config = claudeConfigPath(input.projectDir, input.installRoot, input.scope); await writeJsonKey(config.path, config.jsonPath, claudeMcpEntry(endpoint)); entries.push({ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }); } else if (input.target === 'cursor') { - const config = cursorConfigPath(input.projectDir, input.scope); + const config = cursorConfigPath(input.installRoot, input.scope); await writeJsonKey(config.path, config.jsonPath, cursorMcpEntry(endpoint)); entries.push({ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }); } else if (input.target === 'codex') { @@ -348,7 +366,7 @@ async function installMcpClientConfig(input: { const path = input.scope === 'global' ? '~/.config/opencode/opencode.json' - : relative(input.projectDir, join(input.projectDir, 'opencode.json')); + : relative(input.installRoot, join(input.installRoot, 'opencode.json')); snippets.push(`Add this OpenCode MCP snippet to ${path}:\n${opencodeSnippet(endpoint)}`); } else if (input.target === 'universal') { snippets.push(`Use this universal MCP endpoint with unsupported MCP clients:\n${universalMcpSnippet(endpoint)}`); @@ -359,11 +377,12 @@ async function installMcpClientConfig(input: { function plannedMcpJsonEntries(input: { projectDir: string; + installRoot: string; target: KtxAgentTarget; scope: KtxAgentScope; }): InstallEntry[] { if (input.target === 'claude-code') { - const config = claudeConfigPath(input.projectDir, input.scope); + const config = claudeConfigPath(input.projectDir, input.installRoot, input.scope); return [{ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }]; } if (input.target === 'claude-desktop') { @@ -371,7 +390,7 @@ function plannedMcpJsonEntries(input: { return [{ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }]; } if (input.target === 'cursor') { - const config = cursorConfigPath(input.projectDir, input.scope); + const config = cursorConfigPath(input.installRoot, input.scope); return [{ kind: 'json-key', path: config.path, jsonPath: config.jsonPath }]; } return []; @@ -395,6 +414,7 @@ export function plannedKtxAgentFiles(input: { target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode; + installRoot?: string; }): InstallEntry[] { const withAdminCli = input.mode === 'mcp-cli'; @@ -447,7 +467,7 @@ export function plannedKtxAgentFiles(input: { throw new Error(`Global ${input.target} installation is not supported; omit --global.`); } - const root = resolve(input.projectDir); + const root = resolve(input.installRoot ?? input.projectDir); const analyticsEntries: Partial> = { 'claude-code': [ { kind: 'file', path: join(root, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' }, @@ -650,7 +670,8 @@ function mergeManifest( ): KtxAgentInstallManifest { const installMap = new Map(); for (const install of [...(existing?.installs ?? []), ...installs]) { - installMap.set(`${install.target}:${install.scope}:${install.mode}`, install); + const installRoot = install.installRoot ?? resolve(projectDir); + installMap.set(`${install.target}:${install.scope}:${install.mode}:${installRoot}`, { ...install, installRoot }); } const entryMap = new Map(); for (const entry of [...(existing?.entries ?? []), ...entries]) { @@ -688,6 +709,7 @@ interface KtxSetupAgentsPromptAdapter { options: KtxSetupPromptOption[]; required?: boolean; }): Promise; + text(options: { message: string; placeholder?: string }): Promise; cancel(message: string): void; } @@ -786,16 +808,29 @@ function formatInlinePath(path: string): string { return path; } +function installSummaryTitle(install: KtxAgentInstallLike, projectDir: string): string { + const name = targetDisplayName(install.target); + if (install.scope !== 'project') { + return `${name} · ${scopeDisplayName(install.scope)}`; + } + const installRoot = resolve(install.installRoot ?? projectDir); + if (installRoot === resolve(projectDir)) { + return `${name} · ${scopeDisplayName('project')}`; + } + return `${name} · ${formatInlinePath(installRoot)}`; +} + /** @internal */ export function formatInstallSummaryLines( - installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>, + installs: KtxAgentInstallLike[], entries: InstallEntry[], projectDir: string, ): InstallSummaryEntry[] { const entriesByTarget = new Map(); for (const install of installs) { + const installRoot = install.installRoot ?? projectDir; const plannedFilePaths = new Set( - plannedKtxAgentFiles({ projectDir, ...install }) + plannedKtxAgentFiles({ projectDir, ...install, installRoot }) .filter((entry) => entry.kind === 'file') .map((entry) => entry.path), ); @@ -806,7 +841,10 @@ export function formatInstallSummaryLines( } const mcpEntriesByTarget = new Map(); for (const install of installs) { - const plannedMcpKeys = new Set(plannedMcpJsonEntries({ projectDir, ...install }).map(entryKey)); + const installRoot = install.installRoot ?? projectDir; + const plannedMcpKeys = new Set( + plannedMcpJsonEntries({ projectDir, installRoot, target: install.target, scope: install.scope }).map(entryKey), + ); mcpEntriesByTarget.set( install.target, entries.filter((entry) => entry.kind === 'json-key' && plannedMcpKeys.has(entryKey(entry))), @@ -856,7 +894,7 @@ export function formatInstallSummaryLines( } return { - title: `${targetDisplayName(install.target)} · ${scopeDisplayName(install.scope)}`, + title: installSummaryTitle(install, projectDir), lines, }; }); @@ -864,7 +902,7 @@ export function formatInstallSummaryLines( function claudeDesktopSkillBundlePathsForInstalls( projectDir: string, - installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>, + installs: KtxAgentInstallLike[], ): string[] { return installs .filter((install) => install.target === 'claude-desktop') @@ -939,9 +977,13 @@ function manualActionFromSnippet(snippet: string): { }; } +function openFromDirectoryLabel(installRoot: string, projectDir: string): string { + return resolve(installRoot) === resolve(projectDir) ? 'the ktx project directory' : 'the install directory'; +} + function formatAgentNextActions(input: { projectDir: string; - installs: Array<{ target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode }>; + installs: KtxAgentInstallLike[]; notices: string[]; snippets: string[]; }): string { @@ -983,10 +1025,11 @@ function formatAgentNextActions(input: { if (claudeCodeInstall) { lines.push(`${step}. Open Claude Code`); if (claudeCodeInstall.scope === 'project') { - lines.push(' Open Claude Code from the ktx project directory:'); + const installRoot = resolve(claudeCodeInstall.installRoot ?? projectDir); + lines.push(` Open Claude Code from ${openFromDirectoryLabel(installRoot, projectDir)}:`); lines.push(''); lines.push(' RUN:'); - lines.push(` cd ${shellScriptQuote(projectDir)}`); + lines.push(` cd ${shellScriptQuote(installRoot)}`); lines.push(' claude'); } else { lines.push(' RUN:'); @@ -1000,10 +1043,11 @@ function formatAgentNextActions(input: { if (cursorInstall) { lines.push(`${step}. Open Cursor`); if (cursorInstall.scope === 'project') { - lines.push(' Open Cursor from the ktx project directory:'); + const installRoot = resolve(cursorInstall.installRoot ?? projectDir); + lines.push(` Open Cursor from ${openFromDirectoryLabel(installRoot, projectDir)}:`); lines.push(''); lines.push(' OPEN:'); - lines.push(` ${projectDir}`); + lines.push(` ${installRoot}`); } else { lines.push(' Open Cursor.'); } @@ -1041,6 +1085,7 @@ function formatAgentNextActions(input: { async function installTarget(input: { projectDir: string; + installRoot: string; target: KtxAgentTarget; scope: KtxAgentScope; mode: KtxAgentInstallMode; @@ -1076,6 +1121,72 @@ async function markAgentsComplete(projectDir: string): Promise { await markKtxSetupStateStepComplete(projectDir, 'agents'); } +// A typed path never passes through a shell, so expand a leading ~ here; HOME +// matches formatInlinePath so the ~/… hints shown in the menu round-trip. +function resolveTypedInstallDir(cwd: string, raw: string): string { + const home = process.env.HOME; + if (home && (raw === '~' || raw.startsWith('~/'))) { + return resolve(home, raw.slice(2)); + } + return resolve(cwd, raw); +} + +async function ensureInstallDir(resolvedPath: string): Promise { + if (existsSync(resolvedPath)) { + if (!(await stat(resolvedPath)).isDirectory()) { + throw new Error(`Install directory path is a file, not a directory: ${resolvedPath}`); + } + return resolvedPath; + } + await mkdir(resolvedPath, { recursive: true }); + return resolvedPath; +} + +async function promptInstallDirectory(input: { + prompts: KtxSetupAgentsPromptAdapter; + io: KtxCliIo; + cwd: string; + projectRoot: string; + scopeTargets: KtxAgentTarget[]; +}): Promise<{ scope: KtxAgentScope; installRoot: string } | 'back'> { + const { prompts, io, cwd, projectRoot, scopeTargets } = input; + const options: KtxSetupPromptOption[] = [ + { value: 'project', label: 'ktx project directory', hint: formatInlinePath(projectRoot) }, + ...(cwd !== projectRoot + ? [{ value: 'current', label: 'Current directory', hint: formatInlinePath(cwd) }] + : []), + { value: 'custom', label: 'Custom directory…', hint: 'Enter a path' }, + ...(scopeTargets.every(targetSupportsGlobalScope) + ? [ + { + value: 'global', + label: 'Global scope (user config)', + hint: 'Agents can load this ktx project from any working directory.', + }, + ] + : []), + ]; + const choice = await prompts.select({ + message: `Where should ktx install agent config?\n\nktx project: ${projectRoot}`, + options, + }); + if (choice === 'back') return 'back'; + if (choice === 'global') return { scope: 'global', installRoot: projectRoot }; + if (choice === 'current') return { scope: 'project', installRoot: cwd }; + if (choice === 'project') return { scope: 'project', installRoot: projectRoot }; + while (true) { + const typed = await prompts.text({ message: 'Enter the directory to install agent config into' }); + if (typed === undefined) return 'back'; + const trimmed = typed.trim(); + if (trimmed === '') continue; + try { + return { scope: 'project', installRoot: await ensureInstallDir(resolveTypedInstallDir(cwd, trimmed)) }; + } catch (error) { + io.stderr.write(`${errorMessage(error)}\n`); + } + } +} + export async function runKtxSetupAgentsStep( args: KtxSetupAgentsArgs, io: KtxCliIo, @@ -1146,31 +1257,31 @@ export async function runKtxSetupAgentsStep( return { status: 'missing-input', projectDir: args.projectDir }; } + const cwd = resolve(args.cwd ?? process.cwd()); + const projectRoot = resolve(args.projectDir); const scopeTargets = targets.filter((target) => target !== 'claude-desktop'); - const selectedScope = - args.inputMode !== 'disabled' && - args.scope === 'project' && - scopeTargets.length > 0 && - scopeTargets.every(targetSupportsGlobalScope) - ? ((await prompts.select({ - message: `Where should ktx install supported agent config?\n\nktx project: ${resolve(args.projectDir)}`, - options: [ - { - value: 'project', - label: 'Project scope (ktx project directory)', - hint: 'Only agents opened from this ktx project path load the project-scoped config.', - }, - { - value: 'global', - label: 'Global scope (user config)', - hint: 'Agents can load this ktx project from any working directory.', - }, - ], - })) as KtxAgentScope | 'back') - : args.scope; - if (selectedScope === 'back') return { status: 'back', projectDir: args.projectDir }; - const installs = targets.map((target) => ({ target, scope: effectiveInstallScope(target, selectedScope), mode })); + let selectedScope: KtxAgentScope = args.scope; + let installRoot = projectRoot; + if (args.installRoot !== undefined) { + try { + installRoot = await ensureInstallDir(resolveTypedInstallDir(cwd, args.installRoot)); + } catch (error) { + writePrefixedLines((chunk) => io.stderr.write(chunk), errorMessage(error)); + return { status: 'failed', projectDir: args.projectDir }; + } + selectedScope = 'project'; + } else if (args.inputMode !== 'disabled' && args.scope === 'project' && scopeTargets.length > 0) { + const decision = await promptInstallDirectory({ prompts, io, cwd, projectRoot, scopeTargets }); + if (decision === 'back') return { status: 'back', projectDir: args.projectDir }; + selectedScope = decision.scope; + installRoot = decision.installRoot; + } + + const installs: KtxAgentInstall[] = targets.map((target) => { + const scope = effectiveInstallScope(target, selectedScope); + return { target, scope, mode, installRoot: scope === 'project' ? installRoot : projectRoot }; + }); const entries: InstallEntry[] = []; const snippets: string[] = []; const notices = new Set(); @@ -1180,6 +1291,7 @@ export async function runKtxSetupAgentsStep( entries.push(...targetEntries); const mcpResult = await installMcpClientConfig({ projectDir: args.projectDir, + installRoot: install.installRoot, target: install.target, scope: install.scope, }); diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index 6bbcf90b..dd893ce7 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -80,6 +80,7 @@ export type KtxSetupArgs = agents: boolean; target?: KtxAgentTarget; agentScope?: KtxAgentScope; + installRoot?: string; skipAgents?: boolean; inputMode: 'auto' | 'disabled'; debug?: boolean; @@ -919,6 +920,7 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup agents: true, ...(args.target ? { target: args.target } : {}), scope: args.agentScope ?? 'project', + ...(args.installRoot ? { installRoot: args.installRoot } : {}), mode: 'mcp', skipAgents: false, showNextActions: agentsRequested, diff --git a/packages/cli/test/index.test.ts b/packages/cli/test/index.test.ts index 55af622b..b1901084 100644 --- a/packages/cli/test/index.test.ts +++ b/packages/cli/test/index.test.ts @@ -526,6 +526,7 @@ describe('runKtxCli', () => { expect(stdout).toContain('--target '); expect(stdout).toContain('--global'); expect(stdout).toContain('--local'); + expect(stdout).toContain('--install-dir '); expect(stdout).toContain('--yes'); expect(stdout).toContain('--no-input'); expect(stdout).toContain('Global Options:'); @@ -1486,6 +1487,94 @@ describe('runKtxCli', () => { expect(setup).not.toHaveBeenCalled(); }); + it('dispatches --install-dir as the agent install root', async () => { + const setup = vi.fn(async () => 0); + const setupIo = makeIo(); + + await expect( + runKtxCli( + ['--project-dir', tempDir, 'setup', '--agents', '--target', 'claude-code', '--install-dir', '.', '--no-input'], + setupIo.io, + { setup }, + ), + ).resolves.toBe(0); + + expect(setup).toHaveBeenCalledWith( + expect.objectContaining({ agents: true, target: 'claude-code', agentScope: 'project', installRoot: '.' }), + setupIo.io, + ); + }); + + it('rejects --install-dir together with --global', async () => { + const setup = vi.fn(async () => 0); + const setupIo = makeIo(); + + await expect( + runKtxCli( + ['--project-dir', tempDir, 'setup', '--agents', '--target', 'claude-code', '--install-dir', '.', '--global', '--no-input'], + setupIo.io, + { setup }, + ), + ).resolves.toBe(1); + + expect(setupIo.stderr()).toContain('Choose either --install-dir or a scope flag (--global / --local), not both.'); + expect(setup).not.toHaveBeenCalled(); + }); + + it('rejects --install-dir together with --local', async () => { + const setup = vi.fn(async () => 0); + const setupIo = makeIo(); + + await expect( + runKtxCli( + ['--project-dir', tempDir, 'setup', '--agents', '--target', 'claude-code', '--install-dir', '.', '--local', '--no-input'], + setupIo.io, + { setup }, + ), + ).resolves.toBe(1); + + expect(setupIo.stderr()).toContain('Choose either --install-dir or a scope flag (--global / --local), not both.'); + expect(setup).not.toHaveBeenCalled(); + }); + + it('treats an empty --install-dir as not provided', async () => { + const setup = vi.fn(async () => 0); + const setupIo = makeIo(); + + await expect( + runKtxCli( + ['--project-dir', tempDir, 'setup', '--agents', '--target', 'claude-code', '--install-dir', '', '--global', '--no-input'], + setupIo.io, + { setup }, + ), + ).resolves.toBe(0); + + expect(setup).toHaveBeenCalledWith( + expect.objectContaining({ agents: true, target: 'claude-code', agentScope: 'global' }), + setupIo.io, + ); + expect(setup).toHaveBeenCalledWith( + expect.not.objectContaining({ installRoot: expect.anything() }), + setupIo.io, + ); + }); + + it('rejects --install-dir with --target claude-desktop', async () => { + const setup = vi.fn(async () => 0); + const setupIo = makeIo(); + + await expect( + runKtxCli( + ['--project-dir', tempDir, 'setup', '--agents', '--target', 'claude-desktop', '--install-dir', '.', '--no-input'], + setupIo.io, + { setup }, + ), + ).resolves.toBe(1); + + expect(setupIo.stderr()).toContain('--install-dir does not apply to --target claude-desktop, which is always 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/test/setup-agents.test.ts b/packages/cli/test/setup-agents.test.ts index b85ad9a5..5f7aa369 100644 --- a/packages/cli/test/setup-agents.test.ts +++ b/packages/cli/test/setup-agents.test.ts @@ -383,6 +383,7 @@ describe('setup agents', () => { const prompts = { select: vi.fn(async ({ message }: { message: string }) => (message.startsWith('Where') ? 'project' : 'mcp')), multiselect: vi.fn(async () => ['claude-code']), + text: vi.fn(async () => undefined), cancel: vi.fn(), }; @@ -439,6 +440,7 @@ describe('setup agents', () => { multiselect: vi.fn(async () => { throw new Error('target selection should not run'); }), + text: vi.fn(async () => undefined), cancel: vi.fn(), }; @@ -495,6 +497,7 @@ describe('setup agents', () => { message.startsWith('Where should') ? 'global' : 'mcp', ), multiselect: vi.fn(async () => ['claude-code']), + text: vi.fn(async () => undefined), cancel: vi.fn(), }; @@ -508,6 +511,7 @@ describe('setup agents', () => { scope: 'project', mode: 'mcp', skipAgents: false, + cwd: tempDir, }, io.io, { prompts }, @@ -518,13 +522,10 @@ describe('setup agents', () => { }); expect(prompts.select).toHaveBeenCalledWith({ - message: `Where should ktx install supported agent config?\n\nktx project: ${tempDir}`, + message: `Where should ktx install agent config?\n\nktx project: ${tempDir}`, options: [ - { - value: 'project', - label: 'Project scope (ktx project directory)', - hint: 'Only agents opened from this ktx project path load the project-scoped config.', - }, + { value: 'project', label: 'ktx project directory', hint: tempDir }, + { value: 'custom', label: 'Custom directory…', hint: 'Enter a path' }, { value: 'global', label: 'Global scope (user config)', @@ -978,6 +979,7 @@ describe('setup agents', () => { const prompts = { select: vi.fn(async () => 'back'), multiselect: vi.fn(async () => ['codex']), + text: vi.fn(async () => undefined), cancel: vi.fn(), }; @@ -1003,6 +1005,7 @@ describe('setup agents', () => { const prompts = { select: vi.fn(async () => 'mcp-cli'), multiselect: vi.fn(async () => ['back']), + text: vi.fn(async () => undefined), cancel: vi.fn(), }; @@ -1136,6 +1139,7 @@ describe('setup agents', () => { message.startsWith('Where should') ? 'project' : 'mcp', ), multiselect: vi.fn(async () => ['claude-code', 'claude-desktop']), + text: vi.fn(async () => undefined), cancel: vi.fn(), }; @@ -1228,8 +1232,11 @@ describe('setup agents', () => { it('explains next actions for Codex, Cursor, OpenCode, and universal MCP targets', async () => { const io = makeIo(); const prompts = { - select: vi.fn(async () => 'mcp-cli'), + select: vi.fn(async ({ message }: { message: string }) => + message.startsWith('Where') ? 'project' : 'mcp-cli', + ), multiselect: vi.fn(async () => ['codex', 'cursor', 'opencode', 'universal']), + text: vi.fn(async () => undefined), cancel: vi.fn(), }; @@ -1274,6 +1281,347 @@ describe('setup agents', () => { expect(output).toContain('.agents guidance installed'); }); + describe('install root', () => { + it('plans project-scoped files under installRoot, leaving projectDir as the default', () => { + const installRoot = join(tempDir, 'opened-here'); + expect( + plannedKtxAgentFiles({ + projectDir: tempDir, + installRoot, + target: 'claude-code', + scope: 'project', + mode: 'mcp-cli', + }), + ).toEqual([ + { kind: 'file', path: join(installRoot, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' }, + { kind: 'file', path: join(installRoot, '.claude/skills/ktx/SKILL.md'), role: 'skill' }, + { kind: 'file', path: join(installRoot, '.claude/rules/ktx.md'), role: 'rule' }, + ]); + + expect( + plannedKtxAgentFiles({ projectDir: tempDir, target: 'claude-code', scope: 'project', mode: 'mcp' }), + ).toEqual([ + { kind: 'file', path: join(tempDir, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' }, + ]); + }); + + it('shows the install path in the summary title only when installRoot differs from projectDir', () => { + const installRoot = join(tempDir, 'app'); + const custom = formatInstallSummaryLines( + [{ target: 'claude-code', scope: 'project', mode: 'mcp', installRoot }], + [ + { kind: 'file', path: join(installRoot, '.claude/skills/ktx-analytics/SKILL.md'), role: 'analytics-skill' }, + { kind: 'json-key', path: join(installRoot, '.mcp.json'), jsonPath: ['mcpServers', 'ktx'] }, + ], + tempDir, + ); + expect(custom[0].title).toBe(`Claude Code · ${installRoot}`); + + const same = formatInstallSummaryLines( + [{ target: 'claude-code', scope: 'project', mode: 'mcp', installRoot: tempDir }], + [], + tempDir, + ); + expect(same[0].title).toBe('Claude Code · Project scope'); + }); + + it('installs project files and next actions under an explicit installRoot', async () => { + const io = makeIo(); + const installRoot = join(tempDir, 'workspace'); + + const result = await runKtxSetupAgentsStep( + { + projectDir: tempDir, + inputMode: 'disabled', + yes: true, + agents: true, + target: 'claude-code', + scope: 'project', + mode: 'mcp-cli', + skipAgents: false, + installRoot, + }, + io.io, + ); + + expect(result).toMatchObject({ + status: 'ready', + installs: [{ target: 'claude-code', scope: 'project', mode: 'mcp-cli', installRoot }], + }); + await expect(stat(join(installRoot, '.claude/skills/ktx/SKILL.md'))).resolves.toBeDefined(); + const mcp = JSON.parse(await readFile(join(installRoot, '.mcp.json'), 'utf-8')) as { + mcpServers?: Record; + }; + expect(mcp.mcpServers).toHaveProperty('ktx'); + await expect(stat(join(tempDir, '.claude/skills/ktx/SKILL.md'))).rejects.toThrow(); + + const output = io.stdout(); + expect(output).toContain('Open Claude Code from the install directory:'); + expect(output).toContain(`cd '${installRoot}'`); + expect(output).toContain(`ktx mcp start --project-dir ${tempDir}`); + + expect(await readKtxAgentInstallManifest(tempDir)).toMatchObject({ + projectDir: tempDir, + installs: [{ target: 'claude-code', scope: 'project', mode: 'mcp-cli', installRoot }], + }); + }); + + it('fails when an explicit installRoot points at an existing file', async () => { + const io = makeIo(); + const filePath = join(tempDir, 'not-a-dir'); + await writeFile(filePath, 'x', 'utf-8'); + + await expect( + runKtxSetupAgentsStep( + { + projectDir: tempDir, + inputMode: 'disabled', + yes: true, + agents: true, + target: 'claude-code', + scope: 'project', + mode: 'mcp', + skipAgents: false, + installRoot: filePath, + }, + io.io, + ), + ).resolves.toEqual({ status: 'failed', projectDir: tempDir }); + expect(io.stderr()).toContain('is a file, not a directory'); + }); + + it('installs into the current directory and records it in the manifest', async () => { + const io = makeIo(); + const openedDir = join(tempDir, 'opened'); + await mkdir(openedDir, { recursive: true }); + const prompts = { + select: vi.fn(async ({ message }: { message: string }) => + message.startsWith('Where') ? 'current' : 'mcp', + ), + multiselect: vi.fn(async () => ['claude-code', 'cursor']), + text: vi.fn(async () => undefined), + cancel: vi.fn(), + }; + + const result = await runKtxSetupAgentsStep( + { + projectDir: tempDir, + inputMode: 'auto', + yes: false, + agents: true, + scope: 'project', + mode: 'mcp', + skipAgents: false, + cwd: openedDir, + }, + io.io, + { prompts }, + ); + + expect(result).toMatchObject({ + status: 'ready', + installs: [ + { target: 'claude-code', scope: 'project', mode: 'mcp', installRoot: openedDir }, + { target: 'cursor', scope: 'project', mode: 'mcp', installRoot: openedDir }, + ], + }); + await expect(stat(join(openedDir, '.claude/skills/ktx-analytics/SKILL.md'))).resolves.toBeDefined(); + await expect(stat(join(openedDir, '.cursor/mcp.json'))).resolves.toBeDefined(); + + const output = io.stdout(); + expect(output).toContain('Open Cursor from the install directory:'); + expect(output).toContain(openedDir); + + expect(await readKtxAgentInstallManifest(tempDir)).toMatchObject({ + installs: [ + { target: 'claude-code', scope: 'project', mode: 'mcp', installRoot: openedDir }, + { target: 'cursor', scope: 'project', mode: 'mcp', installRoot: openedDir }, + ], + }); + }); + + it('creates and installs into a typed custom directory', async () => { + const io = makeIo(); + const customDir = join(tempDir, 'custom-target'); + const prompts = { + select: vi.fn(async ({ message }: { message: string }) => + message.startsWith('Where') ? 'custom' : 'mcp', + ), + multiselect: vi.fn(async () => ['claude-code']), + text: vi.fn(async () => customDir), + cancel: vi.fn(), + }; + + const result = await runKtxSetupAgentsStep( + { + projectDir: tempDir, + inputMode: 'auto', + yes: false, + agents: true, + scope: 'project', + mode: 'mcp', + skipAgents: false, + cwd: tempDir, + }, + io.io, + { prompts }, + ); + + expect(result).toMatchObject({ + status: 'ready', + installs: [{ target: 'claude-code', scope: 'project', mode: 'mcp', installRoot: customDir }], + }); + await expect(stat(join(customDir, '.claude/skills/ktx-analytics/SKILL.md'))).resolves.toBeDefined(); + }); + + it('hides the current directory row when cwd equals the ktx project directory', async () => { + const io = makeIo(); + const prompts = { + select: vi.fn(async ({ message }: { message: string; options: Array<{ value: string }> }) => + message.startsWith('Where') ? 'project' : 'mcp', + ), + multiselect: vi.fn(async () => ['claude-code']), + text: vi.fn(async () => undefined), + cancel: vi.fn(), + }; + + await runKtxSetupAgentsStep( + { + projectDir: tempDir, + inputMode: 'auto', + yes: false, + agents: true, + scope: 'project', + mode: 'mcp', + skipAgents: false, + cwd: tempDir, + }, + io.io, + { prompts }, + ); + + const directoryCall = prompts.select.mock.calls.find(([opts]) => opts.message.startsWith('Where')); + expect(directoryCall).toBeDefined(); + expect(directoryCall?.[0].options.map((option) => option.value)).toEqual(['project', 'custom', 'global']); + }); + + it('re-prompts when a typed custom directory is an existing file', async () => { + const io = makeIo(); + const filePath = join(tempDir, 'afile'); + await writeFile(filePath, 'x', 'utf-8'); + const validDir = join(tempDir, 'valid'); + const prompts = { + select: vi.fn(async ({ message }: { message: string }) => + message.startsWith('Where') ? 'custom' : 'mcp', + ), + multiselect: vi.fn(async () => ['claude-code']), + text: vi.fn<() => Promise>().mockResolvedValueOnce(filePath).mockResolvedValueOnce(validDir), + cancel: vi.fn(), + }; + + const result = await runKtxSetupAgentsStep( + { + projectDir: tempDir, + inputMode: 'auto', + yes: false, + agents: true, + scope: 'project', + mode: 'mcp', + skipAgents: false, + cwd: tempDir, + }, + io.io, + { prompts }, + ); + + expect(result).toMatchObject({ + status: 'ready', + installs: [{ target: 'claude-code', scope: 'project', mode: 'mcp', installRoot: validDir }], + }); + expect(prompts.text).toHaveBeenCalledTimes(2); + expect(io.stderr()).toContain('is a file, not a directory'); + await expect(stat(join(validDir, '.claude/skills/ktx-analytics/SKILL.md'))).resolves.toBeDefined(); + }); + + it('expands a leading ~ in a typed custom directory', async () => { + const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-')); + const previousHome = process.env.HOME; + process.env.HOME = home; + try { + const io = makeIo(); + const prompts = { + select: vi.fn(async ({ message }: { message: string }) => + message.startsWith('Where') ? 'custom' : 'mcp', + ), + multiselect: vi.fn(async () => ['claude-code']), + text: vi.fn(async () => '~/opened-here'), + cancel: vi.fn(), + }; + + const expected = join(home, 'opened-here'); + const result = await runKtxSetupAgentsStep( + { + projectDir: tempDir, + inputMode: 'auto', + yes: false, + agents: true, + scope: 'project', + mode: 'mcp', + skipAgents: false, + cwd: tempDir, + }, + io.io, + { prompts }, + ); + + expect(result).toMatchObject({ + status: 'ready', + installs: [{ target: 'claude-code', scope: 'project', mode: 'mcp', installRoot: expected }], + }); + await expect(stat(join(expected, '.claude/skills/ktx-analytics/SKILL.md'))).resolves.toBeDefined(); + await expect(stat(join(tempDir, '~'))).rejects.toThrow(); + } finally { + process.env.HOME = previousHome; + await rm(home, { recursive: true, force: true }); + } + }); + + it('expands a leading ~ in an explicit installRoot', async () => { + const home = await mkdtemp(join(tmpdir(), 'ktx-setup-agents-home-')); + const previousHome = process.env.HOME; + process.env.HOME = home; + try { + const io = makeIo(); + const expected = join(home, 'flagged'); + + const result = await runKtxSetupAgentsStep( + { + projectDir: tempDir, + inputMode: 'disabled', + yes: true, + agents: true, + target: 'claude-code', + scope: 'project', + mode: 'mcp', + skipAgents: false, + installRoot: '~/flagged', + cwd: tempDir, + }, + io.io, + ); + + expect(result).toMatchObject({ + status: 'ready', + installs: [{ target: 'claude-code', scope: 'project', mode: 'mcp', installRoot: expected }], + }); + await expect(stat(join(expected, '.claude/skills/ktx-analytics/SKILL.md'))).resolves.toBeDefined(); + } finally { + process.env.HOME = previousHome; + await rm(home, { recursive: true, force: true }); + } + }); + }); + describe('createAgentNextActionsLineFormatter', () => { function makeColorStdout(): { write: (chunk: string) => boolean; hasColors: () => boolean } { return { write: () => true, hasColors: () => true }; diff --git a/packages/cli/test/setup-demo-tour.test.ts b/packages/cli/test/setup-demo-tour.test.ts index e3efeea9..64acb0fb 100644 --- a/packages/cli/test/setup-demo-tour.test.ts +++ b/packages/cli/test/setup-demo-tour.test.ts @@ -184,7 +184,7 @@ describe('runDemoTour', () => { const mockAgents = vi.fn().mockResolvedValue({ status: 'ready', projectDir: '/tmp/test', - installs: [{ target: 'claude-code', scope: 'project', mode: 'mcp-cli' }], + installs: [{ target: 'claude-code', scope: 'project', mode: 'mcp-cli', installRoot: '/tmp/test' }], } satisfies KtxSetupAgentsResult); const navigation = vi.fn().mockResolvedValue('forward'); diff --git a/packages/cli/test/setup.test.ts b/packages/cli/test/setup.test.ts index f24e744f..ee158248 100644 --- a/packages/cli/test/setup.test.ts +++ b/packages/cli/test/setup.test.ts @@ -2025,7 +2025,7 @@ describe('setup status', () => { return { status: 'ready', projectDir: tempDir, - installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli' }], + installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli', installRoot: tempDir }], }; }, }, @@ -2078,7 +2078,7 @@ describe('setup status', () => { agents: async () => ({ status: 'ready', projectDir: tempDir, - installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli' }], + installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli', installRoot: tempDir }], }), }, ), @@ -2133,7 +2133,7 @@ describe('setup status', () => { return { status: 'ready', projectDir: tempDir, - installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli' }], + installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli', installRoot: tempDir }], }; }, }, @@ -2150,7 +2150,7 @@ describe('setup status', () => { const agents = vi.fn(async () => ({ status: 'ready' as const, projectDir: tempDir, - installs: [{ target: 'codex' as const, scope: 'project' as const, mode: 'mcp-cli' as const }], + installs: [{ target: 'codex' as const, scope: 'project' as const, mode: 'mcp-cli' as const, installRoot: tempDir }], })); await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8'); @@ -2193,7 +2193,7 @@ describe('setup status', () => { const agents = vi.fn(async () => ({ status: 'ready' as const, projectDir: tempDir, - installs: [{ target: 'claude-code' as const, scope: 'project' as const, mode: 'mcp' as const }], + installs: [{ target: 'claude-code' as const, scope: 'project' as const, mode: 'mcp' as const, installRoot: tempDir }], })); await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8'); @@ -2272,7 +2272,7 @@ describe('setup status', () => { version: 1, projectDir: tempDir, installedAt: '2026-05-07T00:00:00.000Z', - installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli' }], + installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli', installRoot: tempDir }], entries: [], }, null, @@ -2347,7 +2347,7 @@ describe('setup status', () => { return { status: 'ready', projectDir: tempDir, - installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli' }], + installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli', installRoot: tempDir }], }; }, }, @@ -2453,7 +2453,7 @@ describe('setup status', () => { return { status: 'ready', projectDir: tempDir, - installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli' }], + installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli', installRoot: tempDir }], }; }, }, @@ -2636,7 +2636,7 @@ describe('setup status', () => { const agents = vi.fn(async () => ({ status: 'ready' as const, projectDir: tempDir, - installs: [{ target: 'universal' as const, scope: 'project' as const, mode: 'mcp-cli' as const }], + installs: [{ target: 'universal' as const, scope: 'project' as const, mode: 'mcp-cli' as const, installRoot: tempDir }], })); await expect( diff --git a/uv.lock b/uv.lock index 924d9159..e66330e3 100644 --- a/uv.lock +++ b/uv.lock @@ -466,7 +466,7 @@ wheels = [ [[package]] name = "ktx-daemon" -version = "0.11.0" +version = "0.12.0" source = { editable = "python/ktx-daemon" } dependencies = [ { name = "fastapi" }, @@ -523,7 +523,7 @@ dev = [ [[package]] name = "ktx-sl" -version = "0.11.0" +version = "0.12.0" source = { editable = "python/ktx-sl" } dependencies = [ { name = "pydantic" },