Merge origin/main into ingest-ignores-auto-commit-config

Resolve git.service.ts conflict: both branches independently fixed the
"ktx adopts an enclosing git repo when the project dir is nested" bug.

main (#282) used checkIsRepo(IS_REPO_ROOT) to init a dedicated repo unless
the project dir is already a repo root. This branch's repo-ownership model
(classifyKtxRepoOwnership) reads only <dir>/.git and never walks up, so it
already handles the nested case (no local .git -> unowned -> dedicated init)
and additionally rejects foreign repos via a ktx.managed marker. Kept the
ownership model and dropped the now-unused CheckRepoActions import; #282's
regression tests (reindex.nested-git-root, project nested-repo) pass against it.
This commit is contained in:
Andrey Avtomonov 2026-06-10 01:16:01 +02:00
commit dd68aadb35
13 changed files with 195 additions and 31 deletions

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Before After
Before After

View file

@ -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.<role>` 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 <path>` 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 |

View file

@ -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",

View file

@ -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",

View file

@ -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}'`);

View file

@ -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<KtxSetupLlmBackend, string> = {
'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(

View file

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

View file

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

View file

@ -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 () => {

View file

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

View file

@ -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"

View file

@ -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"

View file

@ -19,7 +19,7 @@
},
"publishedPackageSmoke": {
"packageName": "@kaelio/ktx",
"version": "0.10.0",
"version": "0.11.0",
"registry": null
},
"runtimeInstaller": {