feat(cli): clean up wiki and sl commands

This commit is contained in:
Andrey Avtomonov 2026-05-13 15:32:00 +02:00
parent e1e9c4af91
commit 67b587f5d0
18 changed files with 311 additions and 585 deletions

View file

@ -3,19 +3,6 @@ import { z } from 'zod';
const projectDirSchema = z.string().min(1);
const stringArraySchema = z.array(z.string());
export const wikiWriteCommandSchema = z.object({
command: z.literal('write'),
projectDir: projectDirSchema,
key: z.string().min(1),
scope: z.enum(['GLOBAL', 'USER']),
userId: z.string().min(1),
summary: z.string().min(1),
content: z.string().min(1),
tags: stringArraySchema,
refs: stringArraySchema,
slRefs: stringArraySchema,
});
const orderBySchema = z.union([
z.string().min(1),
z.object({

View file

@ -1,11 +1,9 @@
import { type Command, Option } from '@commander-js/extra-typings';
import { type Command } from '@commander-js/extra-typings';
import {
collectOption,
type KtxCliCommandContext,
parsePositiveIntegerOption,
resolveCommandProjectDir,
} from '../cli-program.js';
import { wikiWriteCommandSchema } from '../command-schemas.js';
import type { KtxKnowledgeArgs } from '../knowledge.js';
import { profileMark } from '../startup-profile.js';
@ -19,7 +17,7 @@ async function runKnowledgeArgs(context: KtxCliCommandContext, args: KtxKnowledg
export function registerWikiCommands(program: Command, context: KtxCliCommandContext): void {
const wiki = program
.command('wiki')
.description('List, read, search, or write local wiki pages')
.description('List or search local wiki pages')
.showHelpAfterError()
.addHelpText(
'after',
@ -40,22 +38,6 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
});
});
wiki
.command('read')
.description('Read one local wiki page')
.argument('<key>', 'Wiki page key')
.option('--json', 'Print JSON output', false)
.option('--user-id <id>', 'Local user id', 'local')
.action(async (key: string, options: { userId: string; json?: boolean }, command) => {
await runKnowledgeArgs(context, {
command: 'read',
projectDir: resolveCommandProjectDir(command),
key,
userId: options.userId,
json: options.json,
});
});
wiki
.command('search')
.description('Search local wiki pages')
@ -73,31 +55,4 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
...(options.limit !== undefined ? { limit: options.limit } : {}),
});
});
wiki
.command('write')
.description('Write one local wiki page')
.argument('<key>', 'Wiki page key')
.option('--user-id <id>', 'Local user id', 'local')
.addOption(new Option('--scope <scope>', 'global or user').choices(['global', 'user']).default('global'))
.requiredOption('--summary <summary>', 'Wiki summary')
.requiredOption('--content <content>', 'Wiki content')
.option('--tag <tag>', 'Wiki tag; repeatable', collectOption, [])
.option('--ref <ref>', 'Wiki ref; repeatable', collectOption, [])
.option('--sl-ref <ref>', 'Semantic-layer ref; repeatable', collectOption, [])
.action(async (key: string, options, command) => {
const args = wikiWriteCommandSchema.parse({
command: 'write',
projectDir: resolveCommandProjectDir(command),
key,
scope: options.scope === 'user' ? 'USER' : 'GLOBAL',
userId: options.userId,
summary: options.summary,
content: options.content,
tags: options.tag,
refs: options.ref,
slRefs: options.slRef,
});
await runKnowledgeArgs(context, args);
});
}

View file

@ -41,7 +41,7 @@ async function runSlArgs(context: KtxCliCommandContext, args: KtxSlArgs): Promis
export function registerSlCommands(program: Command, context: KtxCliCommandContext, commandName = 'sl'): void {
const sl = program
.command(commandName)
.description('List, read, validate, query, or write local semantic-layer sources')
.description('List, search, validate, or query local semantic-layer sources')
.showHelpAfterError()
.addHelpText(
'after',
@ -51,7 +51,31 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
sl.command('list')
.description('List semantic-layer sources')
.option('--connection-id <id>', 'KTX connection id')
.option('--query <text>', 'Search source names and descriptions')
.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',
@ -62,35 +86,22 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
.option('--json', 'Shortcut for --output=json (overrides --output)', false)
.action(
async (
options: { connectionId?: string; query?: string; output?: 'pretty' | 'plain' | 'json'; json?: boolean },
query: string,
options: { connectionId?: string; limit?: number; output?: 'pretty' | 'plain' | 'json'; json?: boolean },
command,
) => {
await runSlArgs(context, {
command: 'list',
projectDir: resolveCommandProjectDir(command),
connectionId: options.connectionId,
query: options.query,
output: options.output,
json: options.json,
});
await runSlArgs(context, {
command: 'search',
projectDir: resolveCommandProjectDir(command),
connectionId: options.connectionId,
query,
...(options.limit !== undefined ? { limit: options.limit } : {}),
output: options.output,
json: options.json,
});
},
);
sl.command('read')
.description('Read a semantic-layer source')
.argument('<sourceName>', 'Semantic-layer source name')
.requiredOption('--connection-id <id>', 'KTX connection id')
.option('--json', 'Print JSON output', false)
.action(async (sourceName: string, options: { connectionId: string; json?: boolean }, command) => {
await runSlArgs(context, {
command: 'read',
projectDir: resolveCommandProjectDir(command),
connectionId: options.connectionId,
sourceName,
json: options.json,
});
});
sl.command('validate')
.description('Validate a semantic-layer source')
.argument('<sourceName>', 'Semantic-layer source name')
@ -104,21 +115,6 @@ export function registerSlCommands(program: Command, context: KtxCliCommandConte
});
});
sl.command('write')
.description('Write a semantic-layer source')
.argument('<sourceName>', 'Semantic-layer source name')
.requiredOption('--connection-id <id>', 'KTX connection id')
.requiredOption('--yaml <yaml>', 'Semantic-layer source YAML')
.action(async (sourceName: string, options: { connectionId: string; yaml: string }, command) => {
await runSlArgs(context, {
command: 'write',
projectDir: resolveCommandProjectDir(command),
connectionId: options.connectionId,
sourceName,
yaml: options.yaml,
});
});
sl.command('query')
.description('Compile or execute a semantic-layer query')
.option('--connection-id <id>', 'KTX connection id')

View file

@ -79,12 +79,6 @@ describe('standalone local warehouse example', () => {
parseJsonOutput<{ data: { items: Array<{ key: string; summary: string }> } }>(knowledgeList.stdout).data.items,
).toContainEqual(expect.objectContaining({ key: 'revenue', summary: 'Paid order value after refunds' }));
const knowledgeRead = await runBuiltCli(['wiki', 'read', 'revenue', '--json', '--project-dir', projectDir]);
expect(knowledgeRead).toMatchObject({ code: 0, stderr: '' });
expect(parseJsonOutput<{ data: { content: string } }>(knowledgeRead.stdout).data.content).toContain(
'Revenue is paid order amount after refund adjustments.',
);
const slList = await runBuiltCli(['sl', 'list', '--json', '--project-dir', projectDir, '--connection-id', 'warehouse']);
expect(slList).toMatchObject({ code: 0, stderr: '' });
expect(
@ -93,9 +87,9 @@ describe('standalone local warehouse example', () => {
).data.items,
).toContainEqual(expect.objectContaining({ connectionId: 'warehouse', name: 'orders', columnCount: 3 }));
const slRead = await runBuiltCli([
const slSearch = await runBuiltCli([
'sl',
'read',
'search',
'orders',
'--json',
'--connection-id',
@ -103,8 +97,10 @@ describe('standalone local warehouse example', () => {
'--project-dir',
projectDir,
]);
expect(slRead).toMatchObject({ code: 0, stderr: '' });
expect(parseJsonOutput<{ data: { yaml: string } }>(slRead.stdout).data.yaml).toContain('name: orders');
expect(slSearch).toMatchObject({ code: 0, stderr: '' });
expect(
parseJsonOutput<{ data: { items: Array<{ connectionId: string; name: string }> } }>(slSearch.stdout).data.items,
).toContainEqual(expect.objectContaining({ connectionId: 'warehouse', name: 'orders' }));
const ingest = await runBuiltCli([
'ingest',

View file

@ -139,6 +139,56 @@ describe('runKtxCli', () => {
expect(testIo.stderr()).toBe('');
});
it('rejects removed public wiki and sl read/write commands', async () => {
const sl = vi.fn(async () => 0);
const knowledge = vi.fn(async () => 0);
for (const argv of [
['--project-dir', tempDir, 'wiki', 'read', 'revenue'],
['--project-dir', tempDir, 'wiki', 'write', 'revenue', '--summary', 'Revenue', '--content', 'Revenue.'],
['--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, { knowledge, sl })).resolves.toBe(1);
expect(io.stderr()).toMatch(/unknown command|error:/);
}
expect(knowledge).not.toHaveBeenCalled();
expect(sl).not.toHaveBeenCalled();
});
it('routes sl search and rejects the old sl list --query flag', 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'],
searchIo.io,
{ sl },
),
).resolves.toBe(0);
expect(sl).toHaveBeenCalledWith(
{
command: 'search',
projectDir: tempDir,
connectionId: 'warehouse',
query: 'revenue',
limit: 5,
json: true,
output: undefined,
},
searchIo.io,
);
const listIo = makeIo();
await expect(
runKtxCli(['--project-dir', tempDir, 'sl', 'list', '--query', 'revenue'], listIo.io, { sl }),
).resolves.toBe(1);
expect(listIo.stderr()).toContain("unknown option '--query'");
});
it('routes runtime management commands with the CLI package version', async () => {
const runtime = vi.fn(async () => 0);
const installIo = makeIo();

View file

@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { initKtxProject } from '@ktx/context/project';
import type { KtxEmbeddingPort } from '@ktx/context';
import { type LocalKnowledgeScope, writeLocalKnowledgePage } from '@ktx/context/wiki';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { runKtxKnowledge } from './knowledge.js';
@ -40,6 +41,29 @@ class FakeEmbeddingPort implements KtxEmbeddingPort {
}
}
async function seedKnowledgePage(input: {
projectDir: string;
key: string;
summary: string;
content: string;
scope?: LocalKnowledgeScope;
tags?: string[];
refs?: string[];
slRefs?: string[];
}): Promise<void> {
const project = await initKtxProject({ projectDir: input.projectDir, projectName: 'warehouse' });
await writeLocalKnowledgePage(project, {
key: input.key,
scope: input.scope ?? 'GLOBAL',
userId: 'local',
summary: input.summary,
content: input.content,
tags: input.tags ?? [],
refs: input.refs ?? [],
slRefs: input.slRefs ?? [],
});
}
describe('runKtxKnowledge', () => {
let tempDir: string;
@ -51,36 +75,16 @@ describe('runKtxKnowledge', () => {
await rm(tempDir, { recursive: true, force: true });
});
it('writes, reads, lists, and searches knowledge pages', async () => {
it('lists and searches knowledge pages', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
const writeIo = makeIo();
await expect(
runKtxKnowledge(
{
command: 'write',
projectDir,
key: 'metrics-revenue',
scope: 'GLOBAL',
userId: 'local',
summary: 'Revenue',
content: 'Revenue is paid order value.',
tags: ['finance'],
refs: [],
slRefs: ['orders'],
},
writeIo.io,
),
).resolves.toBe(0);
expect(writeIo.stdout()).toContain('Wrote knowledge/global/metrics-revenue.md');
const readIo = makeIo();
await expect(
runKtxKnowledge({ command: 'read', projectDir, key: 'metrics-revenue', userId: 'local' }, readIo.io),
).resolves.toBe(0);
expect(readIo.stdout()).toContain('# metrics-revenue');
expect(readIo.stdout()).toContain('Revenue is paid order value.');
await seedKnowledgePage({
projectDir,
key: 'metrics-revenue',
summary: 'Revenue',
content: 'Revenue is paid order value.',
tags: ['finance'],
slRefs: ['orders'],
});
const listIo = makeIo();
await expect(runKtxKnowledge({ command: 'list', projectDir, userId: 'local' }, listIo.io)).resolves.toBe(0);
@ -93,27 +97,16 @@ describe('runKtxKnowledge', () => {
expect(searchIo.stdout()).toContain('metrics-revenue');
});
it('prints wiki list, search, and read as public JSON envelopes', async () => {
it('prints wiki list and search as public JSON envelopes', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await expect(
runKtxKnowledge(
{
command: 'write',
projectDir,
key: 'metrics-revenue',
scope: 'GLOBAL',
userId: 'local',
summary: 'Revenue',
content: 'Revenue is paid order value.',
tags: ['finance'],
refs: [],
slRefs: ['orders'],
},
makeIo().io,
),
).resolves.toBe(0);
await seedKnowledgePage({
projectDir,
key: 'metrics-revenue',
summary: 'Revenue',
content: 'Revenue is paid order value.',
tags: ['finance'],
slRefs: ['orders'],
});
const listIo = makeIo();
await expect(runKtxKnowledge({ command: 'list', projectDir, userId: 'local', json: true }, listIo.io)).resolves.toBe(
@ -137,48 +130,6 @@ describe('runKtxKnowledge', () => {
data: { items: [expect.objectContaining({ key: 'metrics-revenue', summary: 'Revenue' })] },
meta: { command: 'wiki search' },
});
const readIo = makeIo();
await expect(
runKtxKnowledge({ command: 'read', projectDir, key: 'metrics-revenue', userId: 'local', json: true }, readIo.io),
).resolves.toBe(0);
expect(JSON.parse(readIo.stdout())).toMatchObject({
kind: 'wiki.page',
data: {
key: 'metrics-revenue',
summary: 'Revenue',
content: 'Revenue is paid order value.',
},
});
});
it('rejects slash-delimited write keys with a flat-key suggestion', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
const writeIo = makeIo();
await expect(
runKtxKnowledge(
{
command: 'write',
projectDir,
key: 'orbit/company-overview',
scope: 'GLOBAL',
userId: 'local',
summary: 'Orbit',
content: 'Orbit overview.',
tags: [],
refs: [],
slRefs: [],
},
writeIo.io,
),
).resolves.toBe(1);
expect(writeIo.stderr()).toContain(
'Invalid wiki key "orbit/company-overview". Wiki keys must be flat; use "orbit-company-overview".',
);
expect(writeIo.stdout()).toBe('');
});
it('explains empty search results for a project without wiki pages', async () => {
@ -192,30 +143,19 @@ describe('runKtxKnowledge', () => {
expect(searchIo.stdout()).toBe('');
expect(searchIo.stderr()).toContain('No local wiki pages found');
expect(searchIo.stderr()).toContain('ktx wiki write');
expect(searchIo.stderr()).toContain('Run ingest');
expect(searchIo.stderr()).not.toContain('ktx wiki write');
});
it('uses configured embeddings for semantic wiki search', async () => {
const projectDir = join(tempDir, 'semantic-project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await expect(
runKtxKnowledge(
{
command: 'write',
projectDir,
key: 'active-contract-arr-open-tickets',
scope: 'GLOBAL',
userId: 'local',
summary: 'Active Contract ARR Ranked by Open Support Ticket Count',
content: 'Accounts ranked by annual recurring contract value and support ticket load.',
tags: ['historic-sql'],
refs: [],
slRefs: [],
},
makeIo().io,
),
).resolves.toBe(0);
await seedKnowledgePage({
projectDir,
key: 'active-contract-arr-open-tickets',
summary: 'Active Contract ARR Ranked by Open Support Ticket Count',
content: 'Accounts ranked by annual recurring contract value and support ticket load.',
tags: ['historic-sql'],
});
const searchIo = makeIo();
await expect(

View file

@ -4,31 +4,12 @@ import {
type KtxEmbeddingPort,
} from '@ktx/context';
import { loadKtxProject } from '@ktx/context/project';
import {
type LocalKnowledgeScope,
listLocalKnowledgePages,
readLocalKnowledgePage,
searchLocalKnowledgePages,
writeLocalKnowledgePage,
} from '@ktx/context/wiki';
import { listLocalKnowledgePages, searchLocalKnowledgePages } from '@ktx/context/wiki';
import { writeJsonResult } from './io/print-list.js';
export type KtxKnowledgeArgs =
| { command: 'list'; projectDir: string; userId: string; json?: boolean }
| { command: 'read'; projectDir: string; key: string; userId: string; json?: boolean }
| { command: 'search'; projectDir: string; query: string; userId: string; json?: boolean; limit?: number }
| {
command: 'write';
projectDir: string;
key: string;
scope: LocalKnowledgeScope;
userId: string;
summary: string;
content: string;
tags: string[];
refs: string[];
slRefs: string[];
};
| { command: 'search'; projectDir: string; query: string; userId: string; json?: boolean; limit?: number };
interface KtxKnowledgeIo {
stdout: { write(chunk: string): void };
@ -75,25 +56,6 @@ export async function runKtxKnowledge(
}
return 0;
}
if (args.command === 'read') {
const page = await readLocalKnowledgePage(project, { key: args.key, userId: args.userId });
if (!page) {
throw new Error(`Knowledge page "${args.key}" was not found`);
}
if (args.json) {
writeJsonResult(io, {
kind: 'wiki.page',
data: page,
meta: { command: 'wiki read' },
});
return 0;
}
io.stdout.write(`# ${page.key}\n\n`);
io.stdout.write(`Scope: ${page.scope}\n`);
io.stdout.write(`Summary: ${page.summary}\n\n`);
io.stdout.write(`${page.content}\n`);
return 0;
}
if (args.command === 'search') {
const results = await searchLocalKnowledgePages(project, {
query: args.query,
@ -113,7 +75,7 @@ export async function runKtxKnowledge(
const pages = await listLocalKnowledgePages(project, { userId: args.userId });
if (pages.length === 0) {
io.stderr.write(
`No local wiki pages found in ${project.projectDir}. Create one with \`ktx wiki write <key> --summary <summary> --content <content>\` or run ingest.\n`,
`No local wiki pages found in ${project.projectDir}. Run ingest to capture wiki context, then retry the search.\n`,
);
} else {
io.stderr.write(
@ -127,19 +89,8 @@ export async function runKtxKnowledge(
}
return 0;
}
const write = await writeLocalKnowledgePage(project, {
key: args.key,
scope: args.scope,
userId: args.userId,
summary: args.summary,
content: args.content,
tags: args.tags,
refs: args.refs,
slRefs: args.slRefs,
});
io.stdout.write(`Wrote ${write.path}\n`);
return 0;
const _exhaustive: never = args;
throw new Error(`Unsupported wiki command: ${JSON.stringify(_exhaustive)}`);
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;

View file

@ -138,8 +138,7 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
'',
`- \`${ktxCommandLine(input.launcher, ['status', ...projectDirArgs])}\``,
`- \`${ktxCommandLine(input.launcher, ['sl', 'list', ...projectDirArgs])}\``,
`- \`${ktxCommandLine(input.launcher, ['sl', 'list', ...projectDirArgs, '--query', '<text>'])}\``,
`- \`${ktxCommandLine(input.launcher, ['sl', 'read', '<sourceName>', ...projectDirArgs, '--connection-id', '<id>'])}\``,
`- \`${ktxCommandLine(input.launcher, ['sl', 'search', '<text>', ...projectDirArgs, '--connection-id', '<id>'])}\``,
`- \`${ktxCommandLine(input.launcher, [
'sl',
'query',
@ -153,7 +152,6 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
'100',
])}\``,
`- \`${ktxCommandLine(input.launcher, ['wiki', 'search', '<query>', ...projectDirArgs, '--limit', '10'])}\``,
`- \`${ktxCommandLine(input.launcher, ['wiki', 'read', '<pageId>', ...projectDirArgs])}\``,
'',
'Use semantic-layer queries before direct database access. Do not print secrets or credential references.',
'',

View file

@ -38,6 +38,22 @@ function makeIo() {
};
}
async function seedSlSource(input: {
projectDir: string;
connectionId?: string;
sourceName?: string;
yaml?: string;
}): Promise<void> {
const project = await initKtxProject({ projectDir: input.projectDir, projectName: 'warehouse' });
await project.fileStore.writeFile(
`semantic-layer/${input.connectionId ?? 'warehouse'}/${input.sourceName ?? 'orders'}.yaml`,
input.yaml ?? ORDERS_YAML,
'ktx',
'ktx@example.com',
'Add semantic-layer source',
);
}
describe('runKtxSl', () => {
let tempDir: string;
@ -49,24 +65,9 @@ describe('runKtxSl', () => {
await rm(tempDir, { recursive: true, force: true });
});
it('writes, validates, reads, and lists semantic-layer sources', async () => {
it('validates, lists, and searches semantic-layer sources', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
const writeIo = makeIo();
await expect(
runKtxSl(
{
command: 'write',
projectDir,
connectionId: 'warehouse',
sourceName: 'orders',
yaml: ORDERS_YAML,
},
writeIo.io,
),
).resolves.toBe(0);
expect(writeIo.stdout()).toContain('Wrote semantic-layer/warehouse/orders.yaml');
await seedSlSource({ projectDir });
const validateIo = makeIo();
await expect(
@ -74,62 +75,49 @@ describe('runKtxSl', () => {
).resolves.toBe(0);
expect(validateIo.stdout()).toContain('Valid semantic-layer source: warehouse/orders');
const readIo = makeIo();
await expect(runKtxSl({ command: 'read', projectDir, connectionId: 'warehouse', sourceName: 'orders' }, readIo.io))
.resolves.toBe(0);
expect(readIo.stdout()).toContain('name: orders');
const listIo = makeIo();
await expect(runKtxSl({ command: 'list', projectDir, connectionId: 'warehouse' }, listIo.io)).resolves.toBe(0);
expect(listIo.stdout()).toContain('warehouse\torders\tcolumns=1\tmeasures=0\tjoins=0');
const searchIo = makeIo();
await expect(
runKtxSl({ command: 'search', projectDir, connectionId: 'warehouse', query: 'order', json: true }, searchIo.io),
).resolves.toBe(0);
expect(JSON.parse(searchIo.stdout())).toMatchObject({
kind: 'list',
data: {
items: [
expect.objectContaining({
connectionId: 'warehouse',
name: 'orders',
score: expect.any(Number),
}),
],
},
meta: { command: 'sl search' },
});
});
it('prints semantic-layer reads and searched lists as public JSON envelopes', async () => {
it('prints semantic-layer list and search as public JSON envelopes', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
await expect(
runKtxSl(
{
command: 'write',
projectDir,
connectionId: 'warehouse',
sourceName: 'orders',
yaml: [
'name: orders',
'table: public.orders',
'description: Paid order facts',
'grain: [order_id]',
'columns:',
' - name: order_id',
' type: string',
'',
].join('\n'),
},
makeIo().io,
),
).resolves.toBe(0);
const readIo = makeIo();
await expect(
runKtxSl(
{ command: 'read', projectDir, connectionId: 'warehouse', sourceName: 'orders', json: true },
readIo.io,
),
).resolves.toBe(0);
expect(JSON.parse(readIo.stdout())).toMatchObject({
kind: 'sl.source',
data: {
connectionId: 'warehouse',
name: 'orders',
yaml: expect.stringContaining('name: orders'),
},
await seedSlSource({
projectDir,
yaml: [
'name: orders',
'table: public.orders',
'description: Paid order facts',
'grain: [order_id]',
'columns:',
' - name: order_id',
' type: string',
'',
].join('\n'),
});
const listIo = makeIo();
await expect(
runKtxSl(
{ command: 'list', projectDir, connectionId: 'warehouse', query: 'paid', json: true },
{ command: 'search', projectDir, connectionId: 'warehouse', query: 'paid', json: true },
listIo.io,
),
).resolves.toBe(0);
@ -145,7 +133,7 @@ describe('runKtxSl', () => {
}),
],
},
meta: { command: 'sl list' },
meta: { command: 'sl search' },
});
});
@ -566,13 +554,7 @@ joins: []
it('emits sl list as a JSON envelope when output=json', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
const writeIo = makeIo();
await runKtxSl(
{ command: 'write', projectDir, connectionId: 'warehouse', sourceName: 'orders', yaml: ORDERS_YAML },
writeIo.io,
);
await seedSlSource({ projectDir });
const listIo = makeIo();
const code = await runKtxSl(
@ -604,13 +586,7 @@ joins: []
it('emits sl list with grouping and Clack-style framing when output=pretty', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir, projectName: 'warehouse' });
const writeIo = makeIo();
await runKtxSl(
{ command: 'write', projectDir, connectionId: 'warehouse', sourceName: 'orders', yaml: ORDERS_YAML },
writeIo.io,
);
await seedSlSource({ projectDir });
const listIo = makeIo();
const code = await runKtxSl(

View file

@ -13,10 +13,10 @@ import {
readLocalSlSource,
searchLocalSlSources,
validateLocalSlSource,
writeLocalSlSource,
type LocalSlSourceSearchResult,
type LocalSlSourceSummary,
type SemanticLayerQueryInput,
} from '@ktx/context/sl';
import { writeJsonResult } from './io/print-list.js';
import {
createManagedPythonSemanticLayerComputePort,
type KtxManagedPythonInstallPolicy,
@ -28,10 +28,17 @@ profileMark('module:sl');
type SlQueryFormat = 'json' | 'sql';
export type KtxSlArgs =
| { command: 'list'; projectDir: string; connectionId?: string; query?: string; output?: string; json?: boolean }
| { command: 'read'; projectDir: string; connectionId: string; sourceName: string; json?: boolean }
| { command: 'list'; projectDir: string; connectionId?: string; output?: string; json?: boolean }
| {
command: 'search';
projectDir: string;
connectionId?: string;
query: string;
limit?: number;
output?: string;
json?: boolean;
}
| { command: 'validate'; projectDir: string; connectionId: string; sourceName: string }
| { command: 'write'; projectDir: string; connectionId: string; sourceName: string; yaml: string }
| {
command: 'query';
projectDir: string;
@ -73,6 +80,35 @@ function slSearchEmbeddingService(project: KtxLocalProject, deps: KtxSlDeps): Kt
return provider ? new KtxIngestEmbeddingPortAdapter(provider) : null;
}
async function printSlSources(input: {
rows: ReadonlyArray<LocalSlSourceSummary | LocalSlSourceSearchResult>;
command: 'sl list' | 'sl search';
output?: string;
json?: boolean;
io: KtxSlIo;
emptyMessage: string;
}): Promise<void> {
const { resolveOutputMode } = await import('./io/mode.js');
const { printList } = await import('./io/print-list.js');
const mode = resolveOutputMode({ explicit: input.output, json: input.json, io: input.io });
printList({
rows: input.rows,
columns: [
{ key: 'connectionId', label: 'CONNECTION', plain: '' },
{ key: 'name', label: 'NAME', plain: '' },
{ key: 'columnCount', label: 'COLS', plain: 'columns=', dim: true },
{ key: 'measureCount', label: 'MEASURES', plain: 'measures=', dim: true },
{ key: 'joinCount', label: 'JOINS', plain: 'joins=', dim: true },
{ key: 'description', label: 'DESCRIPTION', plain: false, optional: true, dim: true },
],
groupBy: 'connectionId',
emptyMessage: input.emptyMessage,
command: input.command,
mode,
io: input.io,
});
}
async function readSlQueryFile(path: string): Promise<SemanticLayerQueryInput> {
const parsed = JSON.parse(await readFile(path, 'utf-8')) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
@ -85,51 +121,32 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
try {
const project = await (deps.loadProject ?? loadKtxProject)({ projectDir: args.projectDir });
if (args.command === 'list') {
const sources = args.query
? await searchLocalSlSources(project, {
connectionId: args.connectionId,
query: args.query,
embeddingService: slSearchEmbeddingService(project, deps),
})
: await listLocalSlSources(project, { connectionId: args.connectionId });
const { resolveOutputMode } = await import('./io/mode.js');
const { printList } = await import('./io/print-list.js');
const mode = resolveOutputMode({ explicit: args.output, json: args.json, io });
printList({
const sources = await listLocalSlSources(project, { connectionId: args.connectionId });
await printSlSources({
rows: sources,
columns: [
{ key: 'connectionId', label: 'CONNECTION', plain: '' },
{ key: 'name', label: 'NAME', plain: '' },
{ key: 'columnCount', label: 'COLS', plain: 'columns=', dim: true },
{ key: 'measureCount', label: 'MEASURES', plain: 'measures=', dim: true },
{ key: 'joinCount', label: 'JOINS', plain: 'joins=', dim: true },
{ key: 'description', label: 'DESCRIPTION', plain: false, optional: true, dim: true },
],
groupBy: 'connectionId',
emptyMessage: `No semantic-layer sources found in ${project.projectDir}`,
command: 'sl list',
mode,
output: args.output,
json: args.json,
io,
});
return 0;
}
if (args.command === 'read') {
const source = await readLocalSlSource(project, {
if (args.command === 'search') {
const sources = await searchLocalSlSources(project, {
connectionId: args.connectionId,
sourceName: args.sourceName,
query: args.query,
embeddingService: slSearchEmbeddingService(project, deps),
limit: args.limit,
});
await printSlSources({
rows: sources,
emptyMessage: `No semantic-layer sources matched "${args.query}" in ${project.projectDir}`,
command: 'sl search',
output: args.output,
json: args.json,
io,
});
if (!source) {
throw new Error(`Semantic-layer source "${args.connectionId}/${args.sourceName}" was not found`);
}
if (args.json) {
writeJsonResult(io, {
kind: 'sl.source',
data: source,
meta: { command: 'sl read' },
});
return 0;
}
io.stdout.write(source.yaml);
return 0;
}
if (args.command === 'validate') {
@ -178,14 +195,8 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return 0;
}
const write = await writeLocalSlSource(project, {
connectionId: args.connectionId,
sourceName: args.sourceName,
yaml: args.yaml,
});
io.stdout.write(`Wrote ${write.path}\n`);
return 0;
const _exhaustive: never = args;
throw new Error(`Unsupported sl command: ${JSON.stringify(_exhaustive)}`);
} catch (error) {
io.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;