diff --git a/assets/star-history.svg b/assets/star-history.svg index 87592b49..8fa7bc08 100644 --- a/assets/star-history.svg +++ b/assets/star-history.svg @@ -1 +1 @@ -star-history.comMay 17May 24May 31Jun 07 200400600800kaelio/ktxStar HistoryDateGitHub Stars +star-history.comMay 17May 24May 31Jun 07 200400600800kaelio/ktxStar HistoryDateGitHub Stars diff --git a/docs-site/content/docs/cli-reference/ktx-setup.mdx b/docs-site/content/docs/cli-reference/ktx-setup.mdx index 8ae4469d..f75fdf46 100644 --- a/docs-site/content/docs/cli-reference/ktx-setup.mdx +++ b/docs-site/content/docs/cli-reference/ktx-setup.mdx @@ -67,6 +67,13 @@ of Anthropic API key or Vertex flags. After you choose a backend, `ktx setup` writes that backend's per-role model preset to `ktx.yaml`. To change a model, edit the matching `llm.models.` value in `ktx.yaml`. +With `--no-input`, `ktx setup` does not assume a default LLM provider, because +every backend needs credentials only you can supply. Pass `--llm-backend` +explicitly. Note that `--target` selects the agent integration, not the LLM +provider: `ktx setup --target claude-code --no-input` still needs +`--llm-backend claude-code` to use your Claude subscription for **ktx** LLM +calls. + ### Embeddings | Flag | Description | @@ -276,6 +283,7 @@ Use `ktx status` for repeatable readiness checks after setup exits. |-------|-------|----------| | Setup resumes an unexpected project | `KTX_PROJECT_DIR` or nearest `ktx.yaml` points to another directory | Pass `--project-dir ` explicitly | | Setup cannot run in CI | Required values are missing and `--no-input` disables prompts | Provide the relevant automation flags or create a fixture `ktx.yaml` | +| `Missing LLM backend: pass --llm-backend …` | `--no-input` setup ran without an LLM backend; `--target` does not select one | Pass `--llm-backend claude-code`, `codex`, `anthropic`, or `vertex` (with that backend's credential flags) | | Provider health check fails | Provider key, model id, Vertex project, or Vertex location is invalid | Fix the `env:` or `file:` reference and rerun setup | | Python runtime is missing | The selected setup needs runtime-backed agent, query-history, Looker, or local embedding features | Accept the interactive prompt, rerun with `--yes`, or run the suggested `ktx admin runtime install` command | | `--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 | diff --git a/package.json b/package.json index 10643e11..f941241a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ktx-workspace", - "version": "0.10.0", + "version": "0.11.0", "description": "Workspace root for ktx packages", "private": true, "type": "module", @@ -69,11 +69,6 @@ "typescript": "^6.0.3", "yaml": "^2.9.0" }, - "pnpm": { - "onlyBuiltDependencies": [ - "better-sqlite3" - ] - }, "license": "Apache-2.0", "repository": { "type": "git", diff --git a/packages/cli/package.json b/packages/cli/package.json index 85695a8a..9bb4c5a1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@kaelio/ktx", - "version": "0.10.0", + "version": "0.11.0", "description": "Standalone ktx context layer for data agents", "author": { "name": "Kaelio", diff --git a/packages/cli/src/commands/setup-commands.ts b/packages/cli/src/commands/setup-commands.ts index 418b27f9..e8510210 100644 --- a/packages/cli/src/commands/setup-commands.ts +++ b/packages/cli/src/commands/setup-commands.ts @@ -2,7 +2,7 @@ import { type Command, InvalidArgumentError, Option } from '@commander-js/extra- import type { KtxCliCommandContext } from '../cli-program.js'; import { resolveCommandProjectDir } from '../cli-program.js'; import type { KtxSetupDatabaseDriver } from '../setup-databases.js'; -import type { KtxSetupLlmBackend } from '../setup-models.js'; +import { isKtxSetupLlmBackend, type KtxSetupLlmBackend } from '../setup-models.js'; import type { KtxSetupSourceType } from '../setup-sources.js'; async function runSetupArgs( @@ -29,7 +29,7 @@ function embeddingBackend(value: string): 'openai' | 'sentence-transformers' { } function llmBackend(value: string): KtxSetupLlmBackend { - if (value === 'anthropic' || value === 'vertex' || value === 'claude-code' || value === 'codex') { + if (isKtxSetupLlmBackend(value)) { return value; } throw new InvalidArgumentError(`invalid choice '${value}'`); diff --git a/packages/cli/src/setup-models.ts b/packages/cli/src/setup-models.ts index 911579a9..fbbabbdb 100644 --- a/packages/cli/src/setup-models.ts +++ b/packages/cli/src/setup-models.ts @@ -51,7 +51,27 @@ export type KtxSetupModelResult = | { status: 'missing-input'; projectDir: string } | { status: 'failed'; projectDir: string }; -export type KtxSetupLlmBackend = 'anthropic' | 'vertex' | 'claude-code' | 'codex'; +// Single source of truth for the LLM backends a user can pick during setup. +// The CLI arg parser, the interactive prompt, and the missing-backend error all +// derive from this list, so adding a backend is one edit. Order is the prompt's +// preference order (subscription backends first). +const KTX_SETUP_LLM_BACKENDS = ['claude-code', 'codex', 'anthropic', 'vertex'] as const; +export type KtxSetupLlmBackend = (typeof KTX_SETUP_LLM_BACKENDS)[number]; + +/** Validates a raw CLI or prompt value against the setup-selectable LLM backends. */ +export function isKtxSetupLlmBackend(value: string): value is KtxSetupLlmBackend { + return KTX_SETUP_LLM_BACKENDS.some((backend) => backend === value); +} + +// Display labels for the interactive provider prompt. The Record key type forces +// every backend to carry a label, so adding one to KTX_SETUP_LLM_BACKENDS fails +// to compile until its prompt option exists here. +const KTX_SETUP_LLM_BACKEND_LABELS: Record = { + 'claude-code': 'Claude subscription (Pro/Max)', + codex: 'Codex subscription', + anthropic: 'Anthropic API key', + vertex: 'Google Vertex AI for Anthropic Claude', +}; /** @internal */ export interface KtxSetupModelPromptAdapter { @@ -135,7 +155,18 @@ const execFileAsync = promisify(execFile); type ChooseBackendResult = | { status: 'ready'; backend: KtxSetupLlmBackend; prompted: boolean } - | { status: 'back' }; + | { status: 'back' } + | { status: 'missing-input' }; + +// Non-interactive setup cannot pick a provider safely: every backend needs +// something the user must supply (an API key, gcloud ADC, or a logged-in local +// CLI), so there is no credential-free default to fall back to. Name the hidden +// --llm-backend flag and its choices here instead, mirroring how the other +// automation errors guide users to the flag they need. +const MISSING_LLM_BACKEND_MESSAGE = + `Missing LLM backend: pass --llm-backend with one of ${KTX_SETUP_LLM_BACKENDS.join(', ')}. ` + + 'claude-code and codex use local CLI authentication; anthropic also needs --anthropic-api-key-env or ' + + '--anthropic-api-key-file, and vertex also needs --vertex-project.'; type VertexConfigChoice = | { @@ -446,7 +477,8 @@ async function chooseBackend( return { status: 'ready', backend: explicit, prompted: false }; } if (args.inputMode === 'disabled') { - return { status: 'ready', backend: 'anthropic', prompted: false }; + io.stderr.write(`${MISSING_LLM_BACKEND_MESSAGE}\n`); + return { status: 'missing-input' }; } const prompts = deps.prompts ?? createPromptAdapter(); @@ -458,21 +490,20 @@ async function chooseBackend( const choice = await prompts.select({ message: 'Which LLM provider should KTX use?', options: [ - { value: 'claude-code', label: 'Claude subscription (Pro/Max)' }, - { value: 'codex', label: 'Codex subscription' }, - { value: 'anthropic', label: 'Anthropic API key' }, - { value: 'vertex', label: 'Google Vertex AI for Anthropic Claude' }, + ...KTX_SETUP_LLM_BACKENDS.map((backend) => ({ value: backend, label: KTX_SETUP_LLM_BACKEND_LABELS[backend] })), { value: 'back', label: 'Back' }, ], }); if (choice === 'back') { return { status: 'back' }; } - return { - status: 'ready', - backend: choice === 'vertex' || choice === 'claude-code' || choice === 'codex' ? choice : 'anthropic', - prompted: true, - }; + if (isKtxSetupLlmBackend(choice)) { + return { status: 'ready', backend: choice, prompted: true }; + } + // Options are derived from KTX_SETUP_LLM_BACKENDS, so the only other value is + // 'back' (handled above). Treat any unexpected value as a cancel rather than + // silently assuming a provider. + return { status: 'back' }; } function resolveProvidedVertexRef( diff --git a/packages/cli/test/context/index-sync/reindex.nested-git-root.test.ts b/packages/cli/test/context/index-sync/reindex.nested-git-root.test.ts new file mode 100644 index 00000000..97efa993 --- /dev/null +++ b/packages/cli/test/context/index-sync/reindex.nested-git-root.test.ts @@ -0,0 +1,66 @@ +import { execFileSync } from 'node:child_process'; +import { mkdir, mkdtemp, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { reindexLocalIndexes } from '../../../src/context/index-sync/reindex.js'; +import { initKtxProject, type KtxLocalProject } from '../../../src/context/project/project.js'; + +const AUTHOR = 'Agent'; +const EMAIL = 'agent@example.com'; + +const WIKI_PAGE = '---\nsummary: Revenue\nusage_mode: auto\n---\n\nPaid orders.\n'; + +/** + * Regression for the "wiki silently unsearchable when the project dir is not the git root" + * bug: a ktx project initialized below an existing git working tree. ingest writes wiki + * pages through a session worktree and squash-merges into main, so the page must land + * inside the project dir (where reindex scans), not at the enclosing git root. + */ +describe('reindex with a ktx project nested inside an enclosing git repo', () => { + let tempDir: string; + let enclosing: string; + let projectDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'ktx-nested-git-root-')); + enclosing = join(tempDir, 'enclosing'); + await mkdir(enclosing, { recursive: true }); + execFileSync('git', ['init', '-q'], { cwd: enclosing }); + projectDir = join(enclosing, 'analytics'); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('indexes a wiki page written through a session worktree and squash-merged into main', async () => { + const project: KtxLocalProject = await initKtxProject({ + projectDir, + authorName: AUTHOR, + authorEmail: EMAIL, + }); + + // Mirror the ingest write path: create a session worktree, write the page on its + // branch through the worktree-scoped file store, then squash-merge into main. + const mainHead = await project.git.revParseHead(); + const workdir = join(projectDir, '.ktx/worktrees/session-test'); + const branch = 'session/test'; + await project.git.addWorktree(workdir, branch, mainHead); + const worktreeStore = project.fileStore.forWorktree(workdir); + await worktreeStore.writeFile('wiki/global/revenue.md', WIKI_PAGE, AUTHOR, EMAIL, 'Add revenue page'); + const merge = await project.git.squashMergeIntoMain(branch, AUTHOR, EMAIL, 'Merge session'); + expect(merge.ok).toBe(true); + await project.git.removeWorktree(workdir); + await project.git.deleteBranch(branch, true); + + // The page must land inside the project dir, not the enclosing git root. + await expect(stat(join(projectDir, 'wiki/global/revenue.md'))).resolves.toBeDefined(); + await expect(stat(join(enclosing, 'wiki/global/revenue.md'))).rejects.toMatchObject({ code: 'ENOENT' }); + + // ...and reindex must discover and index it. + const summary = await reindexLocalIndexes(project, { force: false, embeddingService: null }); + const global = summary.scopes.find((scope) => scope.label === 'global'); + expect(global).toMatchObject({ scanned: 1, updated: 1 }); + }); +}); diff --git a/packages/cli/test/context/project/project.test.ts b/packages/cli/test/context/project/project.test.ts index 668fa264..1027d174 100644 --- a/packages/cli/test/context/project/project.test.ts +++ b/packages/cli/test/context/project/project.test.ts @@ -1,4 +1,5 @@ -import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { execFileSync } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, realpath, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -60,6 +61,30 @@ describe('KTX local project runtime', () => { }); }); + it('initializes a dedicated git repo at the project dir even when nested inside an enclosing repo', async () => { + // A ktx project dir living below an existing git working tree (e.g. an analytics + // subfolder of an app repo). ktx must own its own repo rooted at the project dir, + // not silently adopt the enclosing repo — otherwise worktree writes resolve against + // the enclosing root and land outside the project dir. + const enclosing = join(tempDir, 'enclosing'); + await mkdir(enclosing, { recursive: true }); + execFileSync('git', ['init', '-q'], { cwd: enclosing }); + + const projectDir = join(enclosing, 'analytics'); + await initKtxProject({ projectDir, authorName: 'Agent', authorEmail: 'agent@example.com' }); + + await expect(stat(join(projectDir, '.git'))).resolves.toBeDefined(); + const toplevel = execFileSync('git', ['rev-parse', '--show-toplevel'], { + cwd: projectDir, + encoding: 'utf-8', + }).trim(); + expect(await realpath(toplevel)).toBe(await realpath(projectDir)); + + // ktx must not write its scaffold commits into the user's enclosing repo. + const enclosingTracked = execFileSync('git', ['ls-files'], { cwd: enclosing, encoding: 'utf-8' }); + expect(enclosingTracked).not.toContain('ktx.yaml'); + }); + it('rejects reinitializing an existing project unless force is set', async () => { const projectDir = join(tempDir, 'warehouse'); await initKtxProject({ projectDir }); diff --git a/packages/cli/test/setup-models.test.ts b/packages/cli/test/setup-models.test.ts index f09691e0..ba5ce21f 100644 --- a/packages/cli/test/setup-models.test.ts +++ b/packages/cli/test/setup-models.test.ts @@ -814,7 +814,7 @@ describe('setup Anthropic model step', () => { expect(io.stderr()).toContain(`Missing Anthropic API key file: ${missingSecretPath}`); }); - it('does not recommend skipping when non-interactive setup is missing an Anthropic credential source', async () => { + it('fails clearly when non-interactive setup has no LLM backend instead of assuming Anthropic', async () => { const io = makeIo(); const result = await runKtxSetupAnthropicModelStep( @@ -823,10 +823,17 @@ describe('setup Anthropic model step', () => { ); expect(result.status).toBe('missing-input'); - expect(io.stderr()).toContain( - 'Missing Anthropic API key: pass --anthropic-api-key-env or --anthropic-api-key-file.', - ); - expect(io.stderr()).not.toContain('--skip-llm'); + const stderr = io.stderr(); + expect(stderr).toContain('Missing LLM backend: pass --llm-backend'); + // Names every backend so the user can choose without reading hidden --help flags. + expect(stderr).toContain('claude-code'); + expect(stderr).toContain('codex'); + expect(stderr).toContain('anthropic'); + expect(stderr).toContain('vertex'); + // Does not mislead with an Anthropic-key error the user never opted into. + expect(stderr).not.toContain('Missing Anthropic API key'); + // Does not nudge users to skip the LLM. + expect(stderr).not.toContain('--skip-llm'); }); it('writes pasted keys to .ktx/secrets and never prints the key', async () => { diff --git a/packages/cli/test/setup.test.ts b/packages/cli/test/setup.test.ts index 04ca32d1..546136fa 100644 --- a/packages/cli/test/setup.test.ts +++ b/packages/cli/test/setup.test.ts @@ -664,6 +664,38 @@ describe('setup status', () => { expect(testIo.stderr()).toBe(''); }); + it('fails clearly when a non-interactive run has an agent target but no LLM backend', async () => { + const testIo = makeIo(); + + // --target selects agent integration, not the LLM provider. A non-interactive + // run with no --llm-backend must say so plainly instead of assuming Anthropic. + await expect( + runKtxSetup( + { + command: 'run', + projectDir: tempDir, + mode: 'auto', + agents: false, + target: 'claude-code', + skipAgents: true, + inputMode: 'disabled', + yes: true, + cliVersion: '0.2.0', + skipLlm: false, + skipEmbeddings: true, + skipDatabases: true, + skipSources: true, + databaseSchemas: [], + }, + testIo.io, + ), + ).resolves.toBe(1); + + const stderr = testIo.stderr(); + expect(stderr).toContain('Missing LLM backend: pass --llm-backend'); + expect(stderr).not.toContain('Missing Anthropic API key'); + }); + it('preserves a newly created missing project directory when a later setup step fails', async () => { const projectDir = join(tempDir, 'missing-project'); const testIo = makeIo(); diff --git a/python/ktx-daemon/pyproject.toml b/python/ktx-daemon/pyproject.toml index b7e9fc99..3bbb3f60 100644 --- a/python/ktx-daemon/pyproject.toml +++ b/python/ktx-daemon/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ktx-daemon" -version = "0.10.0" +version = "0.11.0" description = "Portable compute package for KTX semantic-layer operations" readme = "README.md" requires-python = ">=3.13" diff --git a/python/ktx-sl/pyproject.toml b/python/ktx-sl/pyproject.toml index dae1f42a..83fcb6f4 100644 --- a/python/ktx-sl/pyproject.toml +++ b/python/ktx-sl/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ktx-sl" -version = "0.10.0" +version = "0.11.0" description = "Agent-first semantic layer engine with aggregate locality" readme = "README.md" requires-python = ">=3.13" diff --git a/release-policy.json b/release-policy.json index 6c9d2d4b..35a2d1d0 100644 --- a/release-policy.json +++ b/release-policy.json @@ -19,7 +19,7 @@ }, "publishedPackageSmoke": { "packageName": "@kaelio/ktx", - "version": "0.10.0", + "version": "0.11.0", "registry": null }, "runtimeInstaller": {