feat(cli): add ktx admin reindex (#160)

* feat(cli): add admin reindex

* fix: keep lexical-only reindex incremental
This commit is contained in:
Andrey Avtomonov 2026-05-20 01:36:54 +02:00 committed by GitHub
parent 3db3e724cb
commit 6dbb0c8b3a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 1640 additions and 393 deletions

View file

@ -0,0 +1,145 @@
import type { ReindexSummary } from '@ktx/context/index-sync';
import { describe, expect, it, vi } from 'vitest';
import { renderReindexJson, renderReindexPlain, reindexHasErrors } from './admin-reindex.js';
import { runKtxCli } from './index.js';
function makeIo(options: { stdoutIsTTY?: boolean } = {}) {
let stdout = '';
let stderr = '';
return {
io: {
stdout: {
isTTY: options.stdoutIsTTY,
write: (chunk: string) => {
stdout += chunk;
},
},
stderr: {
write: (chunk: string) => {
stderr += chunk;
},
},
},
stdout: () => stdout,
stderr: () => stderr,
};
}
function summary(overrides: Partial<ReindexSummary> = {}): ReindexSummary {
return {
scopes: [
{
kind: 'wiki',
label: 'global',
scope: 'global',
scopeId: null,
scanned: 42,
updated: 3,
deleted: 1,
embeddingsRecomputed: 3,
embeddingsFailed: 0,
durationMs: 412,
},
{
kind: 'sl',
label: 'warehouse',
connectionId: 'warehouse',
scanned: 18,
updated: 2,
deleted: 0,
embeddingsRecomputed: 2,
embeddingsFailed: 0,
durationMs: 287,
},
],
totals: { scanned: 60, updated: 5, deleted: 1, embeddingsRecomputed: 5, embeddingsFailed: 0 },
dbPath: '.ktx/db.sqlite',
force: false,
embeddingsAvailable: true,
durationMs: 1234,
...overrides,
};
}
describe('admin reindex renderers', () => {
it('renders plain scope lines to stderr and summary to stdout', () => {
const io = makeIo();
renderReindexPlain(summary(), io.io);
expect(io.stderr()).toContain('wiki/global\tscanned=42\tupdated=3\tdeleted=1\tembeddings=3\tduration_ms=412\n');
expect(io.stderr()).toContain('sl/warehouse\tscanned=18\tupdated=2\tdeleted=0\tembeddings=2\tduration_ms=287\n');
expect(io.stdout()).toBe('reindex\tscopes=2\tscanned=60\tupdated=5\tdeleted=1\tembeddings=5\tduration_ms=1234\n');
});
it('renders rebuilt labels in plain force mode', () => {
const io = makeIo();
renderReindexPlain(summary({ force: true }), io.io);
expect(io.stderr()).toContain('rebuilt=3');
expect(io.stdout()).toContain('rebuilt=5');
expect(io.stdout()).not.toContain('updated=5');
});
it('renders json envelope to stdout only', () => {
const io = makeIo();
renderReindexJson(summary(), io.io);
expect(JSON.parse(io.stdout())).toMatchObject({
kind: 'reindex',
data: { totals: { scanned: 60, updated: 5 } },
meta: { command: 'admin reindex' },
});
expect(io.stderr()).toBe('');
});
it('detects per-scope errors', () => {
expect(
reindexHasErrors(
summary({
scopes: [{ ...summary().scopes[0]!, error: 'provider failed' }],
}),
),
).toBe(true);
});
});
describe('admin reindex Commander routing', () => {
it('routes flags to the injectable reindex runner', async () => {
const { mkdir, mkdtemp, rm, writeFile } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-admin-reindex-cli-'));
const projectDir = join(tempDir, 'project');
const io = makeIo();
const adminReindex = vi.fn(async () => 0);
try {
await mkdir(projectDir, { recursive: true });
await writeFile(join(projectDir, 'ktx.yaml'), '{}\n', 'utf-8');
await expect(
runKtxCli(
['--project-dir', projectDir, 'admin', 'reindex', '--force', '--json', '--output', 'plain'],
io.io,
{ adminReindex },
),
).resolves.toBe(0);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
expect(adminReindex).toHaveBeenCalledWith(
{
projectDir,
force: true,
json: true,
output: 'plain',
cliVersion: '0.1.0-rc.1',
},
io.io,
);
});
});

View file

@ -0,0 +1,210 @@
import {
createLocalKtxEmbeddingProviderFromConfig,
KtxIngestEmbeddingPortAdapter,
MANAGED_SENTENCE_TRANSFORMERS_BASE_URL,
type KtxEmbeddingPort,
} from '@ktx/context';
import { reindexLocalIndexes, type ReindexScopeResult, type ReindexSummary } from '@ktx/context/index-sync';
import { loadKtxProject, type KtxLocalProject } from '@ktx/context/project';
import { Option, type Command } from '@commander-js/extra-typings';
import { cancel, intro, log, note, outro } from '@clack/prompts';
import type { KtxCliCommandContext } from './cli-program.js';
import type { KtxCliIo } from './cli-runtime.js';
import { resolveOutputMode } from './io/mode.js';
import { green, red, SYMBOLS } from './io/symbols.js';
import { ensureManagedLocalEmbeddingsDaemon } from './managed-local-embeddings.js';
export interface KtxAdminReindexArgs {
projectDir: string;
force: boolean;
output?: 'pretty' | 'plain' | 'json';
json?: boolean;
cliVersion: string;
}
export function registerAdminReindexCommand(admin: Command, context: KtxCliCommandContext): void {
admin
.command('reindex')
.description('Sync local wiki and semantic-layer search indexes from disk')
.option('--force', 'Clear each discovered scope before rebuilding it', false)
.option('--json', 'Shortcut for --output=json (overrides --output)', false)
.addOption(
new Option('--output <mode>', 'Output mode: pretty, plain, or json').choices(['pretty', 'plain', 'json']),
)
.action(async (options: { force?: boolean; json?: boolean; output?: 'pretty' | 'plain' | 'json' }, command) => {
const runner = context.deps.adminReindex ?? runKtxAdminReindex;
const { resolveCommandProjectDir } = await import('./cli-program.js');
context.setExitCode(
await runner(
{
projectDir: resolveCommandProjectDir(command),
force: options.force === true,
json: options.json === true,
output: options.output,
cliVersion: context.packageInfo.version,
},
context.io,
),
);
});
}
async function resolveReindexEmbeddingService(
project: KtxLocalProject,
args: KtxAdminReindexArgs,
io: KtxCliIo,
): Promise<KtxEmbeddingPort | null> {
const config = project.config.ingest.embeddings;
if (config.backend === 'none') {
return null;
}
if (
config.backend === 'sentence-transformers' &&
config.sentenceTransformers?.base_url === MANAGED_SENTENCE_TRANSFORMERS_BASE_URL
) {
const daemon = await ensureManagedLocalEmbeddingsDaemon({
cliVersion: args.cliVersion,
projectDir: project.projectDir,
installPolicy: 'never',
io,
});
const provider = createLocalKtxEmbeddingProviderFromConfig(config, { env: { ...process.env, ...daemon.env } });
return provider ? new KtxIngestEmbeddingPortAdapter(provider) : null;
}
const provider = createLocalKtxEmbeddingProviderFromConfig(config);
return provider ? new KtxIngestEmbeddingPortAdapter(provider) : null;
}
function scopeKey(scope: ReindexScopeResult): string {
if (scope.kind === 'wiki') {
return scope.scope === 'user' ? `wiki/user/${scope.scopeId ?? 'local'}` : 'wiki/global';
}
return `sl/${scope.connectionId ?? scope.label}`;
}
function quotePlainValue(value: string): string {
return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
}
export function reindexHasErrors(summary: ReindexSummary): boolean {
return summary.scopes.some((scope) => scope.error);
}
export function renderReindexPlain(summary: ReindexSummary, io: KtxCliIo): void {
const updateKey = summary.force ? 'rebuilt' : 'updated';
for (const scope of summary.scopes) {
const cells = [
scopeKey(scope),
`scanned=${scope.scanned}`,
`${updateKey}=${scope.updated}`,
`deleted=${scope.deleted}`,
`embeddings=${summary.embeddingsAvailable ? String(scope.embeddingsRecomputed) : '-'}`,
`duration_ms=${scope.durationMs}`,
...(scope.error ? [`error=${quotePlainValue(scope.error)}`] : []),
];
io.stderr.write(`${cells.join('\t')}\n`);
}
const failed = summary.scopes.filter((scope) => scope.error).length;
io.stdout.write(
[
'reindex',
`scopes=${summary.scopes.length}`,
`scanned=${summary.totals.scanned}`,
`${updateKey}=${summary.totals.updated}`,
`deleted=${summary.totals.deleted}`,
`embeddings=${summary.embeddingsAvailable ? String(summary.totals.embeddingsRecomputed) : '-'}`,
`duration_ms=${summary.durationMs}`,
...(failed > 0 ? [`failed=${failed}`] : []),
].join('\t') + '\n',
);
}
export function renderReindexJson(summary: ReindexSummary, io: KtxCliIo): void {
io.stdout.write(`${JSON.stringify({ kind: 'reindex', data: summary, meta: { command: 'admin reindex' } }, null, 2)}\n`);
}
function noun(scope: ReindexScopeResult): string {
return scope.kind === 'wiki' ? 'pages' : 'sources';
}
function formatScopeLine(scope: ReindexScopeResult, force: boolean, embeddingsAvailable: boolean): string {
if (scope.error) {
return `${scope.kind === 'wiki' ? 'Wiki' : 'SL'}: ${scope.label} ${SYMBOLS.emDash} failed: ${scope.error}`;
}
const changedLabel = force ? 'rebuilt' : 'updated';
const parts = [`${scope.scanned} ${noun(scope)}`];
if (scope.updated > 0) {
parts.push(`${scope.updated} ${changedLabel}`);
} else {
parts.push('unchanged');
}
if (!force && scope.deleted > 0) {
parts.push(`${scope.deleted} deleted`);
}
if (embeddingsAvailable) {
parts.push(`${scope.embeddingsRecomputed} embeddings recomputed`);
}
parts.push(`${scope.durationMs}ms`);
return `${scope.kind === 'wiki' ? 'Wiki' : 'SL'}: ${scope.label} ${SYMBOLS.emDash} ${parts.join(` ${SYMBOLS.middot} `)}`;
}
function renderReindexPretty(summary: ReindexSummary, io: KtxCliIo): void {
intro(summary.force ? 'ktx admin reindex --force' : 'ktx admin reindex');
if (!summary.embeddingsAvailable) {
log.warn(`Embeddings: not configured ${SYMBOLS.emDash} indexing lexical only`);
}
for (const scope of summary.scopes) {
const line = formatScopeLine(scope, summary.force, summary.embeddingsAvailable);
if (scope.error) {
log.error(red(line));
} else {
log.success(green(line));
}
}
const failed = summary.scopes.filter((scope) => scope.error).length;
note(
[
`scopes ${summary.scopes.length}`,
`scanned ${summary.totals.scanned}`,
`${summary.force ? 'rebuilt' : 'updated'} ${summary.totals.updated}`,
`deleted ${summary.totals.deleted}`,
`embeddings ${summary.embeddingsAvailable ? summary.totals.embeddingsRecomputed : SYMBOLS.emDash}`,
`index ${summary.dbPath}`,
...(failed > 0 ? [`failed ${failed}`] : []),
].join('\n'),
'Summary',
);
if (failed > 0) {
cancel(`reindex completed with ${failed} error${failed === 1 ? '' : 's'}`);
} else {
outro(`Done in ${(summary.durationMs / 1000).toFixed(1)}s`);
}
void io;
}
async function runKtxAdminReindex(args: KtxAdminReindexArgs, io: KtxCliIo = process): Promise<number> {
try {
const project = await loadKtxProject({ projectDir: args.projectDir });
const embeddingService = await resolveReindexEmbeddingService(project, args, io);
const summary = await reindexLocalIndexes(project, { force: args.force, embeddingService });
const mode = resolveOutputMode({ explicit: args.output, json: args.json, io });
if (!summary.embeddingsAvailable && mode === 'plain') {
io.stderr.write(`Embeddings: not configured ${SYMBOLS.emDash} indexing lexical only\n`);
}
if (mode === 'json') {
renderReindexJson(summary, io);
} else if (mode === 'plain') {
renderReindexPlain(summary, io);
} else {
renderReindexPretty(summary, io);
}
return reindexHasErrors(summary) ? 1 : 0;
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
}

View file

@ -22,14 +22,14 @@ function makeIo() {
};
}
describe('dev Commander tree', () => {
it('prints visible dev help with only supported low-level command groups', async () => {
describe('admin Commander tree', () => {
it('prints visible admin help with supported low-level command groups', async () => {
const testIo = makeIo();
await expect(runKtxCli(['dev', '--help'], testIo.io)).resolves.toBe(0);
await expect(runKtxCli(['admin', '--help'], testIo.io)).resolves.toBe(0);
expect(testIo.stdout()).toContain('Usage: ktx dev [options] [command]');
for (const command of ['init', 'runtime']) {
expect(testIo.stdout()).toContain('Usage: ktx admin [options] [command]');
for (const command of ['init', 'runtime', 'reindex']) {
expect(testIo.stdout()).toContain(command);
}
for (const removed of [
@ -52,27 +52,35 @@ describe('dev Commander tree', () => {
expect(testIo.stderr()).toBe('');
});
it('lists dev in root command rows', async () => {
it('lists admin in root command rows', async () => {
const testIo = makeIo();
await expect(runKtxCli(['--help'], testIo.io)).resolves.toBe(0);
expect(testIo.stdout()).not.toContain('Advanced:');
expect(testIo.stdout()).toContain('dev');
expect(testIo.stdout()).toMatch(/Low-level project initialization and runtime\s+management/);
expect(testIo.stdout()).toContain('admin');
expect(testIo.stdout()).toMatch(/Low-level project initialization,\s+runtime,\s+and index management/);
expect(testIo.stderr()).toBe('');
});
it('keeps project scaffolding under dev init', async () => {
it('does not keep a dev alias', async () => {
const testIo = makeIo();
await expect(runKtxCli(['dev', '--help'], testIo.io)).resolves.toBe(1);
expect(testIo.stderr()).toContain("unknown command 'dev'");
});
it('keeps project scaffolding under admin init', async () => {
const { mkdtemp, readFile, rm } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-dev-init-'));
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-admin-init-'));
const projectDir = join(tempDir, 'warehouse');
const testIo = makeIo();
try {
await expect(runKtxCli(['dev', 'init', projectDir], testIo.io)).resolves.toBe(0);
await expect(runKtxCli(['admin', 'init', projectDir], testIo.io)).resolves.toBe(0);
expect(testIo.stdout()).toContain(`Initialized KTX project at ${projectDir}`);
await expect(readFile(join(projectDir, 'ktx.yaml'), 'utf-8')).resolves.not.toContain('project:');
@ -82,17 +90,17 @@ describe('dev Commander tree', () => {
}
});
it('uses global project-dir for dev init when the positional directory is omitted', async () => {
it('uses global project-dir for admin init when the positional directory is omitted', async () => {
const { mkdtemp, rm } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-dev-init-global-'));
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-admin-init-global-'));
const projectDir = join(tempDir, 'global-init');
const testIo = makeIo();
try {
await expect(
runKtxCli(['--project-dir', projectDir, 'dev', 'init'], testIo.io),
runKtxCli(['--project-dir', projectDir, 'admin', 'init'], testIo.io),
).resolves.toBe(0);
expect(testIo.stdout()).toContain(`Initialized KTX project at ${projectDir}`);
@ -106,7 +114,7 @@ describe('dev Commander tree', () => {
const { mkdtemp, rm } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-dev-schema-'));
const tempDir = await mkdtemp(join(tmpdir(), 'ktx-admin-schema-'));
const missingProjectDir = join(tempDir, 'missing-project');
const originalProjectDir = process.env.KTX_PROJECT_DIR;
const testIo = makeIo();
@ -114,7 +122,7 @@ describe('dev Commander tree', () => {
try {
process.env.KTX_PROJECT_DIR = missingProjectDir;
await expect(runKtxCli(['dev', 'schema'], testIo.io)).resolves.toBe(0);
await expect(runKtxCli(['admin', 'schema'], testIo.io)).resolves.toBe(0);
expect(JSON.parse(testIo.stdout())).toMatchObject({
title: 'ktx.yaml',
@ -131,19 +139,19 @@ describe('dev Commander tree', () => {
}
});
it('rejects removed dev command groups', async () => {
it('rejects removed admin command groups', async () => {
for (const argv of [
['dev', 'doctor', 'setup'],
['dev', 'runtime', 'doctor'],
['dev', 'runtime', 'prune', '--dry-run'],
['dev', 'scan', 'warehouse'],
['dev', 'ingest', 'run'],
['dev', 'mapping', 'list'],
['dev', 'completion', 'zsh'],
['dev', '__complete', '--shell', 'zsh', '--position', '2', '--', 'ktx', ''],
['dev', 'knowledge', 'list'],
['dev', 'model', 'list'],
['dev', 'artifacts'],
['admin', 'doctor', 'setup'],
['admin', 'runtime', 'doctor'],
['admin', 'runtime', 'prune', '--dry-run'],
['admin', 'scan', 'warehouse'],
['admin', 'ingest', 'run'],
['admin', 'mapping', 'list'],
['admin', 'completion', 'zsh'],
['admin', '__complete', '--shell', 'zsh', '--position', '2', '--', 'ktx', ''],
['admin', 'knowledge', 'list'],
['admin', 'model', 'list'],
['admin', 'artifacts'],
]) {
const testIo = makeIo();
@ -155,8 +163,8 @@ describe('dev Commander tree', () => {
it.each([
{
argv: ['dev', 'runtime', '--help'],
expected: ['Usage: ktx dev runtime', 'install', 'start', 'stop', 'status'],
argv: ['admin', 'runtime', '--help'],
expected: ['Usage: ktx admin runtime', 'install', 'start', 'stop', 'status'],
},
])('prints generated nested help for $argv', async ({ argv, expected }) => {
const io = makeIo();
@ -167,7 +175,7 @@ describe('dev Commander tree', () => {
for (const text of expected) {
expect(io.stdout()).toContain(text);
}
if (argv.join(' ') === 'dev runtime --help') {
if (argv.join(' ') === 'admin runtime --help') {
expect(io.stdout()).not.toContain('prune');
expect(io.stdout()).not.toContain('doctor');
}

View file

@ -1,27 +1,28 @@
import { resolve } from 'node:path';
import type { Command } from '@commander-js/extra-typings';
import { type CommandWithGlobalOptions, type KtxCliCommandContext, resolveCommandProjectDir } from './cli-program.js';
import { registerAdminReindexCommand } from './admin-reindex.js';
import { registerRuntimeCommands } from './commands/runtime-commands.js';
import { profileMark } from './startup-profile.js';
profileMark('module:dev');
profileMark('module:admin');
export function registerDevCommands(program: Command, context: KtxCliCommandContext): void {
const dev = program
.command('dev')
.description('Low-level project initialization and runtime management')
export function registerAdminCommands(program: Command, context: KtxCliCommandContext): void {
const admin = program
.command('admin')
.description('Low-level project initialization, runtime, and index management')
.showHelpAfterError();
dev.hook('preAction', (_thisCommand, actionCommand) => {
context.writeDebug?.('dev', actionCommand);
admin.hook('preAction', (_thisCommand, actionCommand) => {
context.writeDebug?.('admin', actionCommand);
});
dev.action(() => {
dev.outputHelp();
admin.action(() => {
admin.outputHelp();
context.setExitCode(0);
});
dev
admin
.command('init')
.description('Initialize a Git-backed KTX project directory for maintenance scripts')
.argument('[directory]', 'Project directory')
@ -44,7 +45,7 @@ export function registerDevCommands(program: Command, context: KtxCliCommandCont
},
);
dev
admin
.command('schema')
.description('Print a JSON Schema describing ktx.yaml (for editors and LLM agents)')
.option('--output <file>', 'Write the schema to a file instead of stdout')
@ -62,5 +63,6 @@ export function registerDevCommands(program: Command, context: KtxCliCommandCont
context.setExitCode(0);
});
registerRuntimeCommands(dev, context);
registerRuntimeCommands(admin, context);
registerAdminReindexCommand(admin, context);
}

View file

@ -31,7 +31,7 @@ describe('buildKtxProgram', () => {
expect(program.name()).toBe('ktx');
const topLevel = program.commands.map((command) => command.name()).sort();
for (const expected of ['setup', 'connection', 'ingest', 'sl', 'dev']) {
for (const expected of ['setup', 'connection', 'ingest', 'sl', 'admin']) {
expect(topLevel).toContain(expected);
}
});

View file

@ -10,7 +10,7 @@ import { registerSetupCommands } from './commands/setup-commands.js';
import { registerSlCommands } from './commands/sl-commands.js';
import { registerSqlCommands } from './commands/sql-commands.js';
import { registerStatusCommands } from './commands/status-commands.js';
import { registerDevCommands } from './dev.js';
import { registerAdminCommands } from './admin.js';
import { renderMissingProjectMessage } from './doctor.js';
import { findNearestKtxProjectDir, resolveKtxProjectDir } from './project-resolver.js';
import { profileMark, profileSpan } from './startup-profile.js';
@ -58,8 +58,8 @@ type CommandPathNode = CommandWithGlobalOptions & {
};
const PROJECT_AWARE_ROOT_COMMANDS = new Set(['setup', 'connection', 'ingest', 'wiki', 'sl', 'sql', 'status', 'mcp']);
const PROJECT_INDEPENDENT_DEV_COMMANDS = new Set(['runtime', 'schema']);
const COMMANDS_THAT_CREATE_PROJECT = new Set(['setup', 'ktx dev init']);
const PROJECT_INDEPENDENT_ADMIN_COMMANDS = new Set(['runtime', 'schema']);
const COMMANDS_THAT_CREATE_PROJECT = new Set(['setup', 'ktx admin init']);
const COMMANDS_WITH_OWN_MISSING_PROJECT_HANDLING = new Set(['status']);
const GLOBAL_OPTIONS_WITH_VALUE = new Set(['--project-dir']);
const GLOBAL_OPTIONS_WITHOUT_VALUE = new Set(['--debug', '--help', '-h', '--version', '-v']);
@ -172,15 +172,15 @@ function isProjectAwareCommand(path: string[]): boolean {
}
const rootCommand = path[1];
if (rootCommand === 'dev') {
return path[2] !== undefined && !PROJECT_INDEPENDENT_DEV_COMMANDS.has(path[2]);
if (rootCommand === 'admin') {
return path[2] !== undefined && !PROJECT_INDEPENDENT_ADMIN_COMMANDS.has(path[2]);
}
return rootCommand !== undefined && PROJECT_AWARE_ROOT_COMMANDS.has(rootCommand);
}
function shouldSuppressProjectDirLine(path: string[], options: Record<string, unknown>): boolean {
const commandPathKey = path.join(' ');
if (commandPathKey === 'ktx dev init') {
if (commandPathKey === 'ktx admin init') {
return true;
}
@ -421,7 +421,7 @@ export function buildKtxProgram(options: BuildKtxProgramOptions): Command {
registerSqlCommands(program, context);
registerStatusCommands(program, context);
registerMcpCommands(program, context);
registerDevCommands(program, context);
registerAdminCommands(program, context);
return program;
}

View file

@ -1,6 +1,7 @@
import { createRequire } from 'node:module';
import type { KtxConnectionArgs } from './connection.js';
import type { KtxAdminReindexArgs } from './admin-reindex.js';
import type { KtxDoctorArgs } from './doctor.js';
import type { KtxKnowledgeArgs } from './knowledge.js';
import type { KtxPublicIngestArgs } from './public-ingest.js';
@ -30,6 +31,7 @@ export interface KtxCliIo {
}
export interface KtxCliDeps {
adminReindex?: (args: KtxAdminReindexArgs, io: KtxCliIo) => Promise<number>;
setup?: (args: KtxSetupArgs, io: KtxCliIo) => Promise<number>;
connection?: (args: KtxConnectionArgs, io: KtxCliIo) => Promise<number>;
doctor?: (args: KtxDoctorArgs, io: KtxCliIo) => Promise<number>;

View file

@ -129,9 +129,10 @@ describe('runKtxCli', () => {
expect(testIo.stdout()).toContain('Usage: ktx [options] [command]');
expect(testIo.stdout()).toContain('KTX data agent context layer CLI');
for (const command of ['setup', 'connection', 'ingest', 'wiki', 'sl', 'status', 'dev']) {
for (const command of ['setup', 'connection', 'ingest', 'wiki', 'sl', 'status', 'admin']) {
expect(testIo.stdout()).toContain(`${command}`);
}
expect(testIo.stdout()).not.toMatch(/^ dev\s/m);
expect(testIo.stdout()).not.toMatch(/^ scan\s/m);
for (const removed of ['demo', 'init', 'connect', 'ask', 'knowledge', 'agent', 'completion', 'serve']) {
expect(testIo.stdout()).not.toMatch(new RegExp(`^\\s+${removed}(?:\\s|\\[|$)`, 'm'));
@ -266,17 +267,17 @@ describe('runKtxCli', () => {
const pruneIo = makeIo();
await expect(
runKtxCli(['dev', 'runtime', 'install', '--feature', 'local-embeddings', '--force', '--yes'], installIo.io, {
runKtxCli(['admin', 'runtime', 'install', '--feature', 'local-embeddings', '--force', '--yes'], installIo.io, {
runtime,
}),
).resolves.toBe(0);
await expect(
runKtxCli(['dev', 'runtime', 'start', '--feature', 'local-embeddings', '--force'], startIo.io, { runtime }),
runKtxCli(['admin', 'runtime', 'start', '--feature', 'local-embeddings', '--force'], startIo.io, { runtime }),
).resolves.toBe(0);
await expect(runKtxCli(['dev', 'runtime', 'stop'], stopIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['dev', 'runtime', 'stop', '--all'], stopAllIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['dev', 'runtime', 'status', '--json'], statusIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['dev', 'runtime', 'prune', '--dry-run'], pruneIo.io, { runtime })).resolves.toBe(1);
await expect(runKtxCli(['admin', 'runtime', 'stop'], stopIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['admin', 'runtime', 'stop', '--all'], stopAllIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['admin', 'runtime', 'status', '--json'], statusIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['admin', 'runtime', 'prune', '--dry-run'], pruneIo.io, { runtime })).resolves.toBe(1);
expect(runtime).toHaveBeenNthCalledWith(
1,
@ -377,7 +378,7 @@ describe('runKtxCli', () => {
it('documents runtime stop all in command help', async () => {
const testIo = makeIo();
await expect(runKtxCli(['dev', 'runtime', 'stop', '--help'], testIo.io)).resolves.toBe(0);
await expect(runKtxCli(['admin', 'runtime', 'stop', '--help'], testIo.io)).resolves.toBe(0);
expect(testIo.stdout()).toContain('--all');
expect(testIo.stdout()).toContain('Stop all KTX daemon processes recorded or discoverable');
@ -655,9 +656,9 @@ describe('runKtxCli', () => {
const completionIo = makeIo();
const hiddenIo = makeIo();
await expect(runKtxCli(['dev', 'completion', 'zsh'], completionIo.io)).resolves.toBe(1);
await expect(runKtxCli(['admin', 'completion', 'zsh'], completionIo.io)).resolves.toBe(1);
await expect(
runKtxCli(['dev', '__complete', '--shell', 'zsh', '--position', '2', '--', 'ktx', 'co'], hiddenIo.io),
runKtxCli(['admin', '__complete', '--shell', 'zsh', '--position', '2', '--', 'ktx', 'co'], hiddenIo.io),
).resolves.toBe(1);
expect(completionIo.stderr()).toMatch(/unknown command|error:/);
@ -938,7 +939,7 @@ describe('runKtxCli', () => {
expect(textIngest).not.toHaveBeenCalled();
});
it('rejects old adapter-backed ingest flags at the top level and under dev', async () => {
it('rejects old adapter-backed ingest flags at the top level and under admin', async () => {
const rootRunIo = makeIo();
const devRunIo = makeIo();
const publicIngest = vi.fn(async () => 0);
@ -949,7 +950,7 @@ describe('runKtxCli', () => {
}),
).resolves.toBe(1);
await expect(
runKtxCli(['dev', 'ingest', 'run', '--connection-id', 'warehouse', '--adapter', 'metabase'], devRunIo.io, {
runKtxCli(['admin', 'ingest', 'run', '--connection-id', 'warehouse', '--adapter', 'metabase'], devRunIo.io, {
publicIngest,
}),
).resolves.toBe(1);
@ -958,12 +959,12 @@ describe('runKtxCli', () => {
expect(devRunIo.stderr()).toMatch(/unknown command|error:/);
});
it('rejects removed dev doctor and removed ingest parser cases', async () => {
it('rejects removed admin doctor and removed ingest parser cases', async () => {
const doctor = vi.fn(async () => 0);
const doctorIo = makeIo();
const ingestRunIo = makeIo();
await expect(runKtxCli(['dev', 'doctor', 'setup', '--json', '--no-input'], doctorIo.io, { doctor })).resolves.toBe(1);
await expect(runKtxCli(['admin', 'doctor', 'setup', '--json', '--no-input'], doctorIo.io, { doctor })).resolves.toBe(1);
await expect(
runKtxCli(
[
@ -1755,12 +1756,12 @@ describe('runKtxCli', () => {
expect(serveIo.stderr()).toMatch(/unknown command|error:/);
});
it('prints dev help for bare dev commands', async () => {
it('prints admin help for bare admin commands', async () => {
const testIo = makeIo();
await expect(runKtxCli(['dev'], testIo.io)).resolves.toBe(0);
await expect(runKtxCli(['admin'], testIo.io)).resolves.toBe(0);
expect(testIo.stdout()).toContain('Usage: ktx dev [options] [command]');
expect(testIo.stdout()).toContain('Usage: ktx admin [options] [command]');
expect(testIo.stdout()).toContain('Low-level project initialization');
expect(testIo.stdout()).toContain('init');
expect(testIo.stdout()).toContain('runtime');
@ -1772,13 +1773,13 @@ describe('runKtxCli', () => {
expect(testIo.stderr()).toBe('');
});
it('rejects removed dev command groups without invoking execution', async () => {
it('rejects removed admin command groups without invoking execution', async () => {
for (const command of ['scan', 'ingest', 'mapping']) {
const testIo = makeIo();
const publicIngest = vi.fn().mockResolvedValue(0);
const sl = vi.fn().mockResolvedValue(0);
await expect(runKtxCli(['dev', command], testIo.io, { publicIngest, sl })).resolves.toBe(1);
await expect(runKtxCli(['admin', command], testIo.io, { publicIngest, sl })).resolves.toBe(1);
expect(testIo.stderr()).toMatch(/unknown command|error:/);
expect(publicIngest).not.toHaveBeenCalled();
@ -1786,10 +1787,10 @@ describe('runKtxCli', () => {
}
});
it('rejects removed reserved dev subcommands', async () => {
it('rejects removed reserved admin subcommands', async () => {
const testIo = makeIo();
await expect(runKtxCli(['dev', 'artifacts'], testIo.io)).resolves.toBe(1);
await expect(runKtxCli(['admin', 'artifacts'], testIo.io)).resolves.toBe(1);
expect(testIo.stderr()).toMatch(/unknown command|error:/);
});

View file

@ -118,9 +118,9 @@ function makeSpinnerEvents() {
describe('managedRuntimeInstallCommand', () => {
it('prints the exact command for each managed runtime feature', () => {
expect(managedRuntimeInstallCommand('core')).toBe('ktx dev runtime install --yes');
expect(managedRuntimeInstallCommand('core')).toBe('ktx admin runtime install --yes');
expect(managedRuntimeInstallCommand('local-embeddings')).toBe(
'ktx dev runtime install --feature local-embeddings --yes',
'ktx admin runtime install --feature local-embeddings --yes',
);
});
});
@ -221,7 +221,7 @@ describe('createManagedPythonSemanticLayerComputePort', () => {
readStatus: vi.fn(async () => missingStatus()),
installRuntime,
}),
).rejects.toThrow('KTX Python runtime is required for this command. Run: ktx dev runtime install --yes');
).rejects.toThrow('KTX Python runtime is required for this command. Run: ktx admin runtime install --yes');
expect(installRuntime).not.toHaveBeenCalled();
});

View file

@ -53,8 +53,8 @@ export interface ManagedPythonSemanticLayerComputeOptions extends ManagedPythonC
export function managedRuntimeInstallCommand(feature: KtxRuntimeFeature): string {
return feature === 'local-embeddings'
? 'ktx dev runtime install --feature local-embeddings --yes'
: 'ktx dev runtime install --yes';
? 'ktx admin runtime install --feature local-embeddings --yes'
: 'ktx admin runtime install --yes';
}
function installPrompt(feature: KtxRuntimeFeature): string {

View file

@ -513,7 +513,7 @@ describe('doctorManagedPythonRuntime', () => {
['asset', 'pass'],
['runtime', 'fail'],
]);
expect(checks[2]?.fix).toBe('Run: ktx dev runtime install --yes');
expect(checks[2]?.fix).toBe('Run: ktx admin runtime install --yes');
});
it('reports uv as a hard prerequisite when uv is missing', async () => {
@ -534,7 +534,7 @@ describe('doctorManagedPythonRuntime', () => {
label: 'uv',
status: 'fail',
detail: MISSING_UV_RUNTIME_INSTALL_MESSAGE,
fix: 'Install uv, make sure it is on PATH, and run: ktx dev runtime install --yes',
fix: 'Install uv, make sure it is on PATH, and run: ktx admin runtime install --yes',
});
});
});

View file

@ -122,7 +122,7 @@ export interface ManagedPythonRuntimeDoctorCheck {
}
export const MISSING_UV_RUNTIME_INSTALL_MESSAGE =
'uv is required to install the KTX Python runtime. KTX does not download uv automatically. Install uv, make sure it is on PATH, and retry: ktx dev runtime install --yes';
'uv is required to install the KTX Python runtime. KTX does not download uv automatically. Install uv, make sure it is on PATH, and retry: ktx admin runtime install --yes';
function defaultAssetDir(): string {
return fileURLToPath(new URL('../assets/python/', import.meta.url));
@ -471,7 +471,7 @@ export async function doctorManagedPythonRuntime(
id: 'uv',
label: 'uv',
detail: error instanceof Error ? error.message : String(error),
fix: 'Install uv, make sure it is on PATH, and run: ktx dev runtime install --yes',
fix: 'Install uv, make sure it is on PATH, and run: ktx admin runtime install --yes',
}),
);
}
@ -496,7 +496,7 @@ export async function doctorManagedPythonRuntime(
id: 'runtime',
label: 'Managed Python runtime',
detail: status.detail,
...(status.kind === 'ready' ? {} : { fix: 'Run: ktx dev runtime install --yes' }),
...(status.kind === 'ready' ? {} : { fix: 'Run: ktx admin runtime install --yes' }),
}),
);
return checks;

View file

@ -12,7 +12,7 @@ describe('renderKtxCommandTree', () => {
.filter((line) => /^ {2}[├└]── \S/.test(line))
.map((line) => line.replace(/^ {2}[├└]── /, '').trim().split(' ')[0]);
for (const expected of ['setup', 'connection', 'ingest', 'sl', 'mcp', 'dev']) {
for (const expected of ['setup', 'connection', 'ingest', 'sl', 'mcp', 'admin']) {
expect(topLevel).toContain(expected);
}

View file

@ -291,7 +291,7 @@ describe('runKtxRuntime', () => {
label: 'Managed Python runtime',
status: 'fail',
detail: 'No runtime manifest at /runtime/0.2.0/manifest.json',
fix: 'Run: ktx dev runtime install --yes',
fix: 'Run: ktx admin runtime install --yes',
},
]),
};

View file

@ -368,8 +368,8 @@ describe('runKtxScan', () => {
expect(io.stdout()).toContain('Report: raw-sources/warehouse/live-database/sync-1/scan-report.json');
expect(io.stdout()).toContain('Next:\n');
expect(io.stdout()).toContain('ktx status --project-dir ');
expect(io.stdout()).not.toContain('ktx dev scan status');
expect(io.stdout()).not.toContain('ktx dev scan report');
expect(io.stdout()).not.toContain('ktx admin scan status');
expect(io.stdout()).not.toContain('ktx admin scan report');
expect(io.stdout()).not.toContain('\u001b[');
expect(io.stdout()).not.toContain('✓');
expect(io.stdout()).not.toContain('+1');

View file

@ -286,7 +286,7 @@ describe('setup embeddings step', () => {
const io = makeIo();
const ensureLocalEmbeddings = vi.fn(async () => {
throw new Error(
'KTX Python runtime is required for this command. Run: ktx dev runtime install --feature local-embeddings --yes',
'KTX Python runtime is required for this command. Run: ktx admin runtime install --feature local-embeddings --yes',
);
});
@ -304,7 +304,7 @@ describe('setup embeddings step', () => {
expect(result.status).toBe('failed');
expect(io.stderr()).toContain(
'KTX Python runtime is required for this command. Run: ktx dev runtime install --feature local-embeddings --yes',
'KTX Python runtime is required for this command. Run: ktx admin runtime install --feature local-embeddings --yes',
);
});
@ -331,7 +331,7 @@ describe('setup embeddings step', () => {
expect(await readFile(join(tempDir, 'ktx.yaml'), 'utf-8')).not.toContain('completed_steps:');
expect(config.ingest.embeddings.backend).toBe('none');
expect(io.stderr()).toContain('Local embedding health check failed: 401 invalid api key [redacted]');
expect(io.stderr()).toContain('Prepare the runtime with: ktx dev runtime start --feature local-embeddings');
expect(io.stderr()).toContain('Prepare the runtime with: ktx admin runtime start --feature local-embeddings');
expect(io.stderr()).not.toContain('skip for now');
});

View file

@ -307,7 +307,7 @@ function localEmbeddingSetupMessage(message: string, stderrTail: string[] = []):
const lines = [
`Local embedding health check failed: ${message}`,
'Local embeddings use the KTX-managed Python runtime.',
'Prepare the runtime with: ktx dev runtime start --feature local-embeddings',
'Prepare the runtime with: ktx admin runtime start --feature local-embeddings',
'Use --yes with setup to install and start the runtime without prompting.',
'The first run may download Python packages and the all-MiniLM-L6-v2 model.',
];

View file

@ -71,7 +71,7 @@ describe('runKtxSetupRuntimeStep', () => {
it('fails fast when required runtime features cannot be installed in no-input mode', async () => {
const io = makeIo();
const ensureRuntime = vi.fn(async () => {
throw new Error('KTX Python runtime is required for this command. Run: ktx dev runtime install --yes');
throw new Error('KTX Python runtime is required for this command. Run: ktx admin runtime install --yes');
});
await expect(
@ -94,7 +94,7 @@ describe('runKtxSetupRuntimeStep', () => {
expect(ensureRuntime).toHaveBeenCalledWith(expect.objectContaining({ installPolicy: 'never' }));
expect((await readKtxSetupState(tempDir)).completed_steps).not.toContain('runtime');
expect(io.stderr()).toContain('ktx dev runtime install --yes');
expect(io.stderr()).toContain('ktx admin runtime install --yes');
});
it('starts the managed local embeddings daemon for configured sentence-transformers embeddings', async () => {

View file

@ -144,6 +144,11 @@ describe('standalone built ktx CLI smoke', () => {
expectSetupStderr(init);
expect(init.stdout).toContain(`Project: ${projectDir}`);
const reindex = await runBuiltCli(['--project-dir', projectDir, 'admin', 'reindex', '--output', 'plain']);
expect(reindex.code).toBe(0);
expect(reindex.stdout).toContain('reindex\t');
expect(reindex.stderr).toContain('wiki/global');
const run = await runBuiltCli([
'ingest',
'run',