fix(cli): simplify setup flags and agents tty handling (#155)

* fix(cli): simplify setup flags and agents tty handling

* fix(context): update ingest setup guidance flag
This commit is contained in:
Andrey Avtomonov 2026-05-19 19:23:35 +02:00 committed by GitHub
parent efda990de0
commit 590dd5dddb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 251 additions and 155 deletions

View file

@ -67,7 +67,6 @@ Drive the existing wizard non-interactively (verify exact flag names with `ktx s
```
ktx setup \
--project-dir <path> \
--new \
--no-input --yes \
--llm-backend <claude-code|anthropic|vertex> --llm-model <model> \
[--anthropic-api-key-env ANTHROPIC_API_KEY | --anthropic-api-key-file <path>] \
@ -75,14 +74,22 @@ ktx setup \
--embedding-backend <sentence-transformers|openai> \
[--embedding-api-key-env OPENAI_API_KEY] \
--skip-sources \
--database <driver> --new-database-connection-id <name> --database-url <url|env:VAR|file:/path> \
--database <driver> --database-connection-id <name> --database-url <url|env:VAR|file:/path> \
[--database-schema <schema> …]
# repeat the --database / --new-database-connection-id / --database-url / --database-schema block per connection
```
Notes on the flags above:
- **`--new`** is required when bootstrapping an empty directory; use `--existing` instead when re-running setup against a project that already has a `ktx.yaml`.
- **There is no `--skip-agents` flag.** The agent integration step is opt-in: setup leaves it alone unless you pass `--agents --target <target>`. So you do not need to skip it — just don't pass `--agents`.
- **Project creation is automatic with `--no-input --yes`.** When
`ktx.yaml` exists, setup resumes it. When it doesn't exist, setup creates it
at `--project-dir`.
- **`--database-connection-id` is dual-purpose.** With `--database` or
`--database-url`, it names the new connection. Without those flags, it
selects an existing connection id.
- **Configure one new database connection per setup command.** If the user
wants multiple new connections, run setup again for each connection.
- **You don't need `--skip-agents` in this flow.** The agent integration step
is opt-in: setup leaves it alone unless you pass `--agents --target
<target>`.
- **`--skip-sources`** is correct and is the documented way to leave BI/metadata sources unconfigured.
### Known soft-failure: `ktx setup` exits 1 after a successful fast build
@ -172,7 +179,7 @@ Verdict: ready
Then **Next steps** (copy-pasteable):
1. Enrich with AI descriptions and embeddings: `ktx ingest <connection> --deep` (several minutes per connection).
2. Add more connections later by rerunning this setup or via `ktx setup --existing --database … --new-database-connection-id …`.
2. Add more connections later by rerunning this setup or via `ktx setup --database … --database-connection-id …`.
3. Configure BI sources (dbt, Metabase, Looker, LookML, MetricFlow, Notion) — see `ktx setup --help` for `--source …` flags.
4. Install agent integration: `ktx setup --agents --target <claude-code|claude-desktop|codex|cursor|opencode|universal>` (with optional `--global` for `claude-code`/`codex`).
5. Connect the agent / MCP: see docs at `https://docs.kaelio.com/ktx/`.

View file

@ -27,9 +27,9 @@ below.
| Flag | Description | Default |
|------|-------------|---------|
| `--agents` | Install agent configuration and rules only | `false` |
| `--target <target>` | Agent target: `claude-code`, `codex`, `cursor`, `opencode`, or `universal` | - |
| `--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` |
| `--yes` | Accept safe defaults in non-interactive setup | `false` |
| `--yes` | Accept project creation and runtime install defaults where setup asks for confirmation | `false` |
| `--no-input` | Disable interactive terminal input | - |
Use the global `--project-dir <path>` option when setup should target a
@ -40,12 +40,12 @@ specific directory.
These flags are useful for repeatable setup in examples, tests, CI fixtures, and
scripted project creation. They are not shown in `ktx setup --help`.
### Project Mode
### Project Creation
| Flag | Description | Default |
|------|-------------|---------|
| `--new` | Create a new KTX project before setup | `false` |
| `--existing` | Use an existing KTX project | `false` |
Setup resumes an existing `ktx.yaml` when one is present. When no project
exists, interactive setup prompts for where to create it. In scripts, pass
`--project-dir <dir> --no-input --yes` to create the target directory without
prompts.
### LLM Provider
@ -56,7 +56,6 @@ scripted project creation. They are not shown in `ktx setup --help`.
| `--llm-model <model>` | LLM model ID or backend model alias to validate and save |
| `--anthropic-api-key-env <name>` | Environment variable containing the Anthropic API key |
| `--anthropic-api-key-file <path>` | File containing the Anthropic API key |
| `--anthropic-model <model>` | Legacy alias for `--llm-model` |
| `--vertex-project <project>` | Vertex AI project ID, `env:NAME`, or `file:/path` reference |
| `--vertex-location <location>` | Vertex AI location, `env:NAME`, or `file:/path` reference |
| `--skip-llm` | Leave LLM setup incomplete |
@ -105,8 +104,7 @@ runtime features are missing.
| Flag | Description |
|------|-------------|
| `--database <driver>` | Database driver to configure; repeatable. Choices: `sqlite`, `postgres`, `mysql`, `clickhouse`, `sqlserver`, `bigquery`, `snowflake` |
| `--database-connection-id <id>` | Existing selected connection id; repeatable |
| `--new-database-connection-id <id>` | Connection id for one new database connection |
| `--database-connection-id <id>` | Existing selected connection id; repeatable. With `--database` or `--database-url`, connection id for the new connection. |
| `--database-url <url>` | URL, `env:NAME`, or `file:/path` for one new URL-style database connection; also used as the SQLite path |
| `--database-schema <schema>` | Database schema or dataset to include; repeatable |
| `--skip-databases` | Leave database setup incomplete |
@ -177,10 +175,11 @@ ktx setup \
ktx setup \
--project-dir ./analytics \
--no-input \
--yes \
--skip-llm \
--skip-embeddings \
--database postgres \
--new-database-connection-id warehouse \
--database-connection-id warehouse \
--database-url env:DATABASE_URL \
--database-schema public
@ -188,7 +187,7 @@ ktx setup \
ktx setup \
--project-dir ./analytics \
--database postgres \
--new-database-connection-id warehouse \
--database-connection-id warehouse \
--database-url env:DATABASE_URL \
--enable-query-history \
--query-history-min-executions 5
@ -236,4 +235,5 @@ Use `ktx status` for repeatable readiness checks after setup exits.
| `--enable-query-history` is rejected | The selected database driver does not support query history | Use Postgres, BigQuery, or Snowflake, or rerun without query-history flags |
| Source setup rejects location flags | Both `--source-path` and `--source-git-url` were supplied | Choose the local path or the Git URL, not both |
| Agent integration missing | Setup skipped the agents step | Run `ktx setup --agents --target <target>` |
| Agent setup cannot prompt for a target | Non-TTY `ktx setup --agents` needs a target | Run `ktx setup --agents --target <target>` or rerun in a TTY |
| Global agent install is rejected | `--global` was used with a target other than `claude-code` or `codex` | Omit `--global`, or choose `--target claude-code` or `--target codex` |

View file

@ -216,10 +216,11 @@ For repeatable fixtures and automation, skip prompts with flags:
ktx setup \
--project-dir ./analytics \
--no-input \
--yes \
--skip-llm \
--skip-embeddings \
--database postgres \
--new-database-connection-id warehouse \
--database-connection-id warehouse \
--database-url env:DATABASE_URL \
--database-schema public
```

View file

@ -85,8 +85,6 @@ function optionWasSpecified(command: Command, optionName: string): boolean {
function shouldShowSetupEntryMenu(
options: {
new?: boolean;
existing?: boolean;
agents?: boolean;
target?: string;
global?: boolean;
@ -98,7 +96,6 @@ function shouldShowSetupEntryMenu(
anthropicApiKeyEnv?: string;
anthropicApiKeyFile?: string;
llmModel?: string;
anthropicModel?: string;
vertexProject?: string;
vertexLocation?: string;
skipLlm?: boolean;
@ -108,7 +105,6 @@ function shouldShowSetupEntryMenu(
skipEmbeddings?: boolean;
database?: KtxSetupDatabaseDriver[];
databaseConnectionId?: string[];
newDatabaseConnectionId?: string;
databaseUrl?: string;
databaseSchema?: string[];
enableQueryHistory?: boolean;
@ -160,8 +156,6 @@ function shouldShowSetupEntryMenu(
}
return ![
'new',
'existing',
'agents',
'target',
'global',
@ -173,7 +167,6 @@ function shouldShowSetupEntryMenu(
'anthropicApiKeyEnv',
'anthropicApiKeyFile',
'llmModel',
'anthropicModel',
'vertexProject',
'vertexLocation',
'skipLlm',
@ -181,7 +174,6 @@ function shouldShowSetupEntryMenu(
'embeddingApiKeyEnv',
'embeddingApiKeyFile',
'skipEmbeddings',
'newDatabaseConnectionId',
'databaseUrl',
'enableQueryHistory',
'disableQueryHistory',
@ -214,8 +206,6 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
.command('setup')
.description('Set up or resume a local KTX project')
.addOption(new Option('--project-dir <path>', 'KTX project directory').hideHelp())
.addOption(new Option('--new', 'Create a new KTX project before setup').hideHelp().default(false))
.addOption(new Option('--existing', 'Use an existing KTX project').hideHelp().default(false))
.option('--agents', 'Install agent integration only', false)
.addOption(
new Option('--target <target>', 'Agent target').choices([
@ -230,7 +220,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('--yes', 'Accept project creation and runtime install defaults where setup confirms', false)
.option('--no-input', 'Disable interactive terminal input')
.addOption(new Option('--llm-backend <backend>', 'LLM backend').argParser(llmBackend).hideHelp())
.addOption(
@ -240,7 +230,6 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
new Option('--anthropic-api-key-file <path>', 'File containing the Anthropic API key').hideHelp(),
)
.addOption(new Option('--llm-model <model>', 'LLM model ID or backend model alias').hideHelp())
.addOption(new Option('--anthropic-model <model>', 'Anthropic model ID to validate and save').hideHelp())
.addOption(new Option('--vertex-project <project>', 'Google Vertex AI project ID, env:NAME, or file:/path').hideHelp())
.addOption(new Option('--vertex-location <location>', 'Google Vertex AI location, env:NAME, or file:/path').hideHelp())
.addOption(new Option('--skip-llm', 'Leave LLM setup incomplete for now').hideHelp().default(false))
@ -269,16 +258,6 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
.default([] as string[])
.hideHelp(),
)
.addOption(
new Option('--new-database-connection-id <id>', 'Connection id for one new database connection')
.argParser((value) => {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(value)) {
throw new InvalidArgumentError(`Unsafe connection id: ${value}`);
}
return value;
})
.hideHelp(),
)
.addOption(
new Option('--database-url <url>', 'URL, env:NAME, or file:/path for one new URL-style database connection').hideHelp(),
)
@ -365,11 +344,6 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
context.setExitCode(1);
return;
}
if (options.llmModel && options.anthropicModel) {
context.io.stderr.write('Choose only one LLM model flag: --llm-model or --anthropic-model.\n');
context.setExitCode(1);
return;
}
if (
options.llmBackend &&
options.llmBackend !== 'anthropic' &&
@ -419,12 +393,18 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
return;
}
const mode = options.new ? 'new' : options.existing ? 'existing' : 'auto';
const creatingDatabaseConnection = options.database.length > 0 || options.databaseUrl !== undefined;
if (creatingDatabaseConnection && options.databaseConnectionId.length > 1) {
context.io.stderr.write('Choose only one new database connection id when configuring a database.\n');
context.setExitCode(1);
return;
}
const resolvedAgentScope = options.local ? 'local' : options.global ? 'global' : 'project';
await runSetupArgs(context, {
command: 'run',
projectDir: resolveCommandProjectDir(command),
mode,
mode: 'auto',
agents: options.agents === true,
...(options.target ? { target: options.target } : {}),
agentScope: resolvedAgentScope,
@ -436,7 +416,6 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
...(options.anthropicApiKeyEnv ? { anthropicApiKeyEnv: options.anthropicApiKeyEnv } : {}),
...(options.anthropicApiKeyFile ? { anthropicApiKeyFile: options.anthropicApiKeyFile } : {}),
...(options.llmModel ? { llmModel: options.llmModel } : {}),
...(options.anthropicModel ? { anthropicModel: options.anthropicModel } : {}),
...(options.vertexProject ? { vertexProject: options.vertexProject } : {}),
...(options.vertexLocation ? { vertexLocation: options.vertexLocation } : {}),
skipLlm: options.skipLlm === true,
@ -445,8 +424,12 @@ export function registerSetupCommands(program: Command, context: KtxCliCommandCo
...(options.embeddingApiKeyFile ? { embeddingApiKeyFile: options.embeddingApiKeyFile } : {}),
skipEmbeddings: options.skipEmbeddings === true,
...(options.database.length > 0 ? { databaseDrivers: options.database } : {}),
...(options.databaseConnectionId.length > 0 ? { databaseConnectionIds: options.databaseConnectionId } : {}),
...(options.newDatabaseConnectionId ? { databaseConnectionId: options.newDatabaseConnectionId } : {}),
...(options.databaseConnectionId.length > 0 && creatingDatabaseConnection
? { databaseConnectionId: options.databaseConnectionId[0] }
: {}),
...(options.databaseConnectionId.length > 0 && !creatingDatabaseConnection
? { databaseConnectionIds: options.databaseConnectionId }
: {}),
...(options.databaseUrl ? { databaseUrl: options.databaseUrl } : {}),
databaseSchemas: options.databaseSchema,
...(options.enableQueryHistory ? { enableQueryHistory: true } : {}),

View file

@ -470,8 +470,6 @@ describe('runKtxCli', () => {
expect(stdout).not.toContain('setup context');
for (const hiddenFlag of [
'--new',
'--existing',
'--agent-scope',
'--skip-agents',
'--llm-backend',
@ -480,7 +478,6 @@ describe('runKtxCli', () => {
'--embedding-backend',
'--database ',
'--database-connection-id',
'--new-database-connection-id',
'--enable-historic-sql',
'--historic-sql-min-executions',
'--enable-query-history',
@ -842,8 +839,12 @@ describe('runKtxCli', () => {
it('rejects removed setup options', async () => {
const setup = vi.fn(async () => 0);
const cases = [
['setup', '--new'],
['setup', '--existing'],
['setup', '--project'],
['setup', '--agent-scope', 'global'],
['setup', '--anthropic-model', 'claude-sonnet-4-6'],
['setup', '--new-database-connection-id', 'warehouse'],
['setup', '--skip-initial-source-ingest'],
];
@ -1065,7 +1066,7 @@ describe('runKtxCli', () => {
'--no-input',
'--anthropic-api-key-env',
'ANTHROPIC_API_KEY',
'--anthropic-model',
'--llm-model',
'claude-sonnet-4-6',
],
setupIo.io,
@ -1080,7 +1081,7 @@ describe('runKtxCli', () => {
inputMode: 'disabled',
cliVersion: '0.1.0-rc.1',
anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
anthropicModel: 'claude-sonnet-4-6',
llmModel: 'claude-sonnet-4-6',
skipLlm: false,
}),
setupIo.io,
@ -1104,7 +1105,7 @@ describe('runKtxCli', () => {
'local-gcp-project',
'--vertex-location',
'us-east5',
'--anthropic-model',
'--llm-model',
'claude-sonnet-4-6',
],
setupIo.io,
@ -1121,7 +1122,7 @@ describe('runKtxCli', () => {
llmBackend: 'vertex',
vertexProject: 'local-gcp-project',
vertexLocation: 'us-east5',
anthropicModel: 'claude-sonnet-4-6',
llmModel: 'claude-sonnet-4-6',
skipLlm: false,
}),
setupIo.io,
@ -1239,7 +1240,7 @@ describe('runKtxCli', () => {
'--skip-embeddings',
'--database',
'postgres',
'--new-database-connection-id',
'--database-connection-id',
'warehouse',
'--database-url',
'env:DATABASE_URL',
@ -1283,18 +1284,41 @@ describe('runKtxCli', () => {
const setup = vi.fn(async () => 0);
await expect(
runKtxCli(['setup', '--new-database-connection-id', 'status', '--no-input'], testIo.io, { setup }),
runKtxCli(['setup', '--database-connection-id', 'status', '--no-input'], testIo.io, { setup }),
).resolves.toBe(0);
expect(setup).toHaveBeenCalledWith(
expect.objectContaining({
command: 'run',
databaseConnectionId: 'status',
databaseConnectionIds: ['status'],
}),
testIo.io,
);
});
it('dispatches non-TTY agents setup with target without requiring --no-input or --yes', async () => {
const testIo = makeIo({ stdoutIsTty: false });
const setup = vi.fn(async () => 0);
await expect(
runKtxCli(['--project-dir', tempDir, 'setup', '--agents', '--target', 'claude-code'], testIo.io, { setup }),
).resolves.toBe(0);
expect(setup).toHaveBeenCalledWith(
expect.objectContaining({
command: 'run',
projectDir: tempDir,
agents: true,
target: 'claude-code',
agentScope: 'project',
inputMode: 'auto',
yes: false,
}),
testIo.io,
);
expect(testIo.stderr()).toBe('');
});
it('dispatches setup source flags', async () => {
const setup = vi.fn(async () => 0);
const testIo = makeIo();

View file

@ -262,7 +262,7 @@ describe('runKtxIngest', () => {
{
command: 'run',
projectDir,
mode: 'new',
mode: 'auto',
agents: false,
agentScope: 'project',
skipAgents: true,
@ -322,7 +322,7 @@ describe('runKtxIngest', () => {
expect(runIo.stderr()).toContain('Configure a local Claude Code session or API-backed LLM, then rerun ingest:');
expect(runIo.stderr()).toContain(`ktx setup --project-dir ${projectDir} --llm-backend claude-code --no-input`);
expect(runIo.stderr()).toContain(
`ktx setup --project-dir ${projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`,
`ktx setup --project-dir ${projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --llm-model claude-sonnet-4-6 --no-input`,
);
});

View file

@ -184,6 +184,58 @@ describe('setup agents', () => {
expect(io.stderr()).toBe('');
});
it('installs a specified target in non-interactive mode without --yes', async () => {
const io = makeIo();
await expect(
runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: false,
agents: true,
target: 'claude-code',
scope: 'project',
mode: 'mcp',
skipAgents: false,
},
io.io,
),
).resolves.toMatchObject({
status: 'ready',
projectDir: tempDir,
installs: [{ target: 'claude-code', scope: 'project', mode: 'mcp' }],
});
await expect(stat(join(tempDir, '.claude/skills/ktx-analytics/SKILL.md'))).resolves.toBeDefined();
const mcpConfig = JSON.parse(await readFile(join(tempDir, '.mcp.json'), 'utf-8')) as {
mcpServers?: Record<string, unknown>;
};
expect(mcpConfig.mcpServers).toHaveProperty('ktx');
expect(io.stderr()).toBe('');
});
it('prints concrete target guidance when non-interactive agent setup has no target', async () => {
const io = makeIo();
await expect(
runKtxSetupAgentsStep(
{
projectDir: tempDir,
inputMode: 'disabled',
yes: false,
agents: true,
scope: 'project',
mode: 'mcp',
skipAgents: false,
},
io.io,
),
).resolves.toEqual({ status: 'missing-input', projectDir: tempDir });
expect(io.stderr()).toBe('Run in a TTY, or pass --target <target>.\n');
});
it('prints standalone agent next actions after successful installation', async () => {
const io = makeIo();

View file

@ -1222,7 +1222,11 @@ export async function runKtxSetupAgentsStep(
})) as KtxAgentTarget[]);
if (targets.includes('back' as KtxAgentTarget)) return { status: 'back', projectDir: args.projectDir };
if (targets.length === 0) {
io.stderr.write('Missing agent target: pass --target or use interactive setup.\n');
io.stderr.write(
args.inputMode === 'disabled'
? 'Run in a TTY, or pass --target <target>.\n'
: 'Missing agent target: pass --target or use interactive setup.\n',
);
return { status: 'missing-input', projectDir: args.projectDir };
}

View file

@ -307,7 +307,7 @@ describe('setup Anthropic model step', () => {
projectDir: tempDir,
inputMode: 'disabled',
anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
anthropicModel: 'claude-sonnet-4-6',
llmModel: 'claude-sonnet-4-6',
skipLlm: false,
},
io.io,
@ -351,7 +351,7 @@ describe('setup Anthropic model step', () => {
llmBackend: 'vertex',
vertexProject: 'local-gcp-project',
vertexLocation: 'us-east5',
anthropicModel: 'claude-sonnet-4-6',
llmModel: 'claude-sonnet-4-6',
skipLlm: false,
},
io.io,
@ -658,7 +658,7 @@ describe('setup Anthropic model step', () => {
llmBackend: 'vertex',
vertexProject: 'kaelio-orbit-looker-20260430',
vertexLocation: 'us-east5',
anthropicModel: 'claude-sonnet-4-6',
llmModel: 'claude-sonnet-4-6',
skipLlm: false,
},
io.io,
@ -686,7 +686,7 @@ describe('setup Anthropic model step', () => {
projectDir: tempDir,
inputMode: 'disabled',
anthropicApiKeyFile: secretPath,
anthropicModel: 'claude-sonnet-4-6',
llmModel: 'claude-sonnet-4-6',
skipLlm: false,
},
io.io,
@ -723,7 +723,7 @@ describe('setup Anthropic model step', () => {
projectDir: tempDir,
inputMode: 'disabled',
anthropicApiKeyFile: missingSecretPath,
anthropicModel: 'claude-sonnet-4-6',
llmModel: 'claude-sonnet-4-6',
skipLlm: false,
},
io.io,
@ -1014,7 +1014,7 @@ describe('setup Anthropic model step', () => {
projectDir: tempDir,
inputMode: 'disabled',
anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
anthropicModel: 'claude-sonnet-4-6',
llmModel: 'claude-sonnet-4-6',
skipLlm: false,
},
io.io,

View file

@ -37,7 +37,6 @@ export interface KtxSetupModelArgs {
anthropicApiKeyEnv?: string;
anthropicApiKeyFile?: string;
llmModel?: string;
anthropicModel?: string;
vertexProject?: string;
vertexLocation?: string;
forcePrompt?: boolean;
@ -478,14 +477,14 @@ function requestedBackend(args: KtxSetupModelArgs): KtxSetupLlmBackend | undefin
if (args.vertexProject || args.vertexLocation) {
return 'vertex';
}
if (args.anthropicApiKeyEnv || args.anthropicApiKeyFile || args.llmModel || args.anthropicModel) {
if (args.anthropicApiKeyEnv || args.anthropicApiKeyFile || args.llmModel) {
return 'anthropic';
}
return undefined;
}
function requestedModel(args: KtxSetupModelArgs): string | undefined {
return args.llmModel ?? args.anthropicModel;
return args.llmModel;
}
async function chooseBackend(
@ -929,7 +928,6 @@ export async function runKtxSetupAnthropicModelStep(
!args.anthropicApiKeyEnv &&
!args.anthropicApiKeyFile &&
!args.llmModel &&
!args.anthropicModel &&
!args.vertexProject &&
!args.vertexLocation
) {

View file

@ -55,12 +55,12 @@ describe('setup project step', () => {
await rm(tempDir, { recursive: true, force: true });
});
it('creates a new project with --new and marks the project step complete', async () => {
it('creates a new project in non-interactive auto mode with --yes and marks the project step complete', async () => {
const projectDir = join(tempDir, 'warehouse');
const testIo = makeIo();
const result = await runKtxSetupProjectStep(
{ projectDir, mode: 'new', inputMode: 'disabled', yes: false },
{ projectDir, mode: 'auto', inputMode: 'disabled', yes: true },
testIo.io,
);
@ -74,7 +74,7 @@ describe('setup project step', () => {
expect(testIo.stderr()).toBe('');
});
it('loads an existing project with --existing and drops config setup progress', async () => {
it('loads an existing project in auto mode and drops config setup progress', async () => {
const projectDir = join(tempDir, 'warehouse');
await initKtxProject({ projectDir });
await writeFile(
@ -91,7 +91,7 @@ describe('setup project step', () => {
);
const result = await runKtxSetupProjectStep(
{ projectDir, mode: 'existing', inputMode: 'disabled', yes: false },
{ projectDir, mode: 'auto', inputMode: 'disabled', yes: false },
makeIo().io,
);
@ -112,7 +112,7 @@ describe('setup project step', () => {
await expect(
runKtxSetupProjectStep({ projectDir, mode: 'auto', inputMode: 'disabled', yes: false }, rejectedIo.io),
).resolves.toMatchObject({ status: 'missing-input' });
expect(rejectedIo.stderr()).toContain('Missing setup choice: pass --new or --yes');
expect(rejectedIo.stderr()).toContain('Missing setup choice: pass --yes');
await expect(stat(join(projectDir, 'ktx.yaml'))).rejects.toThrow();
await expect(
@ -121,15 +121,15 @@ describe('setup project step', () => {
await expect(stat(join(projectDir, 'ktx.yaml'))).resolves.toBeDefined();
});
it('fails --existing clearly when ktx.yaml is missing', async () => {
it('fails clearly in no-input auto mode when ktx.yaml is missing and --yes is absent', async () => {
const projectDir = join(tempDir, 'warehouse');
const testIo = makeIo();
await expect(
runKtxSetupProjectStep({ projectDir, mode: 'existing', inputMode: 'disabled', yes: false }, testIo.io),
runKtxSetupProjectStep({ projectDir, mode: 'auto', inputMode: 'disabled', yes: false }, testIo.io),
).resolves.toMatchObject({ status: 'missing-input' });
expect(testIo.stderr()).toContain(`No existing KTX project found at ${projectDir}`);
expect(testIo.stderr()).toContain('Missing setup choice: pass --yes');
});
it('prompts to use the current directory and creates a project in interactive auto mode', async () => {

View file

@ -18,7 +18,7 @@ import {
type KtxSetupPromptOption,
} from './setup-prompts.js';
export type KtxSetupProjectMode = 'auto' | 'new' | 'existing' | 'prompt-new';
export type KtxSetupProjectMode = 'auto' | 'prompt-new';
export type KtxSetupInputMode = 'auto' | 'disabled';
export interface KtxSetupProjectArgs {
@ -283,35 +283,14 @@ export async function runKtxSetupProjectStep(
const homeDir = deps.homeDir ?? homedir();
const exists = hasProjectConfig(projectDir);
if (args.mode === 'existing') {
if (!exists) {
io.stderr.write(`No existing KTX project found at ${projectDir}. Pass --new to create it.\n`);
return { status: 'missing-input', projectDir };
}
const project = await loadExistingProject(projectDir, deps);
printProjectSummary(io, projectDir);
return { status: 'ready', projectDir, project };
}
if (args.mode === 'new') {
const { project, createdProjectCleanup } = await createProjectWithCleanup(projectDir, deps);
printProjectSummary(io, projectDir);
return {
status: 'ready',
projectDir,
project,
...(createdProjectCleanup ? { createdProjectCleanup } : {}),
};
}
if (args.mode === 'prompt-new') {
if (args.inputMode === 'disabled') {
io.stderr.write('Missing new project folder: pass --new --project-dir to create a project without prompts.\n');
io.stderr.write('Missing new project folder: pass --project-dir and --yes to create a project without prompts.\n');
return { status: 'missing-input', projectDir };
}
if (!io.stdout.isTTY && !deps.prompts) {
io.stderr.write(
'Missing new project folder: pass --new --project-dir to create a project outside an interactive terminal.\n',
'Missing new project folder: pass --project-dir and --yes to create a project outside an interactive terminal.\n',
);
return { status: 'missing-input', projectDir };
}
@ -344,7 +323,7 @@ export async function runKtxSetupProjectStep(
if (args.inputMode === 'disabled') {
if (!args.yes) {
io.stderr.write('Missing setup choice: pass --new or --yes to create a project in non-interactive setup.\n');
io.stderr.write('Missing setup choice: pass --yes to create a project in non-interactive setup.\n');
return { status: 'missing-input', projectDir };
}
const { project, createdProjectCleanup } = await createProjectWithCleanup(projectDir, deps);
@ -358,7 +337,7 @@ export async function runKtxSetupProjectStep(
}
if (!io.stdout.isTTY && !deps.prompts) {
io.stderr.write('Missing setup choice: pass --new or --yes to create a project outside an interactive terminal.\n');
io.stderr.write('Missing setup choice: pass --yes to create a project outside an interactive terminal.\n');
return { status: 'missing-input', projectDir };
}

View file

@ -480,7 +480,7 @@ describe('setup status', () => {
{
command: 'run',
projectDir: tempDir,
mode: 'new',
mode: 'auto',
agents: false,
target: 'claude-code',
skipAgents: false,
@ -1053,7 +1053,7 @@ describe('setup status', () => {
);
});
it('creates a project through run mode when --new is selected', async () => {
it('creates a project through run mode when --yes is selected', async () => {
const testIo = makeIo();
await expect(
@ -1061,11 +1061,11 @@ describe('setup status', () => {
{
command: 'run',
projectDir: tempDir,
mode: 'new',
mode: 'auto',
agents: false,
skipAgents: true,
inputMode: 'disabled',
yes: false,
yes: true,
cliVersion: '0.2.0',
skipLlm: true,
skipEmbeddings: true,
@ -1153,14 +1153,14 @@ describe('setup status', () => {
{
command: 'run',
projectDir: tempDir,
mode: 'new',
mode: 'auto',
agents: false,
skipAgents: true,
inputMode: 'disabled',
yes: false,
yes: true,
cliVersion: '0.2.0',
anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
anthropicModel: 'claude-sonnet-4-6',
llmModel: 'claude-sonnet-4-6',
skipLlm: false,
skipEmbeddings: true,
databaseSchemas: [],
@ -1177,7 +1177,7 @@ describe('setup status', () => {
projectDir: tempDir,
inputMode: 'disabled',
anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
anthropicModel: 'claude-sonnet-4-6',
llmModel: 'claude-sonnet-4-6',
skipLlm: false,
}),
testIo.io,
@ -1193,16 +1193,16 @@ describe('setup status', () => {
{
command: 'run',
projectDir: tempDir,
mode: 'new',
mode: 'auto',
agents: false,
skipAgents: true,
inputMode: 'disabled',
yes: false,
yes: true,
cliVersion: '0.2.0',
llmBackend: 'vertex',
vertexProject: 'local-gcp-project',
vertexLocation: 'us-east5',
anthropicModel: 'claude-sonnet-4-6',
llmModel: 'claude-sonnet-4-6',
skipLlm: false,
skipEmbeddings: true,
databaseSchemas: [],
@ -1221,7 +1221,7 @@ describe('setup status', () => {
llmBackend: 'vertex',
vertexProject: 'local-gcp-project',
vertexLocation: 'us-east5',
anthropicModel: 'claude-sonnet-4-6',
llmModel: 'claude-sonnet-4-6',
skipLlm: false,
}),
testIo.io,
@ -1238,14 +1238,14 @@ describe('setup status', () => {
{
command: 'run',
projectDir: tempDir,
mode: 'new',
mode: 'auto',
agents: false,
skipAgents: true,
inputMode: 'disabled',
yes: true,
cliVersion: '0.2.0',
anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
anthropicModel: 'claude-sonnet-4-6',
llmModel: 'claude-sonnet-4-6',
skipLlm: false,
embeddingBackend: 'openai',
embeddingApiKeyEnv: 'OPENAI_API_KEY', // pragma: allowlist secret
@ -1276,13 +1276,14 @@ describe('setup status', () => {
it('passes no-input runtime policy to the embeddings step', async () => {
const io = makeIo();
const embeddings = vi.fn(async () => ({ status: 'failed' as const, projectDir: tempDir }));
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
await expect(
runKtxSetup(
{
command: 'run',
projectDir: tempDir,
mode: 'new',
mode: 'auto',
agents: false,
agentScope: 'project',
skipAgents: true,
@ -1313,13 +1314,14 @@ describe('setup status', () => {
const io = makeIo();
const embeddings = vi.fn(async () => ({ status: 'ready' as const, projectDir: tempDir }));
const context = vi.fn(async () => ({ status: 'failed' as const, projectDir: tempDir }));
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
await expect(
runKtxSetup(
{
command: 'run',
projectDir: tempDir,
mode: 'new',
mode: 'auto',
agents: false,
agentScope: 'project',
skipAgents: true,
@ -1358,6 +1360,7 @@ describe('setup status', () => {
it('lets Back from embedding setup return to the model step instead of exiting', async () => {
const testIo = makeIo();
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
const modelResults = [
{ status: 'ready' as const, projectDir: tempDir },
{ status: 'back' as const, projectDir: tempDir },
@ -1370,7 +1373,7 @@ describe('setup status', () => {
{
command: 'run',
projectDir: tempDir,
mode: 'new',
mode: 'auto',
agents: false,
skipAgents: true,
inputMode: 'auto',
@ -1394,6 +1397,7 @@ describe('setup status', () => {
it('lets Back from database selection return to embedding setup', async () => {
const testIo = makeIo();
await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
const modelResults = [
{ status: 'ready' as const, projectDir: tempDir },
{ status: 'back' as const, projectDir: tempDir },
@ -1417,11 +1421,11 @@ describe('setup status', () => {
{
command: 'run',
projectDir: tempDir,
mode: 'new',
mode: 'auto',
agents: false,
skipAgents: true,
inputMode: 'auto',
yes: false,
yes: true,
cliVersion: '0.2.0',
skipLlm: false,
skipEmbeddings: false,
@ -1464,7 +1468,7 @@ describe('setup status', () => {
agents: false,
skipAgents: true,
inputMode: 'auto',
yes: false,
yes: true,
cliVersion: '0.2.0',
skipLlm: false,
skipEmbeddings: true,
@ -1501,14 +1505,14 @@ describe('setup status', () => {
{
command: 'run',
projectDir: tempDir,
mode: 'new',
mode: 'auto',
agents: false,
skipAgents: true,
inputMode: 'disabled',
yes: false,
yes: true,
cliVersion: '0.2.0',
anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
anthropicModel: 'claude-sonnet-4-6',
llmModel: 'claude-sonnet-4-6',
skipLlm: false,
embeddingBackend: 'openai',
embeddingApiKeyEnv: 'OPENAI_API_KEY', // pragma: allowlist secret
@ -1559,7 +1563,7 @@ describe('setup status', () => {
{
command: 'run',
projectDir: tempDir,
mode: 'existing',
mode: 'auto',
agents: false,
skipAgents: true,
inputMode: 'disabled',
@ -1635,7 +1639,7 @@ describe('setup status', () => {
{
command: 'run',
projectDir: tempDir,
mode: 'existing',
mode: 'auto',
agents: false,
skipAgents: true,
inputMode: 'disabled',
@ -1685,7 +1689,7 @@ describe('setup status', () => {
{
command: 'run',
projectDir: tempDir,
mode: 'existing',
mode: 'auto',
agents: false,
skipAgents: true,
inputMode: 'disabled',
@ -1733,7 +1737,7 @@ describe('setup status', () => {
{
command: 'run',
projectDir: tempDir,
mode: 'existing',
mode: 'auto',
agents: false,
inputMode: 'disabled',
yes: true,
@ -1794,7 +1798,7 @@ describe('setup status', () => {
{
command: 'run',
projectDir: tempDir,
mode: 'new',
mode: 'auto',
agents: false,
inputMode: 'disabled',
yes: true,
@ -1851,7 +1855,7 @@ describe('setup status', () => {
{
command: 'run',
projectDir: tempDir,
mode: 'existing',
mode: 'auto',
agents: true,
target: 'codex',
agentScope: 'project',
@ -1910,7 +1914,7 @@ describe('setup status', () => {
{
command: 'run',
projectDir: tempDir,
mode: 'existing',
mode: 'auto',
agents: true,
target: 'codex',
agentScope: 'project',
@ -1939,6 +1943,53 @@ describe('setup status', () => {
expect(io.stderr()).not.toContain('KTX context is not ready for agents.');
});
it('runs non-TTY --agents with a target without requiring --no-input or --yes', async () => {
const io = makeIo();
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 }],
}));
await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8');
await expect(
runKtxSetup(
{
command: 'run',
projectDir: tempDir,
mode: 'auto',
agents: true,
target: 'claude-code',
agentScope: 'project',
inputMode: 'auto',
yes: false,
cliVersion: '0.2.0',
skipLlm: false,
skipEmbeddings: false,
skipDatabases: false,
skipSources: false,
skipAgents: false,
databaseSchemas: [],
},
io.io,
{ agents },
),
).resolves.toBe(0);
expect(agents).toHaveBeenCalledWith(
expect.objectContaining({
inputMode: 'disabled',
yes: false,
agents: true,
target: 'claude-code',
scope: 'project',
mode: 'mcp',
}),
io.io,
);
expect(io.stderr()).not.toContain('Interactive setup requires a terminal');
});
it('routes a ready project menu selection to agent setup', async () => {
const calls: string[] = [];
const io = makeIo();
@ -2003,7 +2054,7 @@ describe('setup status', () => {
{
command: 'run',
projectDir: tempDir,
mode: 'existing',
mode: 'auto',
agents: false,
inputMode: 'auto',
yes: false,
@ -2106,7 +2157,7 @@ describe('setup status', () => {
{
command: 'run',
projectDir: tempDir,
mode: 'existing',
mode: 'auto',
agents: false,
inputMode: 'auto',
yes: false,
@ -2172,7 +2223,7 @@ describe('setup status', () => {
{
command: 'run',
projectDir: tempDir,
mode: 'new',
mode: 'auto',
agents: true,
target: 'universal',
agentScope: 'project',
@ -2213,14 +2264,14 @@ describe('setup status', () => {
{
command: 'run',
projectDir: tempDir,
mode: 'new',
mode: 'auto',
agents: false,
skipAgents: true,
inputMode: 'disabled',
yes: false,
yes: true,
cliVersion: '0.2.0',
anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
anthropicModel: 'claude-sonnet-4-6',
llmModel: 'claude-sonnet-4-6',
skipLlm: false,
skipEmbeddings: false,
databaseSchemas: [],
@ -2231,6 +2282,7 @@ describe('setup status', () => {
),
).resolves.toBe(1);
expect(model).toHaveBeenCalledTimes(1);
expect(embeddings).not.toHaveBeenCalled();
});
});

View file

@ -81,7 +81,7 @@ export type KtxSetupArgs =
| {
command: 'run';
projectDir: string;
mode: 'auto' | 'new' | 'existing';
mode: 'auto';
agents: boolean;
target?: KtxAgentTarget;
agentScope?: KtxAgentScope;
@ -93,7 +93,6 @@ export type KtxSetupArgs =
anthropicApiKeyEnv?: string;
anthropicApiKeyFile?: string;
llmModel?: string;
anthropicModel?: string;
vertexProject?: string;
vertexLocation?: string;
skipLlm: boolean;
@ -655,7 +654,6 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
...(args.anthropicApiKeyEnv ? { anthropicApiKeyEnv: args.anthropicApiKeyEnv } : {}),
...(args.anthropicApiKeyFile ? { anthropicApiKeyFile: args.anthropicApiKeyFile } : {}),
...(args.llmModel ? { llmModel: args.llmModel } : {}),
...(args.anthropicModel ? { anthropicModel: args.anthropicModel } : {}),
...(args.vertexProject ? { vertexProject: args.vertexProject } : {}),
...(args.vertexLocation ? { vertexLocation: args.vertexLocation } : {}),
forcePrompt: forcePromptSteps.has('models') || runOnly === 'models',
@ -776,7 +774,10 @@ async function runKtxSetupInner(args: KtxSetupArgs, io: KtxCliIo, deps: KtxSetup
const agentResult = await agentsRunner(
{
projectDir: projectResult.projectDir,
inputMode: args.inputMode,
inputMode:
args.inputMode === 'auto' && io.stdout.isTTY !== true && deps.agentsDeps?.prompts === undefined
? 'disabled'
: args.inputMode,
yes: args.yes,
agents: true,
...(args.target ? { target: args.target } : {}),

View file

@ -116,7 +116,6 @@ async function runSetupNewProject(projectDir: string): Promise<CliResult> {
'setup',
'--project-dir',
projectDir,
'--new',
'--no-input',
'--yes',
'--skip-llm',

View file

@ -80,7 +80,7 @@ describe('createLocalBundleIngestRuntime', () => {
'ktx ingest requires llm.provider.backend: anthropic, vertex, gateway, or claude-code, or an injected agentRunner.',
'Configure a local Claude Code session or API-backed LLM, then rerun ingest:',
` ktx setup --project-dir ${project.projectDir} --llm-backend claude-code --no-input`,
` ktx setup --project-dir ${project.projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`,
` ktx setup --project-dir ${project.projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --llm-model claude-sonnet-4-6 --no-input`,
].join('\n'),
);
});

View file

@ -629,7 +629,7 @@ function localIngestLlmProviderGuardMessage(projectDir: string): string {
'ktx ingest requires llm.provider.backend: anthropic, vertex, gateway, or claude-code, or an injected agentRunner.',
'Configure a local Claude Code session or API-backed LLM, then rerun ingest:',
` ktx setup --project-dir ${projectDir} --llm-backend claude-code --no-input`,
` ktx setup --project-dir ${projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --anthropic-model claude-sonnet-4-6 --no-input`,
` ktx setup --project-dir ${projectDir} --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY --llm-model claude-sonnet-4-6 --no-input`,
].join('\n');
}

View file

@ -106,7 +106,6 @@ export function localEmbeddingsSmokeCommands(input) {
'setup',
'--project-dir',
input.projectDir,
'--new',
'--no-input',
'--yes',
'--skip-llm',

View file

@ -114,7 +114,6 @@ describe('localEmbeddingsSmokeCommands', () => {
'setup',
'--project-dir',
'/tmp/ktx-local-embedding-smoke/project',
'--new',
'--no-input',
'--yes',
'--skip-llm',

View file

@ -620,7 +620,6 @@ try {
'setup',
'--project-dir',
projectDir,
'--new',
'--no-input',
'--yes',
'--skip-llm',
@ -638,7 +637,6 @@ try {
'setup',
'--project-dir',
emptyProjectDir,
'--new',
'--no-input',
'--yes',
'--skip-llm',