import { execFile } from 'node:child_process';
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { promisify } from 'node:util';
import { writeKtxSetupState } from '../src/context/project/setup-config.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { localFakeBundleReport, persistLocalBundleReport } from './ingest.test-utils.js';
import { contextBuildCommands, writeKtxSetupContextState } from '../src/setup-context.js';
import { runDemoTour } from '../src/setup-demo-tour.js';
import { formatKtxSetupCompletionSummary, formatKtxSetupStatus, readKtxSetupStatus, runKtxSetup } from '../src/setup.js';

vi.mock('../src/setup-demo-tour.js', () => ({
  runDemoTour: vi.fn(async () => 0),
}));

const execFileAsync = promisify(execFile);

function makeIo() {
  let stdout = '';
  let stderr = '';
  return {
    io: {
      stdout: {
        isTTY: false,
        write: (chunk: string) => {
          stdout += chunk;
        },
      },
      stderr: {
        write: (chunk: string) => {
          stderr += chunk;
        },
      },
    },
    stdout: () => stdout,
    stderr: () => stderr,
  };
}

function runtimeReady(projectDir: string) {
  return { status: 'ready' as const, projectDir, requirements: { features: ['core' as const], requirements: [] } };
}

async function writeReadyRuntime(rootDir: string, cliVersion = '0.2.0') {
  const runtimeRoot = join(rootDir, '.runtime');
  const versionDir = join(runtimeRoot, cliVersion);
  const pythonPath = join(versionDir, '.venv', 'bin', 'python');
  const daemonPath = join(versionDir, '.venv', 'bin', 'ktx-daemon');
  await mkdir(join(versionDir, '.venv', 'bin'), { recursive: true });
  await writeFile(pythonPath, '', 'utf-8');
  await writeFile(daemonPath, '', 'utf-8');
  await writeFile(
    join(versionDir, 'manifest.json'),
    `${JSON.stringify(
      {
        schemaVersion: 1,
        cliVersion,
        installedAt: '2026-05-09T10:02:00.000Z',
        asset: {
          schemaVersion: 1,
          distributionName: 'kaelio-ktx',
          normalizedName: 'kaelio_ktx',
          version: '0.1.0',
          wheel: {
            file: 'kaelio_ktx-0.1.0-py3-none-any.whl',
            sha256: '0'.repeat(64),
            bytes: 0,
          },
        },
        features: ['core'],
        python: {
          executable: pythonPath,
          daemonExecutable: daemonPath,
        },
        installLog: join(versionDir, 'install.log'),
      },
      null,
      2,
    )}\n`,
    'utf-8',
  );
  return runtimeRoot;
}

describe('setup status', () => {
  let tempDir: string;

  beforeEach(async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'ktx-setup-status-'));
  });

  afterEach(async () => {
    vi.unstubAllEnvs();
    await rm(tempDir, { recursive: true, force: true });
  });

  it('reports a missing project without creating files', async () => {
    const status = await readKtxSetupStatus(tempDir);

    expect(status).toMatchObject({
      project: { path: tempDir, ready: false },
      llm: { ready: false },
      embeddings: { ready: false },
      databases: [],
      sources: [],
      context: { ready: false, status: 'not_started' },
      agents: [],
    });
  });

  it('reports disabled default embeddings as not setup-ready', async () => {
    await mkdir(tempDir, { recursive: true });
    await writeFile(
      join(tempDir, 'ktx.yaml'),
      [
        'llm:',
        '  provider:',
        '    backend: anthropic',
        '    anthropic:',
        '      api_key: env:ANTHROPIC_API_KEY', // pragma: allowlist secret
        '  models:',
        '    default: claude-sonnet-4-6',
        'ingest:',
        '  embeddings:',
        '    backend: none',
        '    dimensions: 8',
        'connections: {}',
      ].join('\n'),
      'utf-8',
    );

    await expect(readKtxSetupStatus(tempDir)).resolves.toMatchObject({
      project: { path: tempDir, ready: true },
      llm: { backend: 'anthropic', ready: true, model: 'claude-sonnet-4-6' },
      embeddings: { backend: 'none', ready: false, dimensions: 8 },
    });
  });

  it.each([
    {
      backend: 'vertex',
      providerLines: ['    backend: vertex', '    vertex:', '      project: kaelio-dev', '      location: us-east5'],
      model: 'claude-sonnet-4-6',
    },
    {
      backend: 'gateway',
      providerLines: ['    backend: gateway', '    gateway:', '      api_key: env:AI_GATEWAY_API_KEY'],
      model: 'anthropic/claude-sonnet-4-6',
    },
  ])('reports configured $backend llm backends as setup-ready', async (fixture) => {
    await mkdir(tempDir, { recursive: true });
    await writeFile(
      join(tempDir, 'ktx.yaml'),
      [
        'llm:',
        '  provider:',
        ...fixture.providerLines,
        '  models:',
        `    default: ${fixture.model}`,
        'connections: {}',
      ].join('\n'),
      'utf-8',
    );

    await expect(readKtxSetupStatus(tempDir)).resolves.toMatchObject({
      llm: { backend: fixture.backend, ready: true, model: fixture.model },
    });
  });

  it('uses setup database connection ids when present', async () => {
    await writeFile(
      join(tempDir, 'ktx.yaml'),
      [
        'setup:',
        '  database_connection_ids:',
        '    - warehouse',
        '    - analytics',
        'connections:',
        '  warehouse:',
        '    driver: postgres',
        '    url: env:WAREHOUSE_URL',
        'ingest:',
        '  embeddings:',
        '    backend: openai',
        '    model: text-embedding-3-small',
        '    dimensions: 1536',
        '    openai:',
        '      api_key: env:OPENAI_API_KEY', // pragma: allowlist secret
      ].join('\n'),
      'utf-8',
    );
    await writeKtxSetupState(tempDir, { completed_steps: ['project', 'databases'] });

    await expect(readKtxSetupStatus(tempDir)).resolves.toMatchObject({
      databases: [
        { connectionId: 'warehouse', ready: true },
        { connectionId: 'analytics', ready: false },
      ],
    });
  });

  it('reports selected databases as ready only after the database setup step is complete', async () => {
    await writeFile(
      join(tempDir, 'ktx.yaml'),
      [
        'setup:',
        '  database_connection_ids:',
        '    - warehouse',
        'connections:',
        '  warehouse:',
        '    driver: postgres',
        '    url: env:DATABASE_URL',
        '',
      ].join('\n'),
      'utf-8',
    );
    await writeKtxSetupState(tempDir, { completed_steps: ['project'] });

    await expect(readKtxSetupStatus(tempDir)).resolves.toMatchObject({
      databases: [{ connectionId: 'warehouse', ready: false }],
    });

    await writeFile(
      join(tempDir, 'ktx.yaml'),
      [
        'setup:',
        '  database_connection_ids:',
        '    - warehouse',
        'connections:',
        '  warehouse:',
        '    driver: postgres',
        '    url: env:DATABASE_URL',
        '',
      ].join('\n'),
      'utf-8',
    );
    await writeKtxSetupState(tempDir, { completed_steps: ['project', 'databases'] });

    await expect(readKtxSetupStatus(tempDir)).resolves.toMatchObject({
      databases: [{ connectionId: 'warehouse', ready: true }],
    });
  });

  it('reports source status from configured source connections', async () => {
    await writeFile(
      join(tempDir, 'ktx.yaml'),
      [
        'setup:',
        '  database_connection_ids: []',
        'connections:',
        '  docs:',
        '    driver: notion',
        '    auth_token_ref: env:NOTION_TOKEN',
        '    crawl_mode: all_accessible',
        '  warehouse:',
        '    driver: postgres',
        '    url: env:DATABASE_URL',
        '',
      ].join('\n'),
      'utf-8',
    );
    await writeKtxSetupState(tempDir, { completed_steps: ['project', 'sources'] });

    await expect(readKtxSetupStatus(tempDir)).resolves.toMatchObject({
      sources: [{ connectionId: 'docs', type: 'notion', ready: true }],
    });
  });

  it('reports agent status from the install manifest', async () => {
    await mkdir(join(tempDir, '.ktx', 'agents'), { recursive: true });
    await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
    await writeFile(
      join(tempDir, '.ktx/agents/install-manifest.json'),
      JSON.stringify(
        {
          version: 1,
          projectDir: tempDir,
          installedAt: '2026-05-07T00:00:00.000Z',
          installs: [
            { target: 'codex', scope: 'project', mode: 'mcp' },
            { target: 'codex', scope: 'project', mode: 'mcp-cli' },
          ],
          entries: [],
        },
        null,
        2,
      ),
      'utf-8',
    );

    await expect(readKtxSetupStatus(tempDir)).resolves.toMatchObject({
      agents: [{ target: 'codex', scope: 'project', ready: true }],
    });
  });

  it('reports setup-managed context build status and commands', async () => {
    await writeFile(
      join(tempDir, 'ktx.yaml'),
      [
        'setup:',
        '  database_connection_ids:',
        '    - warehouse',
        'connections:',
        '  warehouse:',
        '    driver: postgres',
        '    url: env:DATABASE_URL',
        'llm:',
        '  provider:',
        '    backend: anthropic',
        '  models:',
        '    default: claude-sonnet-4-6',
        'ingest:',
        '  embeddings:',
        '    backend: openai',
        '    model: text-embedding-3-small',
        '    dimensions: 1536',
        '',
      ].join('\n'),
      'utf-8',
    );
    await writeKtxSetupState(tempDir, {
      completed_steps: ['project', 'llm', 'embeddings', 'databases', 'sources'],
    });
    await writeKtxSetupContextState(tempDir, {
      runId: 'setup-context-local-abc123',
      status: 'stale',
      startedAt: '2026-05-09T10:00:00.000Z',
      updatedAt: '2026-05-09T10:01:00.000Z',
      primarySourceConnectionIds: ['warehouse'],
      contextSourceConnectionIds: [],
      reportIds: [],
      artifactPaths: [],
      retryableFailedTargets: [],
      commands: contextBuildCommands(tempDir),
      failureReason: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.',
    });

    await expect(readKtxSetupStatus(tempDir)).resolves.toMatchObject({
      context: {
        ready: false,
        status: 'stale',
        runId: 'setup-context-local-abc123',
        statusCommand: `ktx status --project-dir ${tempDir}`,
        detail: 'Previous foreground context build did not finish. Rerun setup or ktx ingest.',
      },
    });
  });

  it('reports Vertex LLM and context ready after a successful Metabase ingest report', async () => {
    await writeFile(
      join(tempDir, 'ktx.yaml'),
      [
        'setup:',
        '  database_connection_ids:',
        '    - warehouse',
        'connections:',
        '  warehouse:',
        '    driver: postgres',
        '    url: env:DATABASE_URL',
        '  metabase:',
        '    driver: metabase',
        '    api_url: https://metabase.example.test',
        '    api_key_ref: env:METABASE_API_KEY',
        '    warehouse_connection_id: warehouse',
        'llm:',
        '  provider:',
        '    backend: vertex',
        '    vertex:',
        '      project: kaelio-dev',
        '      location: us-east5',
        '  models:',
        '    default: claude-sonnet-4-6',
        'ingest:',
        '  embeddings:',
        '    backend: none',
        '    dimensions: 8',
        '',
      ].join('\n'),
      'utf-8',
    );
    await writeKtxSetupState(tempDir, { completed_steps: ['project', 'databases', 'sources'] });
    await persistLocalBundleReport(
      tempDir,
      localFakeBundleReport('metabase-job-1', {
        connectionId: 'warehouse',
        sourceKey: 'metabase',
      }),
    );

    const status = await readKtxSetupStatus(tempDir);
    const rendered = formatKtxSetupStatus(status);

    expect(status.llm).toMatchObject({ backend: 'vertex', ready: true, model: 'claude-sonnet-4-6' });
    expect(status.context).toMatchObject({ ready: true, status: 'completed' });
    expect(rendered).toContain('LLM ready: yes (claude-sonnet-4-6)');
    expect(rendered).toContain('KTX context built: yes');
  });

  it('reports context ready after a partial ingest report saved memory', async () => {
    await writeFile(
      join(tempDir, 'ktx.yaml'),
      [
        'setup:',
        '  database_connection_ids:',
        '    - warehouse',
        'connections:',
        '  warehouse:',
        '    driver: postgres',
        '    url: env:DATABASE_URL',
        'ingest:',
        '  embeddings:',
        '    backend: none',
        '    dimensions: 8',
        '',
      ].join('\n'),
      'utf-8',
    );
    await writeKtxSetupState(tempDir, { completed_steps: ['project', 'databases'] });
    await persistLocalBundleReport(
      tempDir,
      localFakeBundleReport('warehouse-job-partial', {
        connectionId: 'warehouse',
        sourceKey: 'fake',
        body: {
          failedWorkUnits: ['orders-bad'],
          workUnits: [
            {
              unitKey: 'orders-ok',
              rawFiles: ['orders/orders.json'],
              status: 'success',
              actions: [{ target: 'wiki', type: 'created', key: 'wiki/orders.md', detail: 'orders' }],
              touchedSlSources: [],
            },
            {
              unitKey: 'orders-bad',
              rawFiles: ['orders/bad.json'],
              status: 'failed',
              reason: 'writer tool failed',
              actions: [],
              touchedSlSources: [],
            },
          ],
        },
      }),
    );

    const status = await readKtxSetupStatus(tempDir);

    expect(status.context).toMatchObject({ ready: true, status: 'completed' });
  });

  it('formats plain and JSON setup status payloads', async () => {
    const status = await readKtxSetupStatus(tempDir);
    const rendered = formatKtxSetupStatus(status);

    expect(rendered).toContain(`No KTX project found at ${tempDir}.`);
    expect(rendered).toContain('Check another project: ktx --project-dir <folder> status');
    expect(rendered).toContain('Or from that folder: ktx status');
    expect(rendered).toContain('Create a new KTX project here: ktx setup');
    expect(rendered).not.toContain('Project ready: no');
    expect(JSON.parse(JSON.stringify(status))).toMatchObject({ project: { path: tempDir, ready: false } });
  });

  it('prints the readiness checklist for an existing project', async () => {
    await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');

    const rendered = formatKtxSetupStatus(await readKtxSetupStatus(tempDir));

    expect(rendered).toContain(`KTX project: ${tempDir}`);
    expect(rendered).toContain('Project ready: yes');
    expect(rendered).toContain('LLM ready: no');
    expect(rendered).toContain('Databases configured: no');
    expect(rendered).not.toContain(['Primary sources', 'configured'].join(' '));
    expect(rendered).toContain('KTX context built: no');
    expect(rendered).not.toContain('No KTX project found.');
  });

  it('formats a concise ready summary for completed agent setup', () => {
    const rendered = formatKtxSetupCompletionSummary(
      {
        project: { path: tempDir, ready: true },
        llm: { ready: true, model: 'sonnet' },
        embeddings: { ready: true, model: 'text-embedding-3-small' },
        databases: [{ connectionId: 'postgres-warehouse', ready: true }],
        sources: [{ connectionId: 'dbt-main', type: 'dbt', ready: true }],
        runtime: { required: true, ready: true, features: ['core'] },
        context: { ready: true, status: 'completed' },
        agents: [
          { target: 'claude-code', scope: 'project', ready: true },
          { target: 'claude-desktop', scope: 'global', ready: true },
        ],
      },
      {
        agentNextActions: [
          '1. Start MCP',
          '  Run this command before using Claude Code:',
          '',
          '  RUN:',
          `  ktx mcp start --project-dir ${tempDir}`,
          '',
          '  If you need to stop MCP later:',
          `  ktx mcp stop --project-dir ${tempDir}`,
          '',
          '2. Open Claude Code',
          '  Open Claude Code from the KTX project directory:',
          '',
          '  RUN:',
          `  cd '${tempDir}'`,
          '  claude',
        ].join('\n'),
      },
    );

    expect(rendered).toContain(`Project\n  ${tempDir}`);
    expect(rendered).toContain('Context\n  built');
    expect(rendered).toContain('Agents configured\n  Claude Code, Claude Desktop');
    expect(rendered).toContain('REQUIRED BEFORE USING AGENTS\n\n  1. Start MCP');
    expect(rendered).toContain('    Run this command before using Claude Code:');
    expect(rendered).toContain('    RUN:');
    expect(rendered).toContain('    If you need to stop MCP later:');
    expect(rendered).toContain(`ktx mcp stop --project-dir ${tempDir}`);
    expect(rendered).toContain('After that, try\n  Ask your agent: "Use KTX to show me the available tables."');
    expect(rendered).not.toContain('Verify');
    expect(rendered).not.toContain('Project ready: yes');
    expect(rendered).not.toContain('What you can do next');
  });

  it('prints agent next actions inside the final ready summary during full setup', async () => {
    const testIo = makeIo();

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          target: 'claude-code',
          skipAgents: false,
          inputMode: 'disabled',
          yes: true,
          cliVersion: '0.2.0',
          skipLlm: true,
          skipEmbeddings: true,
          skipDatabases: true,
          skipSources: true,
          databaseSchemas: [],
        },
        testIo.io,
        {
          runtime: async () => runtimeReady(tempDir),
          context: async () => {
            await writeKtxSetupContextState(tempDir, {
              runId: 'setup-context-local-test',
              status: 'completed',
              primarySourceConnectionIds: [],
              contextSourceConnectionIds: [],
              reportIds: [],
              artifactPaths: [],
              retryableFailedTargets: [],
              commands: contextBuildCommands(tempDir),
            });
            await writeKtxSetupState(tempDir, { completed_steps: ['project', 'context'] });
            return { status: 'ready', projectDir: tempDir, runId: 'setup-context-local-test' };
          },
        },
      ),
    ).resolves.toBe(0);

    const output = testIo.stdout();
    expect(output).toContain('Claude Code · Project scope');
    expect(output).toContain(join(tempDir, '.mcp.json'));
    expect(output).toContain('Requires MCP to be started.');
    expect(output).toContain('Analytics skill installed.');
    expect(output).not.toContain('Agent integration complete');
    expect(output).toContain('Finish KTX agent setup');
    expect(output).not.toContain('KTX project ready');
    expect(output).toContain('REQUIRED BEFORE USING AGENTS');
    expect(output).toContain('Run this command before using Claude Code:');
    expect(output).toContain(`ktx mcp start --project-dir ${tempDir}`);
    expect(output).not.toContain('Finish agent setup');
  });

  it('emits debug telemetry for setup steps without project paths', async () => {
    vi.stubEnv('KTX_TELEMETRY_DEBUG', '1');
    vi.stubEnv('CI', '');
    const testIo = makeIo();
    testIo.io.stdout.isTTY = true;

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          skipAgents: true,
          inputMode: 'disabled',
          yes: true,
          cliVersion: '0.2.0',
          skipLlm: true,
          skipEmbeddings: true,
          skipDatabases: true,
          skipSources: true,
          databaseSchemas: [],
        },
        testIo.io,
        {
          runtime: async () => runtimeReady(tempDir),
          context: async () => ({ status: 'ready', projectDir: tempDir, runId: 'setup-context-local-test' }),
        },
      ),
    ).resolves.toBe(0);

    expect(testIo.stderr()).toContain('"event":"setup_step"');
    expect(testIo.stderr()).toContain('"step":"project"');
    expect(testIo.stderr()).toContain('"step":"models"');
    expect(testIo.stderr()).not.toContain(tempDir);
  });

  it('prints the setup shell intro for auto-created run mode', async () => {
    const testIo = makeIo();

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          skipAgents: true,
          inputMode: 'disabled',
          yes: true,
          cliVersion: '0.2.0',
          skipLlm: true,
          skipEmbeddings: true,
          databaseSchemas: [],
          skipDatabases: true,
          skipSources: true,
        },
        testIo.io,
      ),
    ).resolves.toBe(0);

    expect(testIo.stdout()).toContain('KTX setup');
    expect(testIo.stdout()).toContain(`Project: ${tempDir}`);
    expect(testIo.stdout()).toContain('Project ready: yes');
    expect(testIo.stdout()).toContain('What you can do next:');
    expect(testIo.stdout()).toContain('Connect data, then build context.');
    expect(testIo.stdout()).toContain('ktx setup');
    expect(testIo.stdout()).not.toContain('ktx agent context --json');
    expect(testIo.stdout()).not.toContain('Optional MCP:');
    expect(testIo.stderr()).toBe('');
  });

  it('preserves a newly created missing project directory when a later setup step fails', async () => {
    const projectDir = join(tempDir, 'missing-project');
    const testIo = makeIo();

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir,
          mode: 'auto',
          agents: false,
          skipAgents: true,
          inputMode: 'disabled',
          yes: true,
          cliVersion: '0.2.0',
          skipLlm: true,
          skipEmbeddings: true,
          databaseSchemas: [],
          skipDatabases: true,
          skipSources: true,
        },
        testIo.io,
        {
          model: async () => ({ status: 'skipped', projectDir }),
          embeddings: async () => ({ status: 'skipped', projectDir }),
          databases: async () => ({ status: 'skipped', projectDir }),
          sources: async () => ({ status: 'skipped', projectDir }),
          runtime: async () => ({ status: 'failed', projectDir, requirements: { features: ['core'], requirements: [] } }),
        },
      ),
    ).resolves.toBe(1);

    await expect(stat(projectDir)).resolves.toBeDefined();
    await expect(stat(join(projectDir, 'ktx.yaml'))).resolves.toBeDefined();
    await expect(stat(join(projectDir, '.ktx'))).resolves.toBeDefined();
  });

  it('preserves KTX scaffold files in an initially empty project directory when setup fails', async () => {
    const testIo = makeIo();

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          skipAgents: true,
          inputMode: 'disabled',
          yes: true,
          cliVersion: '0.2.0',
          skipLlm: true,
          skipEmbeddings: true,
          databaseSchemas: [],
          skipDatabases: true,
          skipSources: true,
        },
        testIo.io,
        {
          model: async () => ({ status: 'skipped', projectDir: tempDir }),
          embeddings: async () => ({ status: 'skipped', projectDir: tempDir }),
          databases: async () => ({ status: 'skipped', projectDir: tempDir }),
          sources: async () => ({ status: 'skipped', projectDir: tempDir }),
          runtime: async () => ({ status: 'failed', projectDir: tempDir, requirements: { features: ['core'], requirements: [] } }),
        },
      ),
    ).resolves.toBe(1);

    await expect(stat(join(tempDir, 'ktx.yaml'))).resolves.toBeDefined();
    await expect(stat(join(tempDir, '.ktx'))).resolves.toBeDefined();
  });

  it('preserves partial context-build artifacts and resume state when the context step fails', async () => {
    const projectDir = join(tempDir, 'partial-context');
    const testIo = makeIo();

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir,
          mode: 'auto',
          agents: false,
          skipAgents: true,
          inputMode: 'disabled',
          yes: true,
          cliVersion: '0.2.0',
          skipLlm: true,
          skipEmbeddings: true,
          databaseSchemas: [],
          skipDatabases: true,
          skipSources: true,
        },
        testIo.io,
        {
          model: async () => ({ status: 'skipped', projectDir }),
          embeddings: async () => ({ status: 'skipped', projectDir }),
          databases: async () => ({ status: 'skipped', projectDir }),
          sources: async () => ({ status: 'skipped', projectDir }),
          runtime: async () => runtimeReady(projectDir),
          context: async () => {
            await mkdir(join(projectDir, '.ktx', 'setup'), { recursive: true });
            await writeFile(
              join(projectDir, '.ktx', 'setup', 'state.json'),
              JSON.stringify({ status: 'failed', retryableFailedTargets: [{ source: 'metabase' }] }),
              'utf-8',
            );
            await mkdir(join(projectDir, 'wiki'), { recursive: true });
            await writeFile(join(projectDir, 'wiki', 'postgres-warehouse.md'), '# warehouse\n', 'utf-8');
            await mkdir(join(projectDir, 'semantic-layer'), { recursive: true });
            await writeFile(join(projectDir, 'semantic-layer', 'orders.yaml'), 'name: orders\n', 'utf-8');
            return { status: 'failed', projectDir };
          },
        },
      ),
    ).resolves.toBe(1);

    await expect(stat(join(projectDir, 'ktx.yaml'))).resolves.toBeDefined();
    await expect(readFile(join(projectDir, '.ktx', 'setup', 'state.json'), 'utf-8')).resolves.toContain('"status":"failed"');
    await expect(readFile(join(projectDir, 'wiki', 'postgres-warehouse.md'), 'utf-8')).resolves.toContain('warehouse');
    await expect(readFile(join(projectDir, 'semantic-layer', 'orders.yaml'), 'utf-8')).resolves.toContain('orders');
  });

  it('preserves a pre-existing non-empty project directory when runtime setup fails', async () => {
    await writeFile(join(tempDir, 'notes.txt'), 'keep me\n', 'utf-8');
    const testIo = makeIo();

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          skipAgents: true,
          inputMode: 'disabled',
          yes: true,
          cliVersion: '0.2.0',
          skipLlm: true,
          skipEmbeddings: true,
          databaseSchemas: [],
          skipDatabases: true,
          skipSources: true,
        },
        testIo.io,
        {
          model: async () => ({ status: 'skipped', projectDir: tempDir }),
          embeddings: async () => ({ status: 'skipped', projectDir: tempDir }),
          databases: async () => ({ status: 'skipped', projectDir: tempDir }),
          sources: async () => ({ status: 'skipped', projectDir: tempDir }),
          runtime: async () => ({ status: 'failed', projectDir: tempDir, requirements: { features: ['core'], requirements: [] } }),
        },
      ),
    ).resolves.toBe(1);

    await expect(readFile(join(tempDir, 'notes.txt'), 'utf-8')).resolves.toBe('keep me\n');
    await expect(stat(join(tempDir, 'ktx.yaml'))).resolves.toBeDefined();
  });

  it('shows demo near the bottom of the first setup intent menu before project creation', async () => {
    const testIo = makeIo();
    const select = vi.fn(async (options: { options: Array<{ value: string; label: string }> }) => {
      const labels = options.options.map((option) => option.label);
      expect(labels).toEqual([
        'Set up KTX for my data',
        'Check setup status',
        'Explore a pre-built KTX project',
        'Exit',
      ]);
      expect(labels.indexOf('Explore a pre-built KTX project')).toBe(labels.length - 2);
      return 'exit';
    });
    const cancel = vi.fn();

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          skipAgents: false,
          inputMode: 'auto',
          yes: false,
          cliVersion: '0.2.0',
          skipLlm: false,
          skipEmbeddings: false,
          databaseSchemas: [],
          skipDatabases: false,
          skipSources: false,
          showEntryMenu: true,
        },
        testIo.io,
        { entryMenuDeps: { prompts: { select, cancel } } },
      ),
    ).resolves.toBe(0);

    expect(select).toHaveBeenCalledWith(expect.objectContaining({ message: 'What do you want to do?' }));
    expect(cancel).toHaveBeenCalledWith('Setup cancelled.');
  });

  it('shows agent connection only when the selected setup project exists', async () => {
    const missingIo = makeIo();
    const existingIo = makeIo();
    const missingSelect = vi.fn(async (options: { options: Array<{ value: string; label: string }> }) => {
      expect(options.options.map((option) => option.label)).not.toContain('Connect a coding agent to KTX');
      return 'exit';
    });
    const existingSelect = vi.fn(async (options: { options: Array<{ value: string; label: string }> }) => {
      const labels = options.options.map((option) => option.label);
      expect(labels).toEqual([
        'Resume or change an existing setup',
        'Create a new KTX project',
        'Connect a coding agent to KTX',
        'Check setup status',
        'Explore a pre-built KTX project',
        'Exit',
      ]);
      return 'exit';
    });

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          skipAgents: false,
          inputMode: 'auto',
          yes: false,
          cliVersion: '0.2.0',
          skipLlm: false,
          skipEmbeddings: false,
          databaseSchemas: [],
          skipDatabases: false,
          skipSources: false,
          showEntryMenu: true,
        },
        missingIo.io,
        { entryMenuDeps: { prompts: { select: missingSelect, cancel: vi.fn() } } },
      ),
    ).resolves.toBe(0);

    await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          skipAgents: false,
          inputMode: 'auto',
          yes: false,
          cliVersion: '0.2.0',
          skipLlm: false,
          skipEmbeddings: false,
          databaseSchemas: [],
          skipDatabases: false,
          skipSources: false,
          showEntryMenu: true,
        },
        existingIo.io,
        { entryMenuDeps: { prompts: { select: existingSelect, cancel: vi.fn() } } },
      ),
    ).resolves.toBe(0);

    expect(missingSelect).toHaveBeenCalledTimes(1);
    expect(existingSelect).toHaveBeenCalledTimes(1);
  });

  it('lets Back from project selection return to the first setup intent menu', async () => {
    const entryChoices = ['setup', 'exit'];
    const entryPrompts = {
      select: vi.fn(async () => entryChoices.shift() ?? 'exit'),
      cancel: vi.fn(),
    };
    const projectPrompts = {
      select: vi.fn(async () => 'back'),
      text: vi.fn(),
      cancel: vi.fn(),
    };

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          skipAgents: true,
          inputMode: 'auto',
          yes: false,
          cliVersion: '0.2.0',
          skipLlm: true,
          skipEmbeddings: true,
          databaseSchemas: [],
          skipDatabases: true,
          skipSources: true,
          showEntryMenu: true,
        },
        makeIo().io,
        {
          entryMenuDeps: { prompts: entryPrompts },
          project: { prompts: projectPrompts },
        },
      ),
    ).resolves.toBe(0);

    expect(projectPrompts.select).toHaveBeenCalledWith(
      expect.objectContaining({
        message: 'Where should KTX create the project?',
        options: expect.arrayContaining([expect.objectContaining({ value: 'back', label: 'Back' })]),
      }),
    );
    expect(projectPrompts.select).toHaveBeenCalledWith(
      expect.objectContaining({
        message: 'Where should KTX create the project?',
        options: expect.not.arrayContaining([expect.objectContaining({ value: 'exit', label: 'Exit' })]),
      }),
    );
    expect(entryPrompts.select).toHaveBeenCalledTimes(2);
    expect(entryPrompts.cancel).toHaveBeenCalledWith('Setup cancelled.');
    expect(projectPrompts.cancel).not.toHaveBeenCalled();
    await expect(stat(join(tempDir, 'ktx.yaml'))).rejects.toThrow();
  });

  it('lets Back from new project creation return to the first setup intent menu', async () => {
    const existingConfig = 'connections: {}\n';
    await writeFile(join(tempDir, 'ktx.yaml'), existingConfig, 'utf-8');

    const entryChoices = ['new-project', 'exit'];
    const entryPrompts = {
      select: vi.fn(async () => entryChoices.shift() ?? 'exit'),
      cancel: vi.fn(),
    };
    const projectPrompts = {
      select: vi.fn(async () => 'back'),
      text: vi.fn(),
      cancel: vi.fn(),
    };

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          skipAgents: true,
          inputMode: 'auto',
          yes: false,
          cliVersion: '0.2.0',
          skipLlm: true,
          skipEmbeddings: true,
          databaseSchemas: [],
          skipDatabases: true,
          skipSources: true,
          showEntryMenu: true,
        },
        makeIo().io,
        {
          entryMenuDeps: { prompts: entryPrompts },
          project: { prompts: projectPrompts },
        },
      ),
    ).resolves.toBe(0);

    expect(projectPrompts.select).toHaveBeenCalledWith(
      expect.objectContaining({
        message: 'Where should KTX create the project?',
        options: expect.arrayContaining([expect.objectContaining({ value: 'back', label: 'Back' })]),
      }),
    );
    expect(entryPrompts.select).toHaveBeenCalledTimes(2);
    expect(entryPrompts.cancel).toHaveBeenCalledWith('Setup cancelled.');
    expect(projectPrompts.cancel).not.toHaveBeenCalled();
    await expect(readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).resolves.toBe(existingConfig);
  });

  it('creates a separate project when the existing setup menu chooses new project', async () => {
    const existingProjectDir = join(tempDir, 'existing');
    const newProjectDir = join(tempDir, 'fresh');
    await mkdir(existingProjectDir, { recursive: true });
    const existingConfig = 'connections: {}\n';
    await writeFile(join(existingProjectDir, 'ktx.yaml'), existingConfig, 'utf-8');

    const projectChoices = ['custom', 'create'];
    const projectPrompts = {
      select: vi.fn(async () => projectChoices.shift() ?? 'exit'),
      text: vi.fn(async () => newProjectDir),
      cancel: vi.fn(),
    };
    const model = vi.fn(async (args: { projectDir: string }) => ({
      status: 'skipped' as const,
      projectDir: args.projectDir,
    }));
    const embeddings = vi.fn(async (args: { projectDir: string }) => ({
      status: 'skipped' as const,
      projectDir: args.projectDir,
    }));
    const databases = vi.fn(async (args: { projectDir: string }) => ({
      status: 'skipped' as const,
      projectDir: args.projectDir,
    }));
    const sources = vi.fn(async (args: { projectDir: string }) => ({
      status: 'skipped' as const,
      projectDir: args.projectDir,
    }));

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: existingProjectDir,
          mode: 'auto',
          agents: false,
          skipAgents: true,
          inputMode: 'auto',
          yes: false,
          cliVersion: '0.2.0',
          skipLlm: true,
          skipEmbeddings: true,
          databaseSchemas: [],
          skipDatabases: true,
          skipSources: true,
          showEntryMenu: true,
        },
        makeIo().io,
        {
          entryMenuDeps: { prompts: { select: vi.fn(async () => 'new-project'), cancel: vi.fn() } },
          project: { prompts: projectPrompts },
          model,
          embeddings,
          databases,
          sources,
        },
      ),
    ).resolves.toBe(0);

    expect(projectPrompts.text).toHaveBeenCalledWith(
      expect.objectContaining({
        message: 'Project folder path\n│  Press Escape to go back.\n│',
        placeholder: './analytics-ktx, ~/analytics-ktx, or /Users/you/projects/analytics-ktx',
      }),
    );
    expect(projectPrompts.select).toHaveBeenCalledWith(
      expect.objectContaining({ message: 'Where should KTX create the project?' }),
    );
    await expect(stat(join(newProjectDir, 'ktx.yaml'))).resolves.toBeDefined();
    await expect(readFile(join(existingProjectDir, 'ktx.yaml'), 'utf-8')).resolves.toBe(existingConfig);
    expect(model).toHaveBeenCalledWith(expect.objectContaining({ projectDir: newProjectDir }), expect.anything());
    expect(embeddings).toHaveBeenCalledWith(expect.objectContaining({ projectDir: newProjectDir }), expect.anything());
    expect(databases).toHaveBeenCalledWith(expect.objectContaining({ projectDir: newProjectDir }), expect.anything());
    expect(sources).toHaveBeenCalledWith(expect.objectContaining({ projectDir: newProjectDir }), expect.anything());
  });

  it('does not print navigation instructions immediately after confirming new project creation', async () => {
    const existingProjectDir = join(tempDir, 'existing');
    const newProjectDir = join(tempDir, 'fresh');
    await mkdir(existingProjectDir, { recursive: true });
    await writeFile(join(existingProjectDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');

    const projectChoices = ['custom', 'create'];
    const projectPrompts = {
      select: vi.fn(async () => projectChoices.shift() ?? 'exit'),
      text: vi.fn(async () => newProjectDir),
      cancel: vi.fn(),
    };
    const model = vi.fn(async (args: { projectDir: string; showPromptInstructions?: boolean }) => {
      expect(args.showPromptInstructions).toBe(false);
      return { status: 'skipped' as const, projectDir: args.projectDir };
    });
    const testIo = makeIo();

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: existingProjectDir,
          mode: 'auto',
          agents: false,
          skipAgents: true,
          inputMode: 'auto',
          yes: false,
          cliVersion: '0.2.0',
          skipLlm: false,
          skipEmbeddings: true,
          databaseSchemas: [],
          skipDatabases: true,
          skipSources: true,
          showEntryMenu: true,
        },
        testIo.io,
        {
          entryMenuDeps: { prompts: { select: vi.fn(async () => 'new-project'), cancel: vi.fn() } },
          project: { prompts: projectPrompts },
          model,
        },
      ),
    ).resolves.toBe(0);

    expect(testIo.stdout()).toContain(`Project: ${newProjectDir}\n`);
    expect(testIo.stdout()).not.toContain(
      'Use Up/Down to move, Enter to confirm the current selection, choose Back to return to the previous step, Ctrl+C to exit.',
    );
  });

  it('runs the demo tour when the first setup intent menu chooses demo', async () => {
    const testIo = makeIo();

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          skipAgents: false,
          inputMode: 'auto',
          yes: false,
          cliVersion: '0.2.0',
          skipLlm: false,
          skipEmbeddings: false,
          databaseSchemas: [],
          skipDatabases: false,
          skipSources: false,
          showEntryMenu: true,
        },
        testIo.io,
        { entryMenuDeps: { prompts: { select: vi.fn(async () => 'demo'), cancel: vi.fn() } } },
      ),
    ).resolves.toBe(0);

    expect(runDemoTour).toHaveBeenCalledWith(
      { inputMode: 'auto', cliVersion: '0.2.0' },
      testIo.io,
      expect.objectContaining({}),
    );
  });

  it('creates a project through run mode when --yes is selected', async () => {
    const testIo = makeIo();

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          skipAgents: true,
          inputMode: 'disabled',
          yes: true,
          cliVersion: '0.2.0',
          skipLlm: true,
          skipEmbeddings: true,
          databaseSchemas: [],
          skipDatabases: true,
          skipSources: true,
        },
        testIo.io,
      ),
    ).resolves.toBe(0);

    await expect(stat(join(tempDir, 'ktx.yaml'))).resolves.toBeDefined();
    expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
    await expect(readFile(join(tempDir, '.ktx', 'setup', 'state.json'), 'utf-8')).resolves.toBe(
      `${JSON.stringify({ completed_steps: ['project', 'sources'] }, null, 2)}\n`,
    );
    expect(testIo.stdout()).toContain('KTX setup');
    expect(testIo.stdout()).toContain(`Project: ${tempDir}`);
    expect(testIo.stdout()).toContain('Project ready: yes');
    expect(testIo.stderr()).toBe('');
  });

  it('returns nonzero when project selection is missing in no-input mode even when optional sections are skipped', async () => {
    const testIo = makeIo();

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          skipAgents: true,
          inputMode: 'disabled',
          yes: false,
          cliVersion: '0.2.0',
          skipLlm: true,
          skipEmbeddings: true,
          databaseSchemas: [],
          skipDatabases: true,
          skipSources: true,
        },
        testIo.io,
      ),
    ).resolves.toBe(1);

    expect(testIo.stderr()).toContain('Missing setup choice');
    await expect(stat(join(tempDir, 'ktx.yaml'))).rejects.toThrow();
  });

  it('returns nonzero when project selection is missing in non-interactive setup', async () => {
    const testIo = makeIo();

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          skipAgents: true,
          inputMode: 'disabled',
          yes: false,
          cliVersion: '0.2.0',
          skipLlm: false,
          skipEmbeddings: true,
          databaseSchemas: [],
          skipDatabases: true,
          skipSources: true,
        },
        testIo.io,
      ),
    ).resolves.toBe(1);

    expect(testIo.stderr()).toContain('Missing setup choice');
    await expect(stat(join(tempDir, 'ktx.yaml'))).rejects.toThrow();
  });

  it('runs the Anthropic model step after project selection succeeds', async () => {
    const testIo = makeIo();
    const model = vi.fn(async () => ({ status: 'ready' as const, projectDir: tempDir }));

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          skipAgents: true,
          inputMode: 'disabled',
          yes: true,
          cliVersion: '0.2.0',
          anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
          llmModel: 'claude-sonnet-4-6',
          skipLlm: false,
          skipEmbeddings: true,
          databaseSchemas: [],
          skipDatabases: true,
          skipSources: true,
        },
        testIo.io,
        { model },
      ),
    ).resolves.toBe(0);

    expect(model).toHaveBeenCalledWith(
      expect.objectContaining({
        projectDir: tempDir,
        inputMode: 'disabled',
        anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
        llmModel: 'claude-sonnet-4-6',
        skipLlm: false,
      }),
      testIo.io,
    );
  });

  it('passes Vertex AI model setup args after project selection succeeds', async () => {
    const testIo = makeIo();
    const model = vi.fn(async () => ({ status: 'ready' as const, projectDir: tempDir }));

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          skipAgents: true,
          inputMode: 'disabled',
          yes: true,
          cliVersion: '0.2.0',
          llmBackend: 'vertex',
          vertexProject: 'local-gcp-project',
          vertexLocation: 'us-east5',
          llmModel: 'claude-sonnet-4-6',
          skipLlm: false,
          skipEmbeddings: true,
          databaseSchemas: [],
          skipDatabases: true,
          skipSources: true,
        },
        testIo.io,
        { model },
      ),
    ).resolves.toBe(0);

    expect(model).toHaveBeenCalledWith(
      expect.objectContaining({
        projectDir: tempDir,
        inputMode: 'disabled',
        llmBackend: 'vertex',
        vertexProject: 'local-gcp-project',
        vertexLocation: 'us-east5',
        llmModel: 'claude-sonnet-4-6',
        skipLlm: false,
      }),
      testIo.io,
    );
  });

  it('runs the embedding setup step after the model step succeeds', async () => {
    const testIo = makeIo();
    const model = vi.fn(async () => ({ status: 'ready' as const, projectDir: tempDir }));
    const embeddings = vi.fn(async () => ({ status: 'ready' as const, projectDir: tempDir }));

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          skipAgents: true,
          inputMode: 'disabled',
          yes: true,
          cliVersion: '0.2.0',
          anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
          llmModel: 'claude-sonnet-4-6',
          skipLlm: false,
          embeddingBackend: 'openai',
          embeddingApiKeyEnv: 'OPENAI_API_KEY', // pragma: allowlist secret
          skipEmbeddings: false,
          databaseSchemas: [],
          skipDatabases: true,
          skipSources: true,
        },
        testIo.io,
        { model, embeddings },
      ),
    ).resolves.toBe(0);

    expect(embeddings).toHaveBeenCalledWith(
      expect.objectContaining({
        projectDir: tempDir,
        inputMode: 'disabled',
        cliVersion: '0.2.0',
        runtimeInstallPolicy: 'auto',
        embeddingBackend: 'openai',
        embeddingApiKeyEnv: 'OPENAI_API_KEY', // pragma: allowlist secret
        skipEmbeddings: false,
      }),
      testIo.io,
    );
  });

  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: 'auto',
          agents: false,
          agentScope: 'project',
          skipAgents: true,
          inputMode: 'disabled',
          yes: false,
          cliVersion: '0.2.0',
          skipLlm: true,
          skipEmbeddings: false,
          databaseSchemas: [],
          skipDatabases: true,
          skipSources: true,
        },
        io.io,
        { embeddings },
      ),
    ).resolves.toBe(1);

    expect(embeddings).toHaveBeenCalledWith(
      expect.objectContaining({
        cliVersion: '0.2.0',
        runtimeInstallPolicy: 'never',
      }),
      io.io,
    );
  });

  it('prompts before installing the managed runtime by default during setup', async () => {
    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: 'auto',
          agents: false,
          agentScope: 'project',
          skipAgents: true,
          inputMode: 'auto',
          yes: false,
          cliVersion: '0.2.0',
          skipLlm: true,
          skipEmbeddings: false,
          databaseSchemas: [],
          skipDatabases: true,
          skipSources: true,
        },
        io.io,
        {
          embeddings,
          context,
        },
      ),
    ).resolves.toBe(1);

    expect(embeddings).toHaveBeenCalledWith(
      expect.objectContaining({
        cliVersion: '0.2.0',
        runtimeInstallPolicy: 'prompt',
      }),
      io.io,
    );
    expect(context).toHaveBeenCalledWith(
      expect.objectContaining({
        cliVersion: '0.2.0',
        runtimeInstallPolicy: 'prompt',
      }),
      io.io,
    );
  });

  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 },
    ];
    const model = vi.fn(async () => modelResults.shift() ?? { status: 'back' as const, projectDir: tempDir });
    const embeddings = vi.fn(async () => ({ status: 'back' as const, projectDir: tempDir }));

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          skipAgents: true,
          inputMode: 'auto',
          yes: false,
          cliVersion: '0.2.0',
          skipLlm: false,
          skipEmbeddings: false,
          databaseSchemas: [],
          skipDatabases: true,
          skipSources: true,
        },
        testIo.io,
        { model, embeddings },
      ),
    ).resolves.toBe(0);

    expect(model).toHaveBeenCalledTimes(2);
    expect(model).toHaveBeenNthCalledWith(2, expect.objectContaining({ forcePrompt: true }), testIo.io);
    expect(embeddings).toHaveBeenCalledTimes(1);
  });

  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 },
    ];
    const model = vi.fn(async () => modelResults.shift() ?? { status: 'back' as const, projectDir: tempDir });
    const embeddingResults = [
      { status: 'ready' as const, projectDir: tempDir },
      { status: 'back' as const, projectDir: tempDir },
    ];
    const embeddings = vi.fn(async () => embeddingResults.shift() ?? { status: 'back' as const, projectDir: tempDir });
    const databasePrompts = {
      multiselect: vi.fn(async () => ['back']),
      autocompleteMultiselect: vi.fn(async () => ['back']),
      select: vi.fn(async () => 'back'),
      text: vi.fn(),
      password: vi.fn(),
      cancel: vi.fn(),
    };

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          skipAgents: true,
          inputMode: 'auto',
          yes: true,
          cliVersion: '0.2.0',
          skipLlm: false,
          skipEmbeddings: false,
          databaseSchemas: [],
          skipDatabases: false,
          skipSources: true,
        },
        testIo.io,
        {
          model,
          embeddings,
          databasesDeps: { prompts: databasePrompts },
        },
      ),
    ).resolves.toBe(0);

    expect(databasePrompts.select).not.toHaveBeenCalled();
    expect(embeddings).toHaveBeenCalledTimes(2);
    expect(embeddings).toHaveBeenNthCalledWith(2, expect.objectContaining({ forcePrompt: true }), testIo.io);
    expect(testIo.stderr()).not.toContain('No databases selected.');
  });

  it('lets Back from the first setup step return to the entry menu instead of exiting', async () => {
    await writeFile(join(tempDir, 'ktx.yaml'), 'connections: {}\n', 'utf-8');
    const testIo = makeIo();

    const entryChoices = ['setup', 'exit'];
    const entryPrompts = {
      select: vi.fn(async () => entryChoices.shift() ?? 'exit'),
      cancel: vi.fn(),
    };
    const model = vi.fn(async () => ({ status: 'back' as const, projectDir: tempDir }));

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          skipAgents: true,
          inputMode: 'auto',
          yes: true,
          cliVersion: '0.2.0',
          skipLlm: false,
          skipEmbeddings: true,
          databaseSchemas: [],
          skipDatabases: true,
          skipSources: true,
          showEntryMenu: true,
        },
        testIo.io,
        {
          entryMenuDeps: { prompts: entryPrompts },
          model,
        },
      ),
    ).resolves.toBe(0);

    expect(entryPrompts.select).toHaveBeenCalledTimes(2);
    expect(entryPrompts.cancel).toHaveBeenCalledWith('Setup cancelled.');
    expect(model).toHaveBeenCalledTimes(1);
  });

  it('runs database setup after embeddings succeed', async () => {
    const testIo = makeIo();
    const model = vi.fn(async () => ({ status: 'ready' as const, projectDir: tempDir }));
    const embeddings = vi.fn(async () => ({ status: 'ready' as const, projectDir: tempDir }));
    const databases = vi.fn(async () => ({
      status: 'ready' as const,
      projectDir: tempDir,
      connectionIds: ['warehouse'],
    }));

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          skipAgents: true,
          inputMode: 'disabled',
          yes: true,
          cliVersion: '0.2.0',
          anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
          llmModel: 'claude-sonnet-4-6',
          skipLlm: false,
          embeddingBackend: 'openai',
          embeddingApiKeyEnv: 'OPENAI_API_KEY', // pragma: allowlist secret
          skipEmbeddings: false,
          databaseDrivers: ['postgres'],
          databaseConnectionId: 'warehouse',
          databaseUrl: 'env:DATABASE_URL',
          databaseSchemas: ['public'],
          enableQueryHistory: true,
          queryHistoryWindowDays: 30,
          queryHistoryMinExecutions: 12,
          queryHistoryServiceAccountPatterns: ['^svc_'],
          queryHistoryRedactionPatterns: ['(?i)secret'],
          skipDatabases: false,
          skipSources: true,
        },
        testIo.io,
        { model, embeddings, databases },
      ),
    ).resolves.toBe(0);

    expect(databases).toHaveBeenCalledWith(
      expect.objectContaining({
        projectDir: tempDir,
        inputMode: 'disabled',
        databaseDrivers: ['postgres'],
        databaseConnectionId: 'warehouse',
        databaseUrl: 'env:DATABASE_URL',
        databaseSchemas: ['public'],
        enableQueryHistory: true,
        queryHistoryWindowDays: 30,
        queryHistoryMinExecutions: 12,
        queryHistoryServiceAccountPatterns: ['^svc_'],
        queryHistoryRedactionPatterns: ['(?i)secret'],
        skipDatabases: false,
      }),
      testIo.io,
    );
  });

  it('runs sources after database setup', async () => {
    const calls: string[] = [];
    const io = makeIo();
    await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8');

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          skipAgents: true,
          inputMode: 'disabled',
          yes: true,
          cliVersion: '0.2.0',
          skipLlm: true,
          skipEmbeddings: true,
          skipDatabases: true,
          skipSources: true,
          databaseSchemas: [],
        },
        io.io,
        {
          model: async () => {
            calls.push('model');
            return { status: 'skipped', projectDir: tempDir };
          },
          embeddings: async () => {
            calls.push('embeddings');
            return { status: 'skipped', projectDir: tempDir };
          },
          databases: async () => {
            calls.push('databases');
            return { status: 'skipped', projectDir: tempDir };
          },
          sources: async (args) => {
            expect(args.runInitialSourceIngest).toBe(false);
            calls.push('sources');
            return { status: 'skipped', projectDir: tempDir };
          },
        },
      ),
    ).resolves.toBe(0);

    expect(calls).toEqual(['model', 'embeddings', 'databases', 'sources']);
  });

  it('passes context-source skip selection from database setup into the sources step', async () => {
    const calls: string[] = [];
    const io = makeIo();
    await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8');

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          skipAgents: true,
          inputMode: 'disabled',
          yes: true,
          cliVersion: '0.2.0',
          skipLlm: true,
          skipEmbeddings: true,
          skipDatabases: false,
          skipSources: false,
          databaseSchemas: [],
        },
        io.io,
        {
          model: async () => {
            calls.push('model');
            return { status: 'skipped', projectDir: tempDir };
          },
          embeddings: async () => {
            calls.push('embeddings');
            return { status: 'skipped', projectDir: tempDir };
          },
          databases: async () => {
            calls.push('databases');
            return {
              status: 'ready',
              projectDir: tempDir,
              connectionIds: ['warehouse'],
              skipSources: true,
            };
          },
          sources: async (args) => {
            expect(args.skipSources).toBe(true);
            calls.push('sources');
            return { status: 'skipped', projectDir: tempDir };
          },
          runtime: async () => {
            calls.push('runtime');
            return runtimeReady(tempDir);
          },
          context: async () => {
            calls.push('context');
            return { status: 'ready', projectDir: tempDir, runId: 'setup-context-local-test' };
          },
        },
      ),
    ).resolves.toBe(0);

    expect(calls).toEqual(['model', 'embeddings', 'databases', 'sources', 'runtime', 'context']);
  });

  it.each([
    {
      backend: 'vertex',
      providerLines: ['    backend: vertex', '    vertex:', '      project: kaelio-dev', '      location: us-east5'],
      model: 'claude-sonnet-4-6',
    },
    {
      backend: 'gateway',
      providerLines: ['    backend: gateway', '    gateway:', '      api_key: env:AI_GATEWAY_API_KEY'],
      model: 'anthropic/claude-sonnet-4-6',
    },
  ])('adds a dbt source in non-interactive setup with existing $backend llm config', async (fixture) => {
    const io = makeIo();
    await writeFile(
      join(tempDir, 'ktx.yaml'),
      [
        'setup:',
        '  database_connection_ids:',
        '    - warehouse',
        'connections:',
        '  warehouse:',
        '    driver: postgres',
        '    url: env:WAREHOUSE_URL',
        'llm:',
        '  provider:',
        ...fixture.providerLines,
        '  models:',
        `    default: ${fixture.model}`,
      ].join('\n'),
      'utf-8',
    );
    await writeKtxSetupState(tempDir, { completed_steps: ['project', 'databases'] });

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          skipAgents: true,
          inputMode: 'disabled',
          yes: true,
          cliVersion: '0.2.0',
          skipLlm: false,
          skipEmbeddings: true,
          skipDatabases: true,
          source: 'dbt',
          sourceConnectionId: 'dbt-main',
          sourceGitUrl: 'https://github.com/Kaelio/klo-dbt-demo',
          sourceBranch: 'main',
          sourceProjectName: 'orbit_analytics',
          sourceWarehouseConnectionId: 'warehouse',
          skipSources: false,
          databaseSchemas: [],
        },
        io.io,
        {
          sourcesDeps: { validateDbt: vi.fn(async () => ({ ok: true as const, detail: 'dbt project valid' })) },
          context: vi.fn(async () => ({ status: 'ready' as const, projectDir: tempDir, runId: 'setup-context-test' })),
        },
      ),
    ).resolves.toBe(0);

    expect(io.stderr()).not.toContain('Anthropic');
    expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).toContain('dbt-main:');
  });

  it('does not fail context build when prerequisites were explicitly skipped and agents are skipped', async () => {
    const calls: string[] = [];
    const io = makeIo();
    await writeFile(
      join(tempDir, 'ktx.yaml'),
      [
        'connections:',
        '  warehouse:',
        '    driver: postgres',
        '    url: env:DEMO_DATABASE_URL',
        '',
      ].join('\n'),
      'utf-8',
    );

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          skipAgents: true,
          inputMode: 'disabled',
          yes: true,
          cliVersion: '0.2.0',
          skipLlm: true,
          skipEmbeddings: true,
          skipDatabases: true,
          skipSources: true,
          databaseSchemas: [],
        },
        io.io,
        {
          model: async () => {
            calls.push('model');
            return { status: 'skipped', projectDir: tempDir };
          },
          embeddings: async () => {
            calls.push('embeddings');
            return { status: 'skipped', projectDir: tempDir };
          },
          databases: async () => {
            calls.push('databases');
            return { status: 'skipped', projectDir: tempDir };
          },
          sources: async () => {
            calls.push('sources');
            return { status: 'skipped', projectDir: tempDir };
          },
        },
      ),
    ).resolves.toBe(0);

    expect(calls).toEqual(['model', 'embeddings', 'databases', 'sources']);
    expect(io.stderr()).not.toContain('KTX cannot build agent-ready context yet.');
  });

  it('runs context after sources and before agents in full setup', async () => {
    const calls: string[] = [];
    const io = makeIo();
    await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8');

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          inputMode: 'disabled',
          yes: true,
          cliVersion: '0.2.0',
          skipLlm: true,
          skipEmbeddings: true,
          skipDatabases: true,
          skipSources: true,
          skipAgents: false,
          databaseSchemas: [],
        },
        io.io,
        {
          model: async () => {
            calls.push('model');
            return { status: 'skipped', projectDir: tempDir };
          },
          embeddings: async () => {
            calls.push('embeddings');
            return { status: 'skipped', projectDir: tempDir };
          },
          databases: async () => {
            calls.push('databases');
            return { status: 'skipped', projectDir: tempDir };
          },
          sources: async () => {
            calls.push('sources');
            return { status: 'skipped', projectDir: tempDir };
          },
          runtime: async () => {
            calls.push('runtime');
            return runtimeReady(tempDir);
          },
          context: async () => {
            calls.push('context');
            return { status: 'ready', projectDir: tempDir, runId: 'setup-context-local-test' };
          },
          agents: async () => {
            calls.push('agents');
            return {
              status: 'ready',
              projectDir: tempDir,
              installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli' }],
            };
          },
        },
      ),
    ).resolves.toBe(0);

    expect(calls).toEqual(['model', 'embeddings', 'databases', 'sources', 'runtime', 'context', 'agents']);
  });

  it('commits setup config changes written by later setup steps', async () => {
    const io = makeIo();

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          inputMode: 'disabled',
          yes: true,
          cliVersion: '0.2.0',
          skipLlm: true,
          skipEmbeddings: true,
          skipDatabases: true,
          skipSources: true,
          skipAgents: false,
          databaseSchemas: [],
        },
        io.io,
        {
          model: async () => ({ status: 'skipped', projectDir: tempDir }),
          embeddings: async () => ({ status: 'skipped', projectDir: tempDir }),
          databases: async () => {
            const configPath = join(tempDir, 'ktx.yaml');
            const current = await readFile(configPath, 'utf-8');
            await writeFile(
              configPath,
              current.replace(
                'connections: {}',
                ['connections:', '  warehouse:', '    driver: postgres', '    url: env:DATABASE_URL'].join('\n'),
              ),
              'utf-8',
            );
            return { status: 'skipped', projectDir: tempDir };
          },
          sources: async () => ({ status: 'skipped', projectDir: tempDir }),
          runtime: async () => runtimeReady(tempDir),
          context: async () => ({ status: 'ready', projectDir: tempDir, runId: 'setup-context-local-test' }),
          agents: async () => ({
            status: 'ready',
            projectDir: tempDir,
            installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli' }],
          }),
        },
      ),
    ).resolves.toBe(0);

    const { stdout } = await execFileAsync('git', ['-C', tempDir, 'status', '--short', '--', 'ktx.yaml']);
    expect(stdout).toBe('');
    const committedConfig = await execFileAsync('git', ['-C', tempDir, 'show', 'HEAD:ktx.yaml']);
    expect(committedConfig.stdout).toContain('warehouse:');
  });

  it('runs agent setup without runtime or context in --agents mode', async () => {
    const calls: string[] = [];
    const io = makeIo();
    await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8');

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: true,
          target: 'codex',
          agentScope: 'project',
          inputMode: 'disabled',
          yes: true,
          cliVersion: '0.2.0',
          skipLlm: true,
          skipEmbeddings: true,
          skipDatabases: true,
          skipSources: true,
          skipAgents: false,
          databaseSchemas: [],
        },
        io.io,
        {
          model: async () => ({ status: 'skipped', projectDir: tempDir }),
          embeddings: async () => ({ status: 'skipped', projectDir: tempDir }),
          databases: async () => ({ status: 'skipped', projectDir: tempDir }),
          sources: async () => ({ status: 'skipped', projectDir: tempDir }),
          runtime: async () => {
            calls.push('runtime');
            throw new Error('runtime should not run');
          },
          context: async () => {
            calls.push('context');
            throw new Error('context should not run');
          },
          agents: async () => {
            calls.push('agents');
            return {
              status: 'ready',
              projectDir: tempDir,
              installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli' }],
            };
          },
        },
      ),
    ).resolves.toBe(0);

    expect(calls).toEqual(['agents']);
  });

  it('installs agents when non-interactive --agents finds context incomplete', async () => {
    const io = makeIo();
    const runtime = vi.fn(async () => runtimeReady(tempDir));
    const context = vi.fn(async () => ({ status: 'skipped' as const, projectDir: tempDir }));
    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 }],
    }));
    await writeFile(join(tempDir, 'ktx.yaml'), ['connections: {}', ''].join('\n'), 'utf-8');

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: true,
          target: 'codex',
          agentScope: 'project',
          inputMode: 'disabled',
          yes: true,
          cliVersion: '0.2.0',
          skipLlm: true,
          skipEmbeddings: true,
          skipDatabases: true,
          skipSources: true,
          skipAgents: false,
          databaseSchemas: [],
        },
        io.io,
        {
          runtime,
          context,
          agents,
        },
      ),
    ).resolves.toBe(0);

    expect(runtime).not.toHaveBeenCalled();
    expect(context).not.toHaveBeenCalled();
    expect(agents).toHaveBeenCalledTimes(1);
    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();
    await mkdir(join(tempDir, '.ktx', 'agents'), { recursive: true });
    await writeFile(
      join(tempDir, 'ktx.yaml'),
      [
        'setup:',
        '  database_connection_ids: []',
        'connections: {}',
        'llm:',
        '  provider:',
        '    backend: anthropic',
        '  models:',
        '    default: claude-sonnet-4-6',
        'ingest:',
        '  embeddings:',
        '    backend: openai',
        '    model: text-embedding-3-small',
        '    dimensions: 1536',
        '',
      ].join('\n'),
      'utf-8',
    );
    await writeKtxSetupState(tempDir, {
      completed_steps: ['project', 'llm', 'embeddings', 'sources', 'runtime', 'context', 'agents'],
    });
    await writeFile(
      join(tempDir, '.ktx/agents/install-manifest.json'),
      JSON.stringify(
        {
          version: 1,
          projectDir: tempDir,
          installedAt: '2026-05-07T00:00:00.000Z',
          installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli' }],
          entries: [],
        },
        null,
        2,
      ),
      'utf-8',
    );
    await writeKtxSetupContextState(tempDir, {
      runId: 'setup-context-local-ready',
      status: 'completed',
      startedAt: '2026-05-09T10:00:00.000Z',
      updatedAt: '2026-05-09T10:02:00.000Z',
      completedAt: '2026-05-09T10:02:00.000Z',
      primarySourceConnectionIds: [],
      contextSourceConnectionIds: [],
      reportIds: [],
      artifactPaths: [],
      retryableFailedTargets: [],
      commands: contextBuildCommands(tempDir),
    });

    const previousRuntimeRoot = process.env.KTX_RUNTIME_ROOT;
    process.env.KTX_RUNTIME_ROOT = await writeReadyRuntime(tempDir);
    try {
      await expect(
        runKtxSetup(
          {
            command: 'run',
            projectDir: tempDir,
            mode: 'auto',
            agents: false,
            inputMode: 'auto',
            yes: false,
            cliVersion: '0.2.0',
            skipLlm: false,
            skipEmbeddings: false,
            skipDatabases: false,
            skipSources: false,
            skipAgents: false,
            databaseSchemas: [],
          },
          io.io,
          {
            readyMenuDeps: { prompts: { select: vi.fn(async () => 'agents'), cancel: vi.fn() } },
            model: async (args) => {
              expect(args.skipLlm).toBe(true);
              return { status: 'skipped', projectDir: tempDir };
            },
            embeddings: async (args) => {
              expect(args.skipEmbeddings).toBe(true);
              return { status: 'skipped', projectDir: tempDir };
            },
            databases: async (args) => {
              expect(args.skipDatabases).toBe(true);
              return { status: 'skipped', projectDir: tempDir };
            },
            sources: async (args) => {
              expect(args.skipSources).toBe(true);
              return { status: 'skipped', projectDir: tempDir };
            },
            runtime: async () => {
              calls.push('runtime');
              return runtimeReady(tempDir);
            },
            agents: async () => {
              calls.push('agents');
              return {
                status: 'ready',
                projectDir: tempDir,
                installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli' }],
              };
            },
          },
        ),
      ).resolves.toBe(0);
    } finally {
      if (previousRuntimeRoot === undefined) {
        delete process.env.KTX_RUNTIME_ROOT;
      } else {
        process.env.KTX_RUNTIME_ROOT = previousRuntimeRoot;
      }
    }

    expect(calls).toEqual(['agents']);
  });

  it('skips to agent setup when context is ready but agents are not configured', async () => {
    const calls: string[] = [];
    const io = makeIo();
    await writeFile(
      join(tempDir, 'ktx.yaml'),
      [
        'setup:',
        '  database_connection_ids: []',
        'connections: {}',
        'llm:',
        '  provider:',
        '    backend: anthropic',
        '  models:',
        '    default: claude-sonnet-4-6',
        'ingest:',
        '  embeddings:',
        '    backend: openai',
        '    model: text-embedding-3-small',
        '    dimensions: 1536',
        '',
      ].join('\n'),
      'utf-8',
    );
    await writeKtxSetupState(tempDir, {
      completed_steps: ['project', 'llm', 'embeddings', 'sources', 'context'],
    });
    await writeKtxSetupContextState(tempDir, {
      runId: 'setup-context-local-ready',
      status: 'completed',
      startedAt: '2026-05-09T10:00:00.000Z',
      updatedAt: '2026-05-09T10:02:00.000Z',
      completedAt: '2026-05-09T10:02:00.000Z',
      primarySourceConnectionIds: [],
      contextSourceConnectionIds: [],
      reportIds: [],
      artifactPaths: [],
      retryableFailedTargets: [],
      commands: contextBuildCommands(tempDir),
    });

    const readyMenuSelect = vi.fn();
    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          inputMode: 'auto',
          yes: false,
          cliVersion: '0.2.0',
          skipLlm: false,
          skipEmbeddings: false,
          skipDatabases: false,
          skipSources: false,
          skipAgents: false,
          databaseSchemas: [],
        },
        io.io,
        {
          readyMenuDeps: { prompts: { select: readyMenuSelect, cancel: vi.fn() } },
          model: async (args) => {
            expect(args.skipLlm).toBe(true);
            return { status: 'skipped', projectDir: tempDir };
          },
          embeddings: async (args) => {
            expect(args.skipEmbeddings).toBe(true);
            return { status: 'skipped', projectDir: tempDir };
          },
          databases: async (args) => {
            expect(args.skipDatabases).toBe(true);
            return { status: 'skipped', projectDir: tempDir };
          },
          sources: async (args) => {
            expect(args.skipSources).toBe(true);
            return { status: 'skipped', projectDir: tempDir };
          },
          runtime: async () => {
            calls.push('runtime');
            return runtimeReady(tempDir);
          },
          agents: async () => {
            calls.push('agents');
            return {
              status: 'ready',
              projectDir: tempDir,
              installs: [{ target: 'codex', scope: 'project', mode: 'mcp-cli' }],
            };
          },
        },
      ),
    ).resolves.toBe(0);

    expect(readyMenuSelect).not.toHaveBeenCalled();
    expect(calls).toEqual(['agents']);
  });

  it('runs only project resolution and agent setup in --agents mode', async () => {
    const io = makeIo();
    const runtime = vi.fn(async () => runtimeReady(tempDir));
    const context = vi.fn(async () => ({ status: 'ready' as const, projectDir: tempDir, runId: 'setup-context-local-test' }));
    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 }],
    }));

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: true,
          target: 'universal',
          agentScope: 'project',
          inputMode: 'disabled',
          yes: true,
          cliVersion: '0.2.0',
          skipLlm: false,
          skipEmbeddings: false,
          skipDatabases: false,
          skipSources: false,
          skipAgents: false,
          databaseSchemas: [],
        },
        io.io,
        {
          model: async () => {
            throw new Error('model should not run');
          },
          runtime,
          context,
          agents,
        },
      ),
    ).resolves.toBe(0);

    expect(runtime).not.toHaveBeenCalled();
    expect(context).not.toHaveBeenCalled();
    expect(agents).toHaveBeenCalledTimes(1);
  });

  it('does not run embedding setup when the model step fails', async () => {
    const testIo = makeIo();
    const model = vi.fn(async () => ({ status: 'failed' as const, projectDir: tempDir }));
    const embeddings = vi.fn(async () => ({ status: 'ready' as const, projectDir: tempDir }));

    await expect(
      runKtxSetup(
        {
          command: 'run',
          projectDir: tempDir,
          mode: 'auto',
          agents: false,
          skipAgents: true,
          inputMode: 'disabled',
          yes: true,
          cliVersion: '0.2.0',
          anthropicApiKeyEnv: 'ANTHROPIC_API_KEY', // pragma: allowlist secret
          llmModel: 'claude-sonnet-4-6',
          skipLlm: false,
          skipEmbeddings: false,
          databaseSchemas: [],
          skipDatabases: true,
        },
        testIo.io,
        { model, embeddings },
      ),
    ).resolves.toBe(1);

    expect(model).toHaveBeenCalledTimes(1);
    expect(embeddings).not.toHaveBeenCalled();
  });
});
