diff --git a/packages/cli/src/setup-models.test.ts b/packages/cli/src/setup-models.test.ts index 81c8b361..96092b25 100644 --- a/packages/cli/src/setup-models.test.ts +++ b/packages/cli/src/setup-models.test.ts @@ -676,4 +676,53 @@ describe('setup Anthropic model step', () => { ).resolves.toMatchObject({ status: 'ready' }); expect(healthCheck).not.toHaveBeenCalled(); }); + + 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', + }, + ])('preserves already configured $backend llm setup without asking for Anthropic credentials', async (fixture) => { + await writeFile( + join(tempDir, 'ktx.yaml'), + [ + 'project: warehouse', + 'setup:', + ' database_connection_ids: []', + ' completed_steps:', + ' - project', + ' - llm', + 'connections: {}', + 'llm:', + ' provider:', + ...fixture.providerLines, + ' models:', + ` default: ${fixture.model}`, + 'ingest:', + ' embeddings:', + ' backend: deterministic', + ' model: deterministic', + ' dimensions: 8', + ].join('\n'), + 'utf-8', + ); + + const healthCheck = vi.fn(async () => ({ ok: true as const })); + const io = makeIo(); + await expect( + runKtxSetupAnthropicModelStep({ projectDir: tempDir, inputMode: 'disabled', skipLlm: false }, io.io, { + healthCheck, + }), + ).resolves.toMatchObject({ status: 'ready' }); + + expect(healthCheck).not.toHaveBeenCalled(); + expect(io.stdout()).toContain(`LLM ready: yes (${fixture.model})`); + expect(io.stderr()).not.toContain('Anthropic'); + }); }); diff --git a/packages/cli/src/setup-models.ts b/packages/cli/src/setup-models.ts index 28908849..5b0dea18 100644 --- a/packages/cli/src/setup-models.ts +++ b/packages/cli/src/setup-models.ts @@ -1,5 +1,6 @@ import { writeFile } from 'node:fs/promises'; import { cancel, isCancel, password, select, text } from '@clack/prompts'; +import { resolveLocalKtxLlmConfig } from '@ktx/context'; import { resolveKtxConfigReference } from '@ktx/context/core'; import { type KtxProjectConfig, @@ -170,13 +171,26 @@ export async function fetchAnthropicModels( return models.map((item, index) => ({ ...item, recommended: index === Math.max(recommendedIndex, 0) })); } -function hasCompletedLlm(config: KtxProjectConfig): boolean { - return ( - config.setup?.completed_steps.includes('llm') === true && - config.llm.provider.backend === 'anthropic' && - typeof config.llm.models.default === 'string' && - config.llm.models.default.length > 0 - ); +export function isKtxSetupLlmConfigReady(config: KtxProjectLlmConfig): boolean { + let resolved: KtxLlmConfig | null; + try { + resolved = resolveLocalKtxLlmConfig(config, process.env); + } catch { + return false; + } + if (!resolved) { + return false; + } + + if (resolved.backend === 'vertex') { + return typeof resolved.vertex?.location === 'string' && resolved.vertex.location.trim().length > 0; + } + + return resolved.backend === 'anthropic' || resolved.backend === 'gateway'; +} + +function hasUsableConfiguredLlm(config: KtxProjectConfig): boolean { + return isKtxSetupLlmConfigReady(config.llm); } function buildProjectLlmConfig( @@ -386,7 +400,7 @@ export async function runKtxSetupAnthropicModelStep( const project = await loadKtxProject({ projectDir: args.projectDir }); if ( args.forcePrompt !== true && - hasCompletedLlm(project.config) && + hasUsableConfiguredLlm(project.config) && !args.anthropicApiKeyEnv && !args.anthropicApiKeyFile && !args.anthropicModel diff --git a/packages/cli/src/setup.test.ts b/packages/cli/src/setup.test.ts index eaf6d2c5..cd6e9a9f 100644 --- a/packages/cli/src/setup.test.ts +++ b/packages/cli/src/setup.test.ts @@ -87,6 +87,38 @@ describe('setup status', () => { }); }); + 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'), + [ + 'project: revenue', + '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'), @@ -1174,6 +1206,77 @@ describe('setup status', () => { expect(calls).toEqual(['model', 'embeddings', 'databases', 'sources']); }); + 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'), + [ + 'project: revenue', + 'setup:', + ' database_connection_ids:', + ' - warehouse', + ' completed_steps:', + ' - project', + ' - databases', + 'connections:', + ' warehouse:', + ' driver: postgres', + ' url: env:WAREHOUSE_URL', + 'llm:', + ' provider:', + ...fixture.providerLines, + ' models:', + ` default: ${fixture.model}`, + ].join('\n'), + 'utf-8', + ); + + await expect( + runKtxSetup( + { + command: 'run', + projectDir: tempDir, + mode: 'existing', + 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(); diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index ee6fbdc6..47a7997a 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -20,7 +20,7 @@ import { runKtxSetupDatabasesStep, } from './setup-databases.js'; import { type KtxSetupEmbeddingsDeps, runKtxSetupEmbeddingsStep } from './setup-embeddings.js'; -import { type KtxSetupModelDeps, runKtxSetupAnthropicModelStep } from './setup-models.js'; +import { type KtxSetupModelDeps, isKtxSetupLlmConfigReady, runKtxSetupAnthropicModelStep } from './setup-models.js'; import { type KtxSetupProjectDeps, runKtxSetupProjectStep } from './setup-project.js'; import { isKtxPreAgentSetupReady, @@ -226,10 +226,6 @@ async function runKtxSetupDemoFromEntryMenu( ); } -function llmReady(status: KtxSetupStatus['llm']): boolean { - return status.backend === 'anthropic' && typeof status.model === 'string' && status.model.length > 0; -} - function embeddingsReady(status: KtxSetupStatus['embeddings']): boolean { return ( status.backend !== undefined && @@ -269,10 +265,9 @@ export async function readKtxSetupStatus(projectDir: string): Promise database.ready) && status.sources.every((source) => source.ready) diff --git a/scripts/check-boundaries.mjs b/scripts/check-boundaries.mjs index 86b98712..53455abd 100644 --- a/scripts/check-boundaries.mjs +++ b/scripts/check-boundaries.mjs @@ -95,7 +95,7 @@ function scansForContextProductionLlmBoundaries(relativePath) { } function scansForForbiddenIdentifiers(relativePath) { - return isCodeSource(relativePath) || isRuntimeAsset(relativePath); + return (isCodeSource(relativePath) && !isTestSource(relativePath)) || isRuntimeAsset(relativePath); } function skipsIdentifierScan(relativePath) { diff --git a/scripts/check-boundaries.test.mjs b/scripts/check-boundaries.test.mjs index 8d7fabdd..db8afafe 100644 --- a/scripts/check-boundaries.test.mjs +++ b/scripts/check-boundaries.test.mjs @@ -65,6 +65,13 @@ describe('scanFileContent', () => { assert.equal(scanFileContent('python/ktx-sl/openspec/specs/semantic-layer/spec.md', name).length, 0); }); + it('allows product identifiers in test fixtures', () => { + const name = lowerProductName(); + + assert.equal(scanFileContent('packages/cli/src/setup.test.ts', `project: ${name}-dev`).length, 0); + assert.equal(scanFileContent('packages/context/src/ingest/importer.test.ts', `email: system@${name}.dev`).length, 0); + }); + it('allows public package identifiers in release packaging and managed runtime source', () => { const name = lowerProductName();