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

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