feat(cli): let ktx setup --agents choose an install directory (#298)

Split the fused directory concept into projectDir (what the agent config
references) and installRoot (where project-scoped files are written), so
users can install .claude/, .mcp.json, skills, and rules where they open
their agent instead of only in the ktx project directory.

- Add --install-dir <path> (resolved against cwd, created if missing,
  mutually exclusive with --global/--local, rejected for claude-desktop).
- Add an interactive directory menu: ktx project dir / Current directory
  (hidden when it equals the project dir) / Custom directory… / Global
  scope (shown only when every target supports it).
- Expand a leading ~ in typed/quoted paths so the ~/… menu hints round-trip.
- Record installRoot in the install manifest and merge key; thread it
  through file planning, MCP config paths, summaries, and next actions.
- Refresh uv.lock to 0.12.0 for the editable ktx-sl and ktx-daemon packages.
This commit is contained in:
Andrey Avtomonov 2026-06-13 00:46:56 +02:00 committed by GitHub
parent ed44f46f2a
commit 4e61020089
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 650 additions and 70 deletions

View file

@ -29,6 +29,7 @@ below.
| `--agents` | Install agent configuration and rules only | `false` |
| `--target <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 <path>` | 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 | - |

View file

@ -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 <path>` (for
example `--install-dir .`) for the same result.
## Generated files
**ktx** writes MCP client configuration and analytics guidance by default. It writes

View file

@ -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 <path>',
'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 } : {}),

View file

@ -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<KtxAgentInstall, 'installRoot'> & { 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<KtxMcpClientInstallResult> {
@ -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<Record<KtxAgentTarget, InstallEntry[]>> = {
'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<string, KtxAgentInstallManifest['installs'][number]>();
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<string, InstallEntry>();
for (const entry of [...(existing?.entries ?? []), ...entries]) {
@ -688,6 +709,7 @@ interface KtxSetupAgentsPromptAdapter {
options: KtxSetupPromptOption[];
required?: boolean;
}): Promise<string[]>;
text(options: { message: string; placeholder?: string }): Promise<string | undefined>;
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<KtxAgentTarget, InstallEntry[]>();
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<KtxAgentTarget, InstallEntry[]>();
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<void> {
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<string> {
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<string>();
@ -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,
});

View file

@ -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,

View file

@ -526,6 +526,7 @@ describe('runKtxCli', () => {
expect(stdout).toContain('--target <target>');
expect(stdout).toContain('--global');
expect(stdout).toContain('--local');
expect(stdout).toContain('--install-dir <path>');
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();

View file

@ -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<string, unknown>;
};
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<string | undefined>>().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 };

View file

@ -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');

View file

@ -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(

4
uv.lock generated
View file

@ -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" },