mirror of
https://github.com/Kaelio/ktx.git
synced 2026-07-04 10:52:13 +02:00
Merge branch 'main' into remove-ingest-fallbacks
This commit is contained in:
commit
68d1bb840f
97 changed files with 2525 additions and 831 deletions
145
packages/cli/src/admin-reindex.test.ts
Normal file
145
packages/cli/src/admin-reindex.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
210
packages/cli/src/admin-reindex.ts
Normal file
210
packages/cli/src/admin-reindex.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { type Command } from '@commander-js/extra-typings';
|
|||
import { type KtxCliCommandContext, resolveCommandProjectDir } from '../cli-program.js';
|
||||
import type { KtxConnectionArgs } from '../connection.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
import { resolveConnectionSelection } from './connection-selection.js';
|
||||
|
||||
profileMark('module:commands/connection-commands');
|
||||
|
||||
|
|
@ -18,7 +19,10 @@ export function registerConnectionCommands(program: Command, context: KtxCliComm
|
|||
.addHelpText(
|
||||
'after',
|
||||
'\nProject directory defaults to KTX_PROJECT_DIR when set, otherwise the nearest ktx.yaml or current working directory.\n',
|
||||
);
|
||||
)
|
||||
.action(async (_options: unknown, command) => {
|
||||
await runConnectionArgs(context, { command: 'list', projectDir: resolveCommandProjectDir(command) });
|
||||
});
|
||||
connection.hook('preAction', (_thisCommand, actionCommand) => {
|
||||
context.writeDebug?.(commandName, actionCommand);
|
||||
});
|
||||
|
|
@ -32,25 +36,22 @@ export function registerConnectionCommands(program: Command, context: KtxCliComm
|
|||
|
||||
connection
|
||||
.command('test')
|
||||
.description('Test a configured connection')
|
||||
.argument('[connectionId]', 'KTX connection id (omit when --all is set)')
|
||||
.description('Test one or all configured connections (default: all)')
|
||||
.argument('[connectionId]', 'KTX connection id to test (omit to test all)')
|
||||
.option('--all', 'Test every configured connection and print a summary list')
|
||||
.action(async (connectionId: string | undefined, options: { all?: boolean }, command) => {
|
||||
const all = options.all === true;
|
||||
if (all && connectionId !== undefined) {
|
||||
if (options.all === true && connectionId !== undefined) {
|
||||
command.error('error: --all cannot be combined with a connection id argument');
|
||||
}
|
||||
if (!all && connectionId === undefined) {
|
||||
command.error('error: missing required argument <connectionId> (or pass --all)');
|
||||
}
|
||||
if (all) {
|
||||
const selection = resolveConnectionSelection({ connectionId, all: options.all === true });
|
||||
if (selection.kind === 'all') {
|
||||
await runConnectionArgs(context, { command: 'test-all', projectDir: resolveCommandProjectDir(command) });
|
||||
return;
|
||||
}
|
||||
await runConnectionArgs(context, {
|
||||
command: 'test',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: connectionId as string,
|
||||
connectionId: selection.connectionId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
18
packages/cli/src/commands/connection-selection.ts
Normal file
18
packages/cli/src/commands/connection-selection.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export type ConnectionSelection =
|
||||
| { kind: 'all' }
|
||||
| { kind: 'single'; connectionId: string };
|
||||
|
||||
export interface ResolveConnectionSelectionInput {
|
||||
connectionId?: string | undefined;
|
||||
all: boolean;
|
||||
}
|
||||
|
||||
export function resolveConnectionSelection(input: ResolveConnectionSelectionInput): ConnectionSelection {
|
||||
if (input.all && input.connectionId !== undefined) {
|
||||
throw new Error('--all cannot be combined with a connection id argument');
|
||||
}
|
||||
if (input.connectionId !== undefined) {
|
||||
return { kind: 'single', connectionId: input.connectionId };
|
||||
}
|
||||
return { kind: 'all' };
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import { runtimeInstallPolicyFromFlags } from '../managed-python-command.js';
|
|||
import type { KtxPublicIngestArgs } from '../public-ingest.js';
|
||||
import { profileMark } from '../startup-profile.js';
|
||||
import type { KtxTextIngestArgs } from '../text-ingest.js';
|
||||
import { resolveConnectionSelection } from './connection-selection.js';
|
||||
|
||||
profileMark('module:commands/ingest-commands');
|
||||
|
||||
|
|
@ -24,15 +25,20 @@ export function registerIngestCommands(
|
|||
): void {
|
||||
const ingest = program
|
||||
.command('ingest')
|
||||
.description('Build or inspect KTX context')
|
||||
.description('Build or inspect KTX context, or capture text into memory')
|
||||
.usage('[options] [connectionId]')
|
||||
.argument('[connectionId]', 'Configured connection id to ingest')
|
||||
.argument('[connectionId]', 'Configured connection id to ingest (omit to ingest all)')
|
||||
.option('--all', 'Ingest all configured connections', false)
|
||||
.addOption(new Option('--fast', 'Use deterministic database schema ingest').conflicts('deep'))
|
||||
.addOption(new Option('--deep', 'Use AI-enriched database ingest').conflicts('fast'))
|
||||
.addOption(new Option('--query-history', 'Include database query-history usage patterns').conflicts('noQueryHistory'))
|
||||
.addOption(new Option('--no-query-history', 'Skip database query-history usage patterns'))
|
||||
.option('--query-history-window-days <days>', 'Query-history lookback window for this run', parsePositiveIntegerOption)
|
||||
.option('--text <content>', 'Capture inline text into KTX memory; repeatable', collectOption, [])
|
||||
.option('--file <path>', 'Capture a text file into KTX memory; use - for stdin; repeatable', collectOption, [])
|
||||
.option('--connection-id <connectionId>', 'KTX connection id to tag captured text/file notes')
|
||||
.option('--user-id <id>', 'Memory user id for text/file capture attribution', 'local-cli')
|
||||
.option('--fail-fast', 'Stop after the first failed text/file item', false)
|
||||
.addOption(new Option('--plain', 'Print plain text output').conflicts(['json']))
|
||||
.addOption(new Option('--json', 'Print JSON output').conflicts(['plain']))
|
||||
.option('--yes', 'Install required managed runtime features without prompting')
|
||||
|
|
@ -40,14 +46,45 @@ export function registerIngestCommands(
|
|||
.showHelpAfterError();
|
||||
|
||||
ingest.action(async (connectionId: string | undefined, options, command) => {
|
||||
const projectDir = resolveCommandProjectDir(command);
|
||||
const hasTextCapture = options.text.length > 0 || options.file.length > 0;
|
||||
|
||||
if (hasTextCapture) {
|
||||
if (connectionId !== undefined) {
|
||||
command.error(
|
||||
'error: --text/--file does not accept a positional connection id; use --connection-id <id> to tag captured notes',
|
||||
);
|
||||
}
|
||||
if (options.all === true) {
|
||||
command.error('error: --all cannot be combined with --text or --file');
|
||||
}
|
||||
context.setExitCode(
|
||||
await commandOptions.runTextIngest(
|
||||
{
|
||||
projectDir,
|
||||
texts: options.text,
|
||||
files: options.file,
|
||||
...(options.connectionId ? { connectionId: options.connectionId } : {}),
|
||||
userId: options.userId,
|
||||
json: options.json === true,
|
||||
failFast: options.failFast === true,
|
||||
},
|
||||
context.io,
|
||||
context.deps,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = resolveConnectionSelection({ connectionId, all: options.all === true });
|
||||
const { runKtxPublicIngest } = await import('../public-ingest.js');
|
||||
const queryHistory =
|
||||
options.queryHistory === true ? 'enabled' : options.queryHistory === false ? 'disabled' : 'default';
|
||||
const args: KtxPublicIngestArgs = {
|
||||
command: 'run',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
...(connectionId ? { targetConnectionId: connectionId } : {}),
|
||||
all: options.all === true,
|
||||
projectDir,
|
||||
...(selection.kind === 'single' ? { targetConnectionId: selection.connectionId } : {}),
|
||||
all: selection.kind === 'all',
|
||||
json: options.json === true,
|
||||
inputMode: options.input === false ? 'disabled' : 'auto',
|
||||
...(options.fast === true ? { depth: 'fast' as const } : {}),
|
||||
|
|
@ -63,32 +100,4 @@ export function registerIngestCommands(
|
|||
ingest.hook('preAction', (_thisCommand, actionCommand) => {
|
||||
context.writeDebug?.('ingest', actionCommand);
|
||||
});
|
||||
|
||||
ingest
|
||||
.command('text')
|
||||
.description('Ingest free-form text artifacts into KTX memory')
|
||||
.argument('[files...]', 'Files to ingest; use - to read one item from stdin')
|
||||
.option('--text <content>', 'Text content to ingest; repeat for a batch', collectOption, [])
|
||||
.option('--connection-id <connectionId>', 'Optional KTX connection id for semantic-layer capture')
|
||||
.option('--user-id <id>', 'Memory user id for capture attribution', 'local-cli')
|
||||
.option('--json', 'Print JSON output')
|
||||
.option('--fail-fast', 'Stop after the first failed text item', false)
|
||||
.action(async (files: string[], options, command) => {
|
||||
const parentOptions = command.parent?.opts() as { json?: boolean } | undefined;
|
||||
context.setExitCode(
|
||||
await commandOptions.runTextIngest(
|
||||
{
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
texts: options.text,
|
||||
files,
|
||||
...(options.connectionId ? { connectionId: options.connectionId } : {}),
|
||||
userId: options.userId,
|
||||
json: options.json === true || parentOptions?.json === true,
|
||||
failFast: options.failFast === true,
|
||||
},
|
||||
context.io,
|
||||
context.deps,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,59 +21,29 @@ function isDebugEnabled(command: CommandWithGlobalOptions): boolean {
|
|||
}
|
||||
|
||||
export function registerWikiCommands(program: Command, context: KtxCliCommandContext): void {
|
||||
const wiki = program
|
||||
program
|
||||
.command('wiki')
|
||||
.description('List or search local wiki pages')
|
||||
.usage('[options] [query...]')
|
||||
.argument('[query...]', 'Search query; omit to list all pages')
|
||||
.option('--user-id <id>', 'Local user id', 'local')
|
||||
.option('--limit <number>', 'Maximum search results (search mode only)', parsePositiveIntegerOption)
|
||||
.addOption(
|
||||
new Option('--output <mode>', 'Output mode: pretty (default in TTY), plain (TSV), or json').choices([
|
||||
'pretty',
|
||||
'plain',
|
||||
'json',
|
||||
]),
|
||||
)
|
||||
.option('--json', 'Shortcut for --output=json (overrides --output)', false)
|
||||
.showHelpAfterError()
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\nProject directory defaults to KTX_PROJECT_DIR when set, otherwise the current working directory.\n',
|
||||
);
|
||||
|
||||
wiki
|
||||
.command('list')
|
||||
.description('List local wiki pages')
|
||||
.option('--user-id <id>', 'Local user id', 'local')
|
||||
.addOption(
|
||||
new Option('--output <mode>', 'Output mode: pretty (default in TTY), plain (TSV), or json').choices([
|
||||
'pretty',
|
||||
'plain',
|
||||
'json',
|
||||
]),
|
||||
)
|
||||
.option('--json', 'Shortcut for --output=json (overrides --output)', false)
|
||||
.action(
|
||||
async (
|
||||
options: { userId: string; output?: 'pretty' | 'plain' | 'json'; json?: boolean },
|
||||
command,
|
||||
) => {
|
||||
await runKnowledgeArgs(context, {
|
||||
command: 'list',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
userId: options.userId,
|
||||
output: options.output,
|
||||
json: options.json,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
wiki
|
||||
.command('search')
|
||||
.description('Search local wiki pages')
|
||||
.argument('<query>', 'Search query')
|
||||
.option('--user-id <id>', 'Local user id', 'local')
|
||||
.option('--limit <number>', 'Maximum search results', parsePositiveIntegerOption)
|
||||
.addOption(
|
||||
new Option('--output <mode>', 'Output mode: pretty (default in TTY), plain (TSV), or json').choices([
|
||||
'pretty',
|
||||
'plain',
|
||||
'json',
|
||||
]),
|
||||
)
|
||||
.option('--json', 'Shortcut for --output=json (overrides --output)', false)
|
||||
.action(
|
||||
async (
|
||||
query: string,
|
||||
query: string[],
|
||||
options: {
|
||||
userId: string;
|
||||
limit?: number;
|
||||
|
|
@ -82,10 +52,20 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
|
|||
},
|
||||
command,
|
||||
) => {
|
||||
if (query.length === 0) {
|
||||
await runKnowledgeArgs(context, {
|
||||
command: 'list',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
userId: options.userId,
|
||||
output: options.output,
|
||||
json: options.json,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await runKnowledgeArgs(context, {
|
||||
command: 'search',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
query,
|
||||
query: query.join(' '),
|
||||
userId: options.userId,
|
||||
output: options.output,
|
||||
json: options.json,
|
||||
|
|
|
|||
|
|
@ -36,8 +36,24 @@ function formatMcpStartResultMessage(input: { status: 'started' | 'already-runni
|
|||
].join('\n');
|
||||
}
|
||||
|
||||
async function printMcpStatus(context: KtxCliCommandContext, projectDir: string): Promise<void> {
|
||||
const status = await (context.deps.mcp?.readStatus ?? readKtxMcpDaemonStatus)({ projectDir });
|
||||
context.io.stdout.write(`${status.detail}\n`);
|
||||
if (status.kind === 'running') {
|
||||
context.io.stdout.write(`URL: ${status.url}\n`);
|
||||
context.io.stdout.write(`PID: ${status.state.pid}\n`);
|
||||
context.io.stdout.write(`Token auth: ${status.state.tokenAuth ? 'enabled' : 'disabled'}\n`);
|
||||
context.io.stdout.write(`Project: ${status.state.projectDir}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerMcpCommands(program: Command, context: KtxCliCommandContext): void {
|
||||
const mcp = program.command('mcp').description('Run the KTX MCP HTTP server');
|
||||
const mcp = program
|
||||
.command('mcp')
|
||||
.description('Manage the KTX MCP HTTP server (bare command: show status)')
|
||||
.action(async (_options, command) => {
|
||||
await printMcpStatus(context, resolveCommandProjectDir(command));
|
||||
});
|
||||
|
||||
mcp
|
||||
.command('stdio')
|
||||
|
|
@ -110,16 +126,7 @@ export function registerMcpCommands(program: Command, context: KtxCliCommandCont
|
|||
.command('status')
|
||||
.description('Show KTX MCP daemon status')
|
||||
.action(async (_options, command) => {
|
||||
const status = await (context.deps.mcp?.readStatus ?? readKtxMcpDaemonStatus)({
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
});
|
||||
context.io.stdout.write(`${status.detail}\n`);
|
||||
if (status.kind === 'running') {
|
||||
context.io.stdout.write(`URL: ${status.url}\n`);
|
||||
context.io.stdout.write(`PID: ${status.state.pid}\n`);
|
||||
context.io.stdout.write(`Token auth: ${status.state.tokenAuth ? 'enabled' : 'disabled'}\n`);
|
||||
context.io.stdout.write(`Project: ${status.state.projectDir}\n`);
|
||||
}
|
||||
await printMcpStatus(context, resolveCommandProjectDir(command));
|
||||
});
|
||||
|
||||
mcp
|
||||
|
|
|
|||
|
|
@ -42,59 +42,49 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
|
|||
const sl = program
|
||||
.command(commandName)
|
||||
.description('List, search, validate, or query local semantic-layer sources')
|
||||
.usage('[options] [query...]')
|
||||
.argument('[query...]', 'Search query; omit to list all sources')
|
||||
.option('--connection-id <id>', 'KTX connection id')
|
||||
.option('--limit <number>', 'Maximum search results (search mode only)', parsePositiveIntegerOption)
|
||||
.addOption(
|
||||
new Option('--output <mode>', 'Output mode: pretty (default in TTY), plain (TSV), or json').choices([
|
||||
'pretty',
|
||||
'plain',
|
||||
'json',
|
||||
]),
|
||||
)
|
||||
.option('--json', 'Shortcut for --output=json (overrides --output)', false)
|
||||
.showHelpAfterError()
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\nProject directory defaults to KTX_PROJECT_DIR when set, otherwise the current working directory.\n',
|
||||
);
|
||||
|
||||
sl.command('list')
|
||||
.description('List semantic-layer sources')
|
||||
.option('--connection-id <id>', 'KTX connection id')
|
||||
.addOption(
|
||||
new Option('--output <mode>', 'Output mode: pretty (default in TTY), plain (TSV), or json').choices([
|
||||
'pretty',
|
||||
'plain',
|
||||
'json',
|
||||
]),
|
||||
)
|
||||
.option('--json', 'Shortcut for --output=json (overrides --output)', false)
|
||||
.action(
|
||||
async (options: { connectionId?: string; output?: 'pretty' | 'plain' | 'json'; json?: boolean }, command) => {
|
||||
await runSlArgs(context, {
|
||||
command: 'list',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connectionId,
|
||||
output: options.output,
|
||||
json: options.json,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
sl.command('search')
|
||||
.description('Search semantic-layer sources')
|
||||
.argument('<query>', 'Search query')
|
||||
.option('--connection-id <id>', 'KTX connection id')
|
||||
.option('--limit <number>', 'Maximum search results', parsePositiveIntegerOption)
|
||||
.addOption(
|
||||
new Option('--output <mode>', 'Output mode: pretty (default in TTY), plain (TSV), or json').choices([
|
||||
'pretty',
|
||||
'plain',
|
||||
'json',
|
||||
]),
|
||||
)
|
||||
.option('--json', 'Shortcut for --output=json (overrides --output)', false)
|
||||
.action(
|
||||
async (
|
||||
query: string,
|
||||
options: { connectionId?: string; limit?: number; output?: 'pretty' | 'plain' | 'json'; json?: boolean },
|
||||
query: string[],
|
||||
options: {
|
||||
connectionId?: string;
|
||||
limit?: number;
|
||||
output?: 'pretty' | 'plain' | 'json';
|
||||
json?: boolean;
|
||||
},
|
||||
command,
|
||||
) => {
|
||||
if (query.length === 0) {
|
||||
await runSlArgs(context, {
|
||||
command: 'list',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connectionId,
|
||||
output: options.output,
|
||||
json: options.json,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await runSlArgs(context, {
|
||||
command: 'search',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connectionId,
|
||||
query,
|
||||
query: query.join(' '),
|
||||
...(options.limit !== undefined ? { limit: options.limit } : {}),
|
||||
output: options.output,
|
||||
json: options.json,
|
||||
|
|
@ -103,21 +93,24 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
|
|||
);
|
||||
|
||||
sl.command('validate')
|
||||
.description('Validate a semantic-layer source')
|
||||
.description('Validate a semantic-layer source (set --connection-id on `ktx sl`)')
|
||||
.argument('<sourceName>', 'Semantic-layer source name')
|
||||
.requiredOption('--connection-id <id>', 'KTX connection id')
|
||||
.action(async (sourceName: string, options: { connectionId: string }, command) => {
|
||||
.action(async (sourceName: string, _options, command) => {
|
||||
const parentOpts = command.parent?.opts() as { connectionId?: string } | undefined;
|
||||
const connectionId = parentOpts?.connectionId;
|
||||
if (connectionId === undefined) {
|
||||
command.error("error: required option '--connection-id <id>' not specified");
|
||||
}
|
||||
await runSlArgs(context, {
|
||||
command: 'validate',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connectionId,
|
||||
connectionId: connectionId as string,
|
||||
sourceName,
|
||||
});
|
||||
});
|
||||
|
||||
sl.command('query')
|
||||
.description('Compile or execute a semantic-layer query')
|
||||
.option('--connection-id <id>', 'KTX connection id')
|
||||
.description('Compile or execute a semantic-layer query (set --connection-id on `ktx sl`)')
|
||||
.option('--query-file <path>', 'JSON semantic-layer query file')
|
||||
.option('--measure <measure>', 'Measure to query; repeatable', collectOption, [])
|
||||
.option('--dimension <dimension>', 'Dimension to include; repeatable', collectOption, [])
|
||||
|
|
@ -135,10 +128,11 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
|
|||
if (options.measure.length === 0 && !options.queryFile) {
|
||||
throw new Error('sl query requires at least one --measure');
|
||||
}
|
||||
const parentOpts = command.parent?.opts() as { connectionId?: string } | undefined;
|
||||
const args = slQueryCommandSchema.parse({
|
||||
command: 'query',
|
||||
projectDir: resolveCommandProjectDir(command),
|
||||
connectionId: options.connectionId,
|
||||
connectionId: parentOpts?.connectionId,
|
||||
...(options.queryFile
|
||||
? { queryFile: options.queryFile }
|
||||
: {
|
||||
|
|
|
|||
|
|
@ -65,8 +65,8 @@ describe('formatDoctorReport', () => {
|
|||
expect(output).not.toContain('v22.16.0');
|
||||
expect(output).toContain('Everything ready.');
|
||||
expect(output).toContain('ktx status --json');
|
||||
expect(output).toContain('ktx sl list');
|
||||
expect(output).toContain('ktx wiki list');
|
||||
expect(output).toContain('ktx sl');
|
||||
expect(output).toContain('ktx wiki');
|
||||
expect(output).not.toContain('ktx scan');
|
||||
expect(output).not.toContain('ktx sl ask');
|
||||
});
|
||||
|
|
@ -561,8 +561,8 @@ describe('runKtxDoctor', () => {
|
|||
expect(out).toContain('info: pg_stat_statements.max is 1000');
|
||||
expect(out).not.toContain('Update the Postgres parameter group or config');
|
||||
expect(out).toContain('ktx status --json');
|
||||
expect(out).toContain('ktx sl list');
|
||||
expect(out).toContain('ktx wiki list');
|
||||
expect(out).toContain('ktx sl');
|
||||
expect(out).toContain('ktx wiki');
|
||||
expect(out).not.toContain('ktx scan');
|
||||
expect(out).not.toContain('ktx sl ask');
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
|
|
|
|||
|
|
@ -72,13 +72,13 @@ describe('standalone local warehouse example', () => {
|
|||
it('runs local CLI commands against the copied example project', async () => {
|
||||
const projectDir = await copyExampleProject(tempDir);
|
||||
|
||||
const knowledgeList = await runBuiltCli(['wiki', 'search', 'revenue', '--json', '--project-dir', projectDir]);
|
||||
const knowledgeList = await runBuiltCli(['wiki', 'revenue', '--json', '--project-dir', projectDir]);
|
||||
expect(knowledgeList).toMatchObject({ code: 0, stderr: '' });
|
||||
expect(
|
||||
parseJsonOutput<{ data: { items: Array<{ key: string; summary: string }> } }>(knowledgeList.stdout).data.items,
|
||||
).toContainEqual(expect.objectContaining({ key: 'revenue', summary: 'Paid order value after refunds' }));
|
||||
|
||||
const slList = await runBuiltCli(['sl', 'list', '--json', '--project-dir', projectDir, '--connection-id', 'warehouse']);
|
||||
const slList = await runBuiltCli(['sl', '--json', '--project-dir', projectDir, '--connection-id', 'warehouse']);
|
||||
expect(slList).toMatchObject({ code: 0, stderr: '' });
|
||||
expect(
|
||||
parseJsonOutput<{ data: { items: Array<{ connectionId: string; name: string; columnCount: number }> } }>(
|
||||
|
|
@ -110,7 +110,7 @@ describe('standalone local warehouse example', () => {
|
|||
'fake',
|
||||
]);
|
||||
expect(ingest).toMatchObject({ code: 1, stdout: '' });
|
||||
expect(ingest.stderr).toContain("unknown option '--connection-id'");
|
||||
expect(ingest.stderr).toContain("unknown option '--adapter'");
|
||||
}, 30_000);
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
@ -148,7 +149,7 @@ describe('runKtxCli', () => {
|
|||
const knowledge = vi.fn(async () => 0);
|
||||
|
||||
const listIo = makeIo();
|
||||
await expect(runKtxCli(['--project-dir', tempDir, 'wiki', 'list', '--json'], listIo.io, { knowledge }))
|
||||
await expect(runKtxCli(['--project-dir', tempDir, 'wiki', '--json'], listIo.io, { knowledge }))
|
||||
.resolves.toBe(0);
|
||||
expect(knowledge).toHaveBeenCalledWith(
|
||||
{
|
||||
|
|
@ -162,7 +163,7 @@ describe('runKtxCli', () => {
|
|||
|
||||
const searchIo = makeIo();
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'wiki', 'search', 'revenue', '--limit', '5'], searchIo.io, { knowledge }),
|
||||
runKtxCli(['--project-dir', tempDir, 'wiki', 'revenue', '--limit', '5'], searchIo.io, { knowledge }),
|
||||
).resolves.toBe(0);
|
||||
expect(knowledge).toHaveBeenLastCalledWith(
|
||||
{
|
||||
|
|
@ -178,7 +179,7 @@ describe('runKtxCli', () => {
|
|||
|
||||
const debugSearchIo = makeIo();
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, '--debug', 'wiki', 'search', 'revenue'], debugSearchIo.io, { knowledge }),
|
||||
runKtxCli(['--project-dir', tempDir, '--debug', 'wiki', 'revenue'], debugSearchIo.io, { knowledge }),
|
||||
).resolves.toBe(0);
|
||||
expect(knowledge).toHaveBeenLastCalledWith(
|
||||
{
|
||||
|
|
@ -191,47 +192,57 @@ describe('runKtxCli', () => {
|
|||
},
|
||||
debugSearchIo.io,
|
||||
);
|
||||
|
||||
const multiWordIo = makeIo();
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'wiki', 'revenue', 'policy'], multiWordIo.io, { knowledge }),
|
||||
).resolves.toBe(0);
|
||||
expect(knowledge).toHaveBeenLastCalledWith(
|
||||
{
|
||||
command: 'search',
|
||||
projectDir: tempDir,
|
||||
query: 'revenue policy',
|
||||
userId: 'local',
|
||||
json: false,
|
||||
},
|
||||
multiWordIo.io,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects removed public wiki read and write commands', async () => {
|
||||
it('rejects unknown write-style flags on the flattened wiki and sl commands', async () => {
|
||||
const knowledge = vi.fn(async () => 0);
|
||||
|
||||
for (const argv of [
|
||||
['--project-dir', tempDir, 'wiki', 'read', 'revenue', '--json'],
|
||||
['--project-dir', tempDir, 'wiki', 'write', 'revenue', '--summary', 'Revenue', '--content', 'Revenue.'],
|
||||
]) {
|
||||
const io = makeIo();
|
||||
|
||||
await expect(runKtxCli(argv, io.io, { knowledge })).resolves.toBe(1);
|
||||
|
||||
expect(io.stderr()).toMatch(/unknown command|error:/);
|
||||
}
|
||||
|
||||
expect(knowledge).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects removed public sl read/write commands', async () => {
|
||||
const sl = vi.fn(async () => 0);
|
||||
|
||||
for (const argv of [
|
||||
['--project-dir', tempDir, 'sl', 'read', 'orders', '--connection-id', 'warehouse'],
|
||||
['--project-dir', tempDir, 'sl', 'write', 'orders', '--connection-id', 'warehouse', '--yaml', 'name: orders'],
|
||||
]) {
|
||||
const io = makeIo();
|
||||
await expect(runKtxCli(argv, io.io, { sl })).resolves.toBe(1);
|
||||
expect(io.stderr()).toMatch(/unknown command|error:/);
|
||||
}
|
||||
const wikiIo = makeIo();
|
||||
await expect(
|
||||
runKtxCli(
|
||||
['--project-dir', tempDir, 'wiki', 'revenue', '--summary', 'Revenue', '--content', 'Revenue.'],
|
||||
wikiIo.io,
|
||||
{ knowledge },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
expect(wikiIo.stderr()).toMatch(/unknown option|error:/);
|
||||
expect(knowledge).not.toHaveBeenCalled();
|
||||
|
||||
const slIo = makeIo();
|
||||
await expect(
|
||||
runKtxCli(
|
||||
['--project-dir', tempDir, 'sl', 'orders', '--yaml', 'name: orders'],
|
||||
slIo.io,
|
||||
{ sl },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
expect(slIo.stderr()).toMatch(/unknown option|error:/);
|
||||
expect(sl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes sl search and rejects the old sl list --query flag', async () => {
|
||||
it('routes sl search via the flattened query positional and rejects unknown flags', async () => {
|
||||
const sl = vi.fn(async () => 0);
|
||||
|
||||
const searchIo = makeIo();
|
||||
await expect(
|
||||
runKtxCli(
|
||||
['--project-dir', tempDir, 'sl', 'search', 'revenue', '--connection-id', 'warehouse', '--limit', '5', '--json'],
|
||||
['--project-dir', tempDir, 'sl', 'revenue', '--connection-id', 'warehouse', '--limit', '5', '--json'],
|
||||
searchIo.io,
|
||||
{ sl },
|
||||
),
|
||||
|
|
@ -249,11 +260,26 @@ describe('runKtxCli', () => {
|
|||
searchIo.io,
|
||||
);
|
||||
|
||||
const listIo = makeIo();
|
||||
const bareIo = makeIo();
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'sl', 'list', '--query', 'revenue'], listIo.io, { sl }),
|
||||
runKtxCli(['--project-dir', tempDir, 'sl', '--connection-id', 'warehouse', '--json'], bareIo.io, { sl }),
|
||||
).resolves.toBe(0);
|
||||
expect(sl).toHaveBeenLastCalledWith(
|
||||
{
|
||||
command: 'list',
|
||||
projectDir: tempDir,
|
||||
connectionId: 'warehouse',
|
||||
json: true,
|
||||
output: undefined,
|
||||
},
|
||||
bareIo.io,
|
||||
);
|
||||
|
||||
const unknownIo = makeIo();
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'sl', '--query', 'revenue'], unknownIo.io, { sl }),
|
||||
).resolves.toBe(1);
|
||||
expect(listIo.stderr()).toContain("unknown option '--query'");
|
||||
expect(unknownIo.stderr()).toContain("unknown option '--query'");
|
||||
});
|
||||
|
||||
it('routes runtime management commands with the release runtime version', async () => {
|
||||
|
|
@ -266,17 +292,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 +403,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');
|
||||
|
|
@ -523,7 +549,7 @@ describe('runKtxCli', () => {
|
|||
await initKtxProject({ projectDir });
|
||||
const commands = [
|
||||
['--project-dir', projectDir, 'status', '--json'],
|
||||
['--project-dir', projectDir, 'sl', 'list', '--json'],
|
||||
['--project-dir', projectDir, 'sl', '--json'],
|
||||
];
|
||||
|
||||
for (const argv of commands) {
|
||||
|
|
@ -655,9 +681,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:/);
|
||||
|
|
@ -871,7 +897,8 @@ describe('runKtxCli', () => {
|
|||
expect(testIo.stdout()).toContain('--query-history');
|
||||
expect(testIo.stdout()).toContain('--no-query-history');
|
||||
expect(testIo.stdout()).toContain('--query-history-window-days <days>');
|
||||
expect(testIo.stdout()).toContain('text');
|
||||
expect(testIo.stdout()).toContain('--text');
|
||||
expect(testIo.stdout()).toContain('--file');
|
||||
expect(testIo.stdout()).not.toMatch(/^ status\s/m);
|
||||
expect(testIo.stdout()).not.toMatch(/^ replay\s/m);
|
||||
expect(testIo.stdout()).not.toMatch(/^ run\s/m);
|
||||
|
|
@ -891,7 +918,6 @@ describe('runKtxCli', () => {
|
|||
'--project-dir',
|
||||
tempDir,
|
||||
'ingest',
|
||||
'text',
|
||||
'--text',
|
||||
'Revenue means gross receipts.',
|
||||
'--text',
|
||||
|
|
@ -923,22 +949,45 @@ describe('runKtxCli', () => {
|
|||
expect(testIo.stderr()).toBe('');
|
||||
});
|
||||
|
||||
it('documents text ingest inputs without a manifest option', async () => {
|
||||
it('rejects a positional connection id when --text is supplied', async () => {
|
||||
const textIngest = vi.fn(async () => 0);
|
||||
const publicIngest = vi.fn(async () => 0);
|
||||
const testIo = makeIo();
|
||||
|
||||
await expect(runKtxCli(['ingest', 'text', '--help'], testIo.io, { textIngest })).resolves.toBe(0);
|
||||
await expect(
|
||||
runKtxCli(
|
||||
['--project-dir', tempDir, 'ingest', 'warehouse', '--text', 'hello'],
|
||||
testIo.io,
|
||||
{ textIngest, publicIngest },
|
||||
),
|
||||
).resolves.toBe(1);
|
||||
|
||||
expect(testIo.stdout()).toContain('Usage: ktx ingest text [options] [files...]');
|
||||
expect(testIo.stdout()).toContain('--text <content>');
|
||||
expect(testIo.stdout()).toContain('--connection-id <connectionId>');
|
||||
expect(testIo.stdout()).toContain('--user-id <id>');
|
||||
expect(testIo.stdout()).toContain('--fail-fast');
|
||||
expect(testIo.stdout()).not.toContain('--manifest');
|
||||
expect(textIngest).not.toHaveBeenCalled();
|
||||
expect(publicIngest).not.toHaveBeenCalled();
|
||||
expect(testIo.stderr()).toMatch(/--text\/--file does not accept a positional connection id/);
|
||||
});
|
||||
|
||||
it('rejects old adapter-backed ingest flags at the top level and under dev', async () => {
|
||||
it('treats bare ingest as ingest --all', async () => {
|
||||
const publicIngest = vi.fn().mockResolvedValue(0);
|
||||
const testIo = makeIo();
|
||||
|
||||
await expect(
|
||||
runKtxCli(['--project-dir', tempDir, 'ingest', '--no-input'], testIo.io, { publicIngest }),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(publicIngest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'run',
|
||||
projectDir: tempDir,
|
||||
all: true,
|
||||
}),
|
||||
testIo.io,
|
||||
);
|
||||
const args = publicIngest.mock.calls[0]?.[0] as { targetConnectionId?: string };
|
||||
expect(args.targetConnectionId).toBeUndefined();
|
||||
});
|
||||
|
||||
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 +998,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 +1007,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 +1804,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 +1821,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 +1835,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:/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -78,14 +78,14 @@ describe('printList — plain mode', () => {
|
|||
mode: 'plain',
|
||||
command: 'sl search',
|
||||
emptyMessage: 'No sources matched "foo"',
|
||||
emptyHint: 'Run `ktx sl list` to see available sources.',
|
||||
emptyHint: 'Run `ktx sl` to see available sources.',
|
||||
unit: 'source',
|
||||
io: r.io,
|
||||
});
|
||||
expect(r.out()).toBe('');
|
||||
expect(r.err()).toBe(
|
||||
'No sources matched "foo"\n' +
|
||||
'Run `ktx sl list` to see available sources.\n',
|
||||
'Run `ktx sl` to see available sources.\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -188,13 +188,13 @@ describe('printList — pretty mode', () => {
|
|||
mode: 'pretty',
|
||||
command: 'sl search',
|
||||
emptyMessage: 'No sources matched "foo"',
|
||||
emptyHint: 'Run `ktx sl list` to see available sources.',
|
||||
emptyHint: 'Run `ktx sl` to see available sources.',
|
||||
unit: 'source',
|
||||
io: r.io,
|
||||
});
|
||||
const out = stripAnsi(r.out());
|
||||
expect(out).toContain('No sources matched "foo"');
|
||||
expect(out).toContain('Run `ktx sl list` to see available sources.');
|
||||
expect(out).toContain('Run `ktx sl` to see available sources.');
|
||||
});
|
||||
|
||||
it('singularizes the footer when there is one row', () => {
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ export async function runKtxKnowledge(
|
|||
}
|
||||
const mode = resolveOutputMode({ explicit: args.output, json: args.json, io });
|
||||
let emptyMessage = `No local wiki pages matched "${args.query}"`;
|
||||
let emptyHint = 'Run `ktx wiki list` to inspect available pages.';
|
||||
let emptyHint = 'Run `ktx wiki` to inspect available pages.';
|
||||
if (results.length === 0 && mode !== 'json') {
|
||||
const pages = await listLocalKnowledgePages(project, { userId: args.userId });
|
||||
if (pages.length === 0) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -198,8 +198,8 @@ describe('MemoryFlowTuiApp', () => {
|
|||
expect(frame).toContain('order lifecycle');
|
||||
expect(frame).toContain('customer metrics');
|
||||
expect(frame).toContain('KTX finished ingesting your data');
|
||||
expect(frame).toContain('ktx sl list');
|
||||
expect(frame).toContain('ktx wiki list');
|
||||
expect(frame).toContain('ktx sl');
|
||||
expect(frame).toContain('ktx wiki');
|
||||
expect(frame).not.toContain('ktx serve --mcp stdio --user-id local');
|
||||
expect(frame).not.toContain(['ktx', 'ask'].join(' '));
|
||||
expect(frame).not.toContain(['ktx', 'mcp'].join(' '));
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ describe('KTX demo next steps', () => {
|
|||
it('uses supported context-build commands before agent usage', () => {
|
||||
expect(KTX_CONTEXT_BUILD_COMMANDS).toEqual([
|
||||
{
|
||||
command: 'ktx ingest --all',
|
||||
description: 'Build or refresh agent-ready context from configured connections',
|
||||
command: 'ktx ingest',
|
||||
description: 'Build or refresh agent-ready context from all configured connections',
|
||||
},
|
||||
{
|
||||
command: 'ktx status',
|
||||
|
|
@ -27,11 +27,11 @@ describe('KTX demo next steps', () => {
|
|||
description: 'Verify project setup and context readiness',
|
||||
},
|
||||
{
|
||||
command: 'ktx sl list',
|
||||
command: 'ktx sl',
|
||||
description: 'Inspect generated semantic-layer sources',
|
||||
},
|
||||
{
|
||||
command: 'ktx wiki list',
|
||||
command: 'ktx wiki',
|
||||
description: 'Inspect generated wiki pages',
|
||||
},
|
||||
]);
|
||||
|
|
@ -67,7 +67,7 @@ describe('KTX demo next steps', () => {
|
|||
|
||||
expect(rendered).toContain('Build KTX context next.');
|
||||
expect(rendered).toContain('Run ingest to build database schema context before context-source ingest.');
|
||||
expect(rendered).toContain('ktx ingest --all');
|
||||
expect(rendered).toContain('ktx ingest');
|
||||
expect(rendered).not.toContain('resume');
|
||||
expect(rendered).not.toContain('scan');
|
||||
expect(rendered).toContain('ktx status');
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export const KTX_CONTEXT_BUILD_COMMANDS = [
|
||||
{
|
||||
command: 'ktx ingest --all',
|
||||
description: 'Build or refresh agent-ready context from configured connections',
|
||||
command: 'ktx ingest',
|
||||
description: 'Build or refresh agent-ready context from all configured connections',
|
||||
},
|
||||
{
|
||||
command: 'ktx status',
|
||||
|
|
@ -15,11 +15,11 @@ export const KTX_NEXT_STEP_DIRECT_COMMANDS = [
|
|||
description: 'Verify project setup and context readiness',
|
||||
},
|
||||
{
|
||||
command: 'ktx sl list',
|
||||
command: 'ktx sl',
|
||||
description: 'Inspect generated semantic-layer sources',
|
||||
},
|
||||
{
|
||||
command: 'ktx wiki list',
|
||||
command: 'ktx wiki',
|
||||
description: 'Inspect generated wiki pages',
|
||||
},
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -124,12 +124,15 @@ describe('buildPublicIngestPlan', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('rejects bare non-interactive ingest until the interactive confirmation slice exists', () => {
|
||||
const project = projectWithConnections({ warehouse: { driver: 'postgres' } });
|
||||
it('treats a bare invocation (no connection id, no --all) as all configured connections', () => {
|
||||
const project = projectWithConnections({
|
||||
warehouse: { driver: 'postgres' },
|
||||
docs: { driver: 'notion' },
|
||||
});
|
||||
|
||||
expect(() => buildPublicIngestPlan(project, { projectDir: '/tmp/project', all: false })).toThrow(
|
||||
'Context build requires a connection id or all targets',
|
||||
);
|
||||
const plan = buildPublicIngestPlan(project, { projectDir: '/tmp/project', all: false });
|
||||
|
||||
expect(plan.targets.map((target) => target.connectionId).sort()).toEqual(['docs', 'warehouse']);
|
||||
});
|
||||
|
||||
it('resolves database depth from flags, stored context, and defaults', () => {
|
||||
|
|
|
|||
|
|
@ -469,14 +469,11 @@ export function buildPublicIngestPlan(
|
|||
scanMode?: Extract<KtxScanArgs, { command: 'run' }>['mode'];
|
||||
},
|
||||
): KtxPublicIngestPlan {
|
||||
if (!args.all && !args.targetConnectionId) {
|
||||
throw new Error('Context build requires a connection id or all targets');
|
||||
}
|
||||
|
||||
const allConnections = args.all || !args.targetConnectionId;
|
||||
const entries = Object.entries(project.config.connections).sort(([a], [b]) => a.localeCompare(b));
|
||||
const selected = args.all ? entries : entries.filter(([connectionId]) => connectionId === args.targetConnectionId);
|
||||
const selected = allConnections ? entries : entries.filter(([connectionId]) => connectionId === args.targetConnectionId);
|
||||
|
||||
if (!args.all && selected.length === 0) {
|
||||
if (!allConnections && selected.length === 0) {
|
||||
throw new Error(`Connection "${args.targetConnectionId}" is not configured in ktx.yaml`);
|
||||
}
|
||||
if (selected.length === 0) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ describe('setup agents', () => {
|
|||
expect(skill).toContain(`--project-dir ${tempDir}`);
|
||||
expect(skill).toContain('must not print secrets');
|
||||
expect(skill).toContain('status --json');
|
||||
expect(skill).toContain('sl list --json');
|
||||
expect(skill).toContain('sl --json');
|
||||
expect(skill).toContain('sl query');
|
||||
expect(skill).toContain('--format json');
|
||||
expect(skill).not.toContain('sl query --json');
|
||||
|
|
|
|||
|
|
@ -569,8 +569,8 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
|
|||
'Available commands:',
|
||||
'',
|
||||
`- \`${ktxCommandLine(input.launcher, ['status', ...jsonProjectDirArgs])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, ['sl', 'list', ...jsonProjectDirArgs])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, ['sl', 'search', '<text>', ...jsonProjectDirArgs, '--connection-id', '<id>'])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, ['sl', ...jsonProjectDirArgs])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, ['sl', '<text>', ...jsonProjectDirArgs, '--connection-id', '<id>'])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, [
|
||||
'sl',
|
||||
'query',
|
||||
|
|
@ -585,7 +585,7 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
|
|||
'--max-rows',
|
||||
'100',
|
||||
])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, ['wiki', 'search', '<query>', ...jsonProjectDirArgs, '--limit', '10'])}\``,
|
||||
`- \`${ktxCommandLine(input.launcher, ['wiki', '<query>', ...jsonProjectDirArgs, '--limit', '10'])}\``,
|
||||
'',
|
||||
'Use semantic-layer queries before direct database access. Do not print secrets or credential references.',
|
||||
'',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
|
|||
await printSlSources({
|
||||
rows: sources,
|
||||
emptyMessage: `No semantic-layer sources matched "${args.query}" in ${project.projectDir}`,
|
||||
emptyHint: 'Run `ktx sl list` to inspect available sources.',
|
||||
emptyHint: 'Run `ktx sl` to inspect available sources.',
|
||||
command: 'sl search',
|
||||
output: args.output,
|
||||
json: args.json,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
@ -153,7 +158,7 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
'fake',
|
||||
]);
|
||||
expect(run).toMatchObject({ code: 1, stdout: '' });
|
||||
expect(run.stderr).toContain("unknown option '--connection-id'");
|
||||
expect(run.stderr).toContain("unknown option '--adapter'");
|
||||
});
|
||||
|
||||
it('rejects the removed agent command through the built binary', async () => {
|
||||
|
|
@ -280,7 +285,7 @@ describe('standalone built ktx CLI smoke', () => {
|
|||
|
||||
expect(add.code).toBe(1);
|
||||
expect(add.stdout).toBe('');
|
||||
expect(add.stderr).toContain("unknown command 'add'");
|
||||
expect(add.stderr).toMatch(/unknown (command|option)|too many arguments/);
|
||||
|
||||
const yaml = await readFile(join(projectDir, 'ktx.yaml'), 'utf-8');
|
||||
expect(yaml).not.toContain('driver: notion');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue