mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-22 08:38:08 +02:00
feat(cli): unify wiki and sl search output with clack-style box and score badge
Routes `ktx wiki list` and `ktx wiki search` through the shared printList()
renderer so all four list/search commands now produce the same Clack-style
pretty box, TSV plain output, and JSON envelope. Adds a `--output` flag to
the wiki commands mirroring sl, and surfaces relevance score as a leading
dim badge ("87%") in pretty mode and a `score=` prefix in plain mode for
both wiki search and sl search. Empty results now emit a consistent
actionable hint across commands.
This commit is contained in:
parent
52dd89481c
commit
c19d03c0c8
5 changed files with 440 additions and 86 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Command } from '@commander-js/extra-typings';
|
import { type Command, Option } from '@commander-js/extra-typings';
|
||||||
import {
|
import {
|
||||||
type KtxCliCommandContext,
|
type KtxCliCommandContext,
|
||||||
parsePositiveIntegerOption,
|
parsePositiveIntegerOption,
|
||||||
|
|
@ -27,32 +27,64 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon
|
||||||
wiki
|
wiki
|
||||||
.command('list')
|
.command('list')
|
||||||
.description('List local wiki pages')
|
.description('List local wiki pages')
|
||||||
.option('--json', 'Print JSON output', false)
|
|
||||||
.option('--user-id <id>', 'Local user id', 'local')
|
.option('--user-id <id>', 'Local user id', 'local')
|
||||||
.action(async (options: { userId: string; json?: boolean }, command) => {
|
.addOption(
|
||||||
await runKnowledgeArgs(context, {
|
new Option('--output <mode>', 'Output mode: pretty (default in TTY), plain (TSV), or json').choices([
|
||||||
command: 'list',
|
'pretty',
|
||||||
projectDir: resolveCommandProjectDir(command),
|
'plain',
|
||||||
userId: options.userId,
|
'json',
|
||||||
json: options.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
|
wiki
|
||||||
.command('search')
|
.command('search')
|
||||||
.description('Search local wiki pages')
|
.description('Search local wiki pages')
|
||||||
.argument('<query>', 'Search query')
|
.argument('<query>', 'Search query')
|
||||||
.option('--json', 'Print JSON output', false)
|
|
||||||
.option('--user-id <id>', 'Local user id', 'local')
|
.option('--user-id <id>', 'Local user id', 'local')
|
||||||
.option('--limit <number>', 'Maximum search results', parsePositiveIntegerOption)
|
.option('--limit <number>', 'Maximum search results', parsePositiveIntegerOption)
|
||||||
.action(async (query: string, options: { userId: string; json?: boolean; limit?: number }, command) => {
|
.addOption(
|
||||||
await runKnowledgeArgs(context, {
|
new Option('--output <mode>', 'Output mode: pretty (default in TTY), plain (TSV), or json').choices([
|
||||||
command: 'search',
|
'pretty',
|
||||||
projectDir: resolveCommandProjectDir(command),
|
'plain',
|
||||||
query,
|
'json',
|
||||||
userId: options.userId,
|
]),
|
||||||
json: options.json,
|
)
|
||||||
...(options.limit !== undefined ? { limit: options.limit } : {}),
|
.option('--json', 'Shortcut for --output=json (overrides --output)', false)
|
||||||
});
|
.action(
|
||||||
});
|
async (
|
||||||
|
query: string,
|
||||||
|
options: {
|
||||||
|
userId: string;
|
||||||
|
limit?: number;
|
||||||
|
output?: 'pretty' | 'plain' | 'json';
|
||||||
|
json?: boolean;
|
||||||
|
},
|
||||||
|
command,
|
||||||
|
) => {
|
||||||
|
await runKnowledgeArgs(context, {
|
||||||
|
command: 'search',
|
||||||
|
projectDir: resolveCommandProjectDir(command),
|
||||||
|
query,
|
||||||
|
userId: options.userId,
|
||||||
|
output: options.output,
|
||||||
|
json: options.json,
|
||||||
|
...(options.limit !== undefined ? { limit: options.limit } : {}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ describe('printList — plain mode', () => {
|
||||||
mode: 'plain',
|
mode: 'plain',
|
||||||
command: 'sl list',
|
command: 'sl list',
|
||||||
emptyMessage: 'No sources',
|
emptyMessage: 'No sources',
|
||||||
|
unit: 'source',
|
||||||
io: r.io,
|
io: r.io,
|
||||||
});
|
});
|
||||||
expect(r.out()).toBe(
|
expect(r.out()).toBe(
|
||||||
|
|
@ -62,9 +63,30 @@ describe('printList — plain mode', () => {
|
||||||
mode: 'plain',
|
mode: 'plain',
|
||||||
command: 'sl list',
|
command: 'sl list',
|
||||||
emptyMessage: 'No sources',
|
emptyMessage: 'No sources',
|
||||||
|
unit: 'source',
|
||||||
io: r.io,
|
io: r.io,
|
||||||
});
|
});
|
||||||
expect(r.out()).toBe('');
|
expect(r.out()).toBe('');
|
||||||
|
expect(r.err()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes emptyMessage + emptyHint to stderr when no rows and hint is provided', () => {
|
||||||
|
const r = recorder();
|
||||||
|
printList<SlRow>({
|
||||||
|
rows: [],
|
||||||
|
columns: SL_COLUMNS,
|
||||||
|
mode: 'plain',
|
||||||
|
command: 'sl search',
|
||||||
|
emptyMessage: 'No sources matched "foo"',
|
||||||
|
emptyHint: 'Run `ktx sl list` 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',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -77,6 +99,7 @@ describe('printList — json mode', () => {
|
||||||
mode: 'json',
|
mode: 'json',
|
||||||
command: 'sl list',
|
command: 'sl list',
|
||||||
emptyMessage: 'No sources',
|
emptyMessage: 'No sources',
|
||||||
|
unit: 'source',
|
||||||
io: r.io,
|
io: r.io,
|
||||||
});
|
});
|
||||||
const written = r.out();
|
const written = r.out();
|
||||||
|
|
@ -97,6 +120,8 @@ describe('printList — json mode', () => {
|
||||||
mode: 'json',
|
mode: 'json',
|
||||||
command: 'sl list',
|
command: 'sl list',
|
||||||
emptyMessage: 'No sources',
|
emptyMessage: 'No sources',
|
||||||
|
emptyHint: 'ignored in json mode',
|
||||||
|
unit: 'source',
|
||||||
io: r.io,
|
io: r.io,
|
||||||
});
|
});
|
||||||
expect(JSON.parse(r.out())).toEqual({
|
expect(JSON.parse(r.out())).toEqual({
|
||||||
|
|
@ -104,6 +129,7 @@ describe('printList — json mode', () => {
|
||||||
data: { items: [] },
|
data: { items: [] },
|
||||||
meta: { command: 'sl list' },
|
meta: { command: 'sl list' },
|
||||||
});
|
});
|
||||||
|
expect(r.err()).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -122,6 +148,7 @@ describe('printList — pretty mode', () => {
|
||||||
mode: 'pretty',
|
mode: 'pretty',
|
||||||
command: 'sl list',
|
command: 'sl list',
|
||||||
emptyMessage: 'No sources',
|
emptyMessage: 'No sources',
|
||||||
|
unit: 'source',
|
||||||
io: r.io,
|
io: r.io,
|
||||||
});
|
});
|
||||||
const out = stripAnsi(r.out());
|
const out = stripAnsi(r.out());
|
||||||
|
|
@ -143,6 +170,7 @@ describe('printList — pretty mode', () => {
|
||||||
mode: 'pretty',
|
mode: 'pretty',
|
||||||
command: 'sl list',
|
command: 'sl list',
|
||||||
emptyMessage: 'No semantic-layer sources found in /tmp/proj',
|
emptyMessage: 'No semantic-layer sources found in /tmp/proj',
|
||||||
|
unit: 'source',
|
||||||
io: r.io,
|
io: r.io,
|
||||||
});
|
});
|
||||||
const out = stripAnsi(r.out());
|
const out = stripAnsi(r.out());
|
||||||
|
|
@ -150,6 +178,25 @@ describe('printList — pretty mode', () => {
|
||||||
expect(out).toContain(`${SYMBOLS.barEnd} No semantic-layer sources found in /tmp/proj`);
|
expect(out).toContain(`${SYMBOLS.barEnd} No semantic-layer sources found in /tmp/proj`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders empty-state with hint and zero-count footer when emptyHint is provided', () => {
|
||||||
|
const r = recorder();
|
||||||
|
printList<SlRow>({
|
||||||
|
rows: [],
|
||||||
|
columns: SL_COLUMNS,
|
||||||
|
groupBy: 'connectionId',
|
||||||
|
mode: 'pretty',
|
||||||
|
command: 'sl search',
|
||||||
|
emptyMessage: 'No sources matched "foo"',
|
||||||
|
emptyHint: 'Run `ktx sl list` to see available sources.',
|
||||||
|
unit: 'source',
|
||||||
|
io: r.io,
|
||||||
|
});
|
||||||
|
const out = stripAnsi(r.out());
|
||||||
|
expect(out).toContain(`${SYMBOLS.bar} No sources matched "foo"`);
|
||||||
|
expect(out).toContain(`${SYMBOLS.bar} Run \`ktx sl list\` to see available sources.`);
|
||||||
|
expect(out).toContain(`${SYMBOLS.barEnd} 0 sources`);
|
||||||
|
});
|
||||||
|
|
||||||
it('singularizes the footer when there is one row', () => {
|
it('singularizes the footer when there is one row', () => {
|
||||||
const r = recorder();
|
const r = recorder();
|
||||||
printList<SlRow>({
|
printList<SlRow>({
|
||||||
|
|
@ -159,11 +206,103 @@ describe('printList — pretty mode', () => {
|
||||||
mode: 'pretty',
|
mode: 'pretty',
|
||||||
command: 'sl list',
|
command: 'sl list',
|
||||||
emptyMessage: 'No sources',
|
emptyMessage: 'No sources',
|
||||||
|
unit: 'source',
|
||||||
io: r.io,
|
io: r.io,
|
||||||
});
|
});
|
||||||
const out = stripAnsi(r.out());
|
const out = stripAnsi(r.out());
|
||||||
expect(out).toContain(`${SYMBOLS.barEnd} 1 source`);
|
expect(out).toContain(`${SYMBOLS.barEnd} 1 source`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the provided unit in pluralization and group counts', () => {
|
||||||
|
const r = recorder();
|
||||||
|
interface PageRow { scope: string; key: string; summary: string }
|
||||||
|
const PAGE_COLUMNS: ReadonlyArray<PrintListColumn<PageRow>> = [
|
||||||
|
{ key: 'scope', label: 'SCOPE', plain: '' },
|
||||||
|
{ key: 'key', label: 'KEY', plain: '' },
|
||||||
|
{ key: 'summary', label: 'SUMMARY', plain: '', optional: true, dim: true },
|
||||||
|
];
|
||||||
|
printList<PageRow>({
|
||||||
|
rows: [
|
||||||
|
{ scope: 'GLOBAL', key: 'a', summary: 'x' },
|
||||||
|
{ scope: 'GLOBAL', key: 'b', summary: '' },
|
||||||
|
],
|
||||||
|
columns: PAGE_COLUMNS,
|
||||||
|
groupBy: 'scope',
|
||||||
|
mode: 'pretty',
|
||||||
|
command: 'wiki list',
|
||||||
|
emptyMessage: 'No pages',
|
||||||
|
unit: 'page',
|
||||||
|
io: r.io,
|
||||||
|
});
|
||||||
|
const out = stripAnsi(r.out());
|
||||||
|
expect(out).toContain('(2 pages)');
|
||||||
|
expect(out).toContain(`${SYMBOLS.barEnd} 2 pages`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a leading dim badge column with prettyFormat in pretty mode', () => {
|
||||||
|
const r = recorder();
|
||||||
|
interface SearchRow { score: number; scope: string; key: string; summary: string }
|
||||||
|
const SEARCH_COLUMNS: ReadonlyArray<PrintListColumn<SearchRow>> = [
|
||||||
|
{
|
||||||
|
key: 'score',
|
||||||
|
label: 'SCORE',
|
||||||
|
plain: 'score=',
|
||||||
|
role: 'badge',
|
||||||
|
prettyFormat: (v) => `${Math.round(Number(v) * 100)}%`,
|
||||||
|
dim: true,
|
||||||
|
},
|
||||||
|
{ key: 'scope', label: 'SCOPE', plain: '' },
|
||||||
|
{ key: 'key', label: 'KEY', plain: '' },
|
||||||
|
{ key: 'summary', label: 'SUMMARY', plain: '', optional: true, dim: true },
|
||||||
|
];
|
||||||
|
const rows: SearchRow[] = [
|
||||||
|
{ score: 0.87, scope: 'GLOBAL', key: 'alpha', summary: 'first' },
|
||||||
|
{ score: 0.04, scope: 'GLOBAL', key: 'beta', summary: 'second' },
|
||||||
|
];
|
||||||
|
printList<SearchRow>({
|
||||||
|
rows,
|
||||||
|
columns: SEARCH_COLUMNS,
|
||||||
|
groupBy: 'scope',
|
||||||
|
mode: 'pretty',
|
||||||
|
command: 'wiki search',
|
||||||
|
emptyMessage: 'No matches',
|
||||||
|
unit: 'page',
|
||||||
|
io: r.io,
|
||||||
|
});
|
||||||
|
const out = stripAnsi(r.out());
|
||||||
|
// Badge displays as right-padded percentage before the name column.
|
||||||
|
expect(out).toMatch(new RegExp(`${escapeRegExp(SYMBOLS.item)} 87%\\s+alpha\\s+`));
|
||||||
|
expect(out).toMatch(new RegExp(`${escapeRegExp(SYMBOLS.item)} 4%\\s+beta\\s+`));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits the badge column in plain mode using its plain prefix', () => {
|
||||||
|
const r = recorder();
|
||||||
|
interface SearchRow { score: number; scope: string; key: string; summary: string }
|
||||||
|
const SEARCH_COLUMNS: ReadonlyArray<PrintListColumn<SearchRow>> = [
|
||||||
|
{
|
||||||
|
key: 'score',
|
||||||
|
label: 'SCORE',
|
||||||
|
plain: 'score=',
|
||||||
|
role: 'badge',
|
||||||
|
prettyFormat: (v) => `${Math.round(Number(v) * 100)}%`,
|
||||||
|
dim: true,
|
||||||
|
},
|
||||||
|
{ key: 'scope', label: 'SCOPE', plain: '' },
|
||||||
|
{ key: 'key', label: 'KEY', plain: '' },
|
||||||
|
{ key: 'summary', label: 'SUMMARY', plain: '', optional: true, dim: true },
|
||||||
|
];
|
||||||
|
printList<SearchRow>({
|
||||||
|
rows: [{ score: 0.87, scope: 'GLOBAL', key: 'alpha', summary: 'first' }],
|
||||||
|
columns: SEARCH_COLUMNS,
|
||||||
|
groupBy: 'scope',
|
||||||
|
mode: 'plain',
|
||||||
|
command: 'wiki search',
|
||||||
|
emptyMessage: 'No matches',
|
||||||
|
unit: 'page',
|
||||||
|
io: r.io,
|
||||||
|
});
|
||||||
|
expect(r.out()).toBe('score=0.87\tGLOBAL\talpha\tfirst\n');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function escapeRegExp(s: string): string {
|
function escapeRegExp(s: string): string {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,16 @@ export interface PrintListColumn<Row> {
|
||||||
optional?: boolean;
|
optional?: boolean;
|
||||||
/** Pretty-mode hint: render this column dim. */
|
/** Pretty-mode hint: render this column dim. */
|
||||||
dim?: boolean;
|
dim?: boolean;
|
||||||
|
/**
|
||||||
|
* Pretty-mode role override. When omitted, role is auto-detected:
|
||||||
|
* - `'badge'` — leading dim cell before the name column (right-padded across rows).
|
||||||
|
* - `'name'` — name column. Default: first non-grouped, non-metric, non-optional column.
|
||||||
|
* - `'metric'` — `"N word"` cell. Default: any column with a non-empty `plain` prefix.
|
||||||
|
* - `'suffix'` — trailing em-dash optional value. Default: any column with `optional: true`.
|
||||||
|
*/
|
||||||
|
role?: 'name' | 'metric' | 'badge' | 'suffix';
|
||||||
|
/** Custom pretty-mode value formatter (e.g. score → "87%"). Plain/JSON unaffected. */
|
||||||
|
prettyFormat?: (value: Row[keyof Row & string], row: Row) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrintListArgs<Row> {
|
export interface PrintListArgs<Row> {
|
||||||
|
|
@ -23,6 +33,11 @@ export interface PrintListArgs<Row> {
|
||||||
columns: ReadonlyArray<PrintListColumn<Row>>;
|
columns: ReadonlyArray<PrintListColumn<Row>>;
|
||||||
groupBy?: keyof Row & string;
|
groupBy?: keyof Row & string;
|
||||||
emptyMessage: string;
|
emptyMessage: string;
|
||||||
|
/** Optional second-line hint shown on empty results.
|
||||||
|
* Plain mode: written to stderr. Pretty mode: dimmed line inside the box. JSON mode: ignored. */
|
||||||
|
emptyHint?: string;
|
||||||
|
/** Singular noun used in counts (`N {unit}s`, `(N {unit}s)`). Defaults to `'result'`. */
|
||||||
|
unit?: string;
|
||||||
command: string;
|
command: string;
|
||||||
mode: KtxOutputMode;
|
mode: KtxOutputMode;
|
||||||
io: KtxCliIo;
|
io: KtxCliIo;
|
||||||
|
|
@ -57,6 +72,15 @@ function isEmpty(value: unknown): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
function printListPlain<Row extends object>(args: PrintListArgs<Row>): void {
|
function printListPlain<Row extends object>(args: PrintListArgs<Row>): void {
|
||||||
|
if (args.rows.length === 0) {
|
||||||
|
if (args.emptyHint !== undefined && args.emptyHint !== '') {
|
||||||
|
// Plain mode keeps stdout pipe-safe. Send the human-readable empty
|
||||||
|
// state to stderr as two lines (message, then hint).
|
||||||
|
args.io.stderr.write(`${args.emptyMessage}\n`);
|
||||||
|
args.io.stderr.write(`${args.emptyHint}\n`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (const row of args.rows) {
|
for (const row of args.rows) {
|
||||||
const cells: string[] = [];
|
const cells: string[] = [];
|
||||||
for (const col of args.columns) {
|
for (const col of args.columns) {
|
||||||
|
|
@ -114,52 +138,129 @@ function groupRows<Row extends object>(
|
||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ResolvedColumns<Row extends object> {
|
||||||
|
badge: ReadonlyArray<PrintListColumn<Row>>;
|
||||||
|
name?: PrintListColumn<Row>;
|
||||||
|
metric: ReadonlyArray<PrintListColumn<Row>>;
|
||||||
|
suffix: ReadonlyArray<PrintListColumn<Row>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveColumns<Row extends object>(
|
||||||
|
columns: ReadonlyArray<PrintListColumn<Row>>,
|
||||||
|
groupBy: (keyof Row & string) | undefined,
|
||||||
|
): ResolvedColumns<Row> {
|
||||||
|
const badge: PrintListColumn<Row>[] = [];
|
||||||
|
const metric: PrintListColumn<Row>[] = [];
|
||||||
|
const suffix: PrintListColumn<Row>[] = [];
|
||||||
|
let name: PrintListColumn<Row> | undefined;
|
||||||
|
|
||||||
|
for (const col of columns) {
|
||||||
|
if (col.role === 'badge') {
|
||||||
|
badge.push(col);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (col.role === 'name') {
|
||||||
|
name ??= col;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (col.role === 'metric') {
|
||||||
|
metric.push(col);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (col.role === 'suffix') {
|
||||||
|
suffix.push(col);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Auto-detect when no explicit role.
|
||||||
|
if (col.key === groupBy) continue;
|
||||||
|
if (col.optional === true) {
|
||||||
|
suffix.push(col);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof col.plain === 'string' && col.plain.length > 0) {
|
||||||
|
metric.push(col);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!name && !col.plain && col.plain !== false) {
|
||||||
|
name = col;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { badge, name, metric, suffix };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCellValue<Row extends object>(col: PrintListColumn<Row>, row: Row): string {
|
||||||
|
const value = row[col.key];
|
||||||
|
if (col.prettyFormat) {
|
||||||
|
return col.prettyFormat(value as Row[keyof Row & string], row);
|
||||||
|
}
|
||||||
|
if (value === undefined || value === null) return '';
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
function printListPretty<Row extends object>(args: PrintListArgs<Row>): void {
|
function printListPretty<Row extends object>(args: PrintListArgs<Row>): void {
|
||||||
const { io, command, rows, columns, groupBy, emptyMessage } = args;
|
const { io, command, rows, columns, groupBy, emptyMessage, emptyHint } = args;
|
||||||
|
const unit = args.unit ?? 'result';
|
||||||
|
|
||||||
io.stdout.write(`${SYMBOLS.barStart} ${command}\n`);
|
io.stdout.write(`${SYMBOLS.barStart} ${command}\n`);
|
||||||
io.stdout.write(`${SYMBOLS.bar}\n`);
|
io.stdout.write(`${SYMBOLS.bar}\n`);
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
io.stdout.write(`${SYMBOLS.barEnd} ${emptyMessage}\n`);
|
if (emptyHint !== undefined && emptyHint !== '') {
|
||||||
|
io.stdout.write(`${SYMBOLS.bar} ${emptyMessage}\n`);
|
||||||
|
io.stdout.write(`${SYMBOLS.bar} ${dim(emptyHint)}\n`);
|
||||||
|
io.stdout.write(`${SYMBOLS.barEnd} ${dim(`0 ${unit}s`)}\n`);
|
||||||
|
} else {
|
||||||
|
io.stdout.write(`${SYMBOLS.barEnd} ${emptyMessage}\n`);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Identify role of each column.
|
const resolved = resolveColumns(columns, groupBy);
|
||||||
// - First non-grouped, non-metric, non-optional column = "name" column (bolded)
|
|
||||||
// - Columns with a `plain` prefix = metric columns (rendered as "N word")
|
|
||||||
// - optional columns = trailing suffix (em-dash + value), only when value is present
|
|
||||||
const nameCol = columns.find(
|
|
||||||
(c) => c.key !== groupBy && !c.plain && !c.optional && c.plain !== false,
|
|
||||||
);
|
|
||||||
const metricCols = columns.filter((c) => typeof c.plain === 'string' && c.plain.length > 0);
|
|
||||||
const optionalCols = columns.filter((c) => c.optional === true);
|
|
||||||
|
|
||||||
const buckets = groupBy ? groupRows(rows, groupBy) : new Map<string, Row[]>([['', [...rows]]]);
|
const buckets = groupBy ? groupRows(rows, groupBy) : new Map<string, Row[]>([['', [...rows]]]);
|
||||||
|
|
||||||
const nameWidth = nameCol
|
const nameWidth = resolved.name
|
||||||
? Math.max(...rows.map((r) => String(r[nameCol.key] ?? '').length))
|
? Math.max(...rows.map((r) => String(r[resolved.name!.key] ?? '').length))
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
const badgeWidths = resolved.badge.map((col) =>
|
||||||
|
Math.max(0, ...rows.map((r) => formatCellValue(col, r).length)),
|
||||||
|
);
|
||||||
|
|
||||||
for (const [groupValue, groupRowList] of buckets) {
|
for (const [groupValue, groupRowList] of buckets) {
|
||||||
if (groupBy) {
|
if (groupBy) {
|
||||||
io.stdout.write(
|
io.stdout.write(
|
||||||
`${SYMBOLS.bar} ${SYMBOLS.group} ${bold(groupValue)} ${dim(`(${pluralize(groupRowList.length, 'source')})`)}\n`,
|
`${SYMBOLS.bar} ${SYMBOLS.group} ${bold(groupValue)} ${dim(`(${pluralize(groupRowList.length, unit)})`)}\n`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (const row of groupRowList) {
|
for (const row of groupRowList) {
|
||||||
const segments: string[] = [];
|
const segments: string[] = [];
|
||||||
if (nameCol) {
|
|
||||||
segments.push(String(row[nameCol.key] ?? '').padEnd(nameWidth));
|
resolved.badge.forEach((col, idx) => {
|
||||||
|
segments.push(dim(formatCellValue(col, row).padStart(badgeWidths[idx] ?? 0)));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resolved.name) {
|
||||||
|
segments.push(String(row[resolved.name.key] ?? '').padEnd(nameWidth));
|
||||||
}
|
}
|
||||||
const metrics = metricCols
|
|
||||||
.map((c) => metricCell(c.label ?? c.key, Number(row[c.key] ?? 0)))
|
const metrics = resolved.metric
|
||||||
|
.map((col) => {
|
||||||
|
if (col.prettyFormat) return formatCellValue(col, row);
|
||||||
|
return metricCell(col.label ?? col.key, Number(row[col.key] ?? 0));
|
||||||
|
})
|
||||||
.join(` ${SYMBOLS.middot} `);
|
.join(` ${SYMBOLS.middot} `);
|
||||||
if (metrics.length > 0) segments.push(dim(metrics));
|
if (metrics.length > 0) segments.push(dim(metrics));
|
||||||
const optionalSuffix = optionalCols
|
|
||||||
.map((c) => row[c.key])
|
const optionalSuffix = resolved.suffix
|
||||||
.filter((v) => !isEmpty(v))
|
.map((col) => {
|
||||||
.map((v) => `${SYMBOLS.emDash} ${dim(String(v))}`)
|
const value = row[col.key];
|
||||||
|
if (isEmpty(value)) return null;
|
||||||
|
const formatted = col.prettyFormat ? formatCellValue(col, row) : String(value);
|
||||||
|
return `${SYMBOLS.emDash} ${dim(formatted)}`;
|
||||||
|
})
|
||||||
|
.filter((s): s is string => s !== null)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
if (optionalSuffix.length > 0) segments.push(optionalSuffix);
|
if (optionalSuffix.length > 0) segments.push(optionalSuffix);
|
||||||
|
|
||||||
|
|
@ -169,5 +270,5 @@ function printListPretty<Row extends object>(args: PrintListArgs<Row>): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
io.stdout.write(`${SYMBOLS.bar}\n`);
|
io.stdout.write(`${SYMBOLS.bar}\n`);
|
||||||
io.stdout.write(`${SYMBOLS.barEnd} ${pluralize(rows.length, 'source')}\n`);
|
io.stdout.write(`${SYMBOLS.barEnd} ${pluralize(rows.length, unit)}\n`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,28 @@ import {
|
||||||
import { loadKtxProject } from '@ktx/context/project';
|
import { loadKtxProject } from '@ktx/context/project';
|
||||||
import {
|
import {
|
||||||
type LocalKnowledgeScope,
|
type LocalKnowledgeScope,
|
||||||
|
type LocalKnowledgeSearchResult,
|
||||||
|
type LocalKnowledgeSummary,
|
||||||
listLocalKnowledgePages,
|
listLocalKnowledgePages,
|
||||||
readLocalKnowledgePage,
|
readLocalKnowledgePage,
|
||||||
searchLocalKnowledgePages,
|
searchLocalKnowledgePages,
|
||||||
writeLocalKnowledgePage,
|
writeLocalKnowledgePage,
|
||||||
} from '@ktx/context/wiki';
|
} from '@ktx/context/wiki';
|
||||||
import { writeJsonResult } from './io/print-list.js';
|
import { resolveOutputMode } from './io/mode.js';
|
||||||
|
import { printList, type PrintListColumn, writeJsonResult } from './io/print-list.js';
|
||||||
|
|
||||||
export type KtxKnowledgeArgs =
|
export type KtxKnowledgeArgs =
|
||||||
| { command: 'list'; projectDir: string; userId: string; json?: boolean }
|
| { command: 'list'; projectDir: string; userId: string; output?: string; json?: boolean }
|
||||||
| { command: 'read'; projectDir: string; key: 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: 'search';
|
||||||
|
projectDir: string;
|
||||||
|
query: string;
|
||||||
|
userId: string;
|
||||||
|
output?: string;
|
||||||
|
json?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
command: 'write';
|
command: 'write';
|
||||||
projectDir: string;
|
projectDir: string;
|
||||||
|
|
@ -30,10 +41,27 @@ export type KtxKnowledgeArgs =
|
||||||
slRefs: string[];
|
slRefs: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
interface KtxKnowledgeIo {
|
type KtxKnowledgeIo = import('./cli-runtime.js').KtxCliIo;
|
||||||
stdout: { write(chunk: string): void };
|
|
||||||
stderr: { write(chunk: string): void };
|
const WIKI_LIST_COLUMNS: ReadonlyArray<PrintListColumn<LocalKnowledgeSummary>> = [
|
||||||
}
|
{ key: 'scope', label: 'SCOPE', plain: '' },
|
||||||
|
{ key: 'key', label: 'KEY', plain: '' },
|
||||||
|
{ key: 'summary', label: 'SUMMARY', plain: '', optional: true, dim: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const WIKI_SEARCH_COLUMNS: ReadonlyArray<PrintListColumn<LocalKnowledgeSearchResult>> = [
|
||||||
|
{
|
||||||
|
key: 'score',
|
||||||
|
label: 'SCORE',
|
||||||
|
plain: 'score=',
|
||||||
|
role: 'badge',
|
||||||
|
prettyFormat: (value) => `${Math.round(Number(value) * 100)}%`,
|
||||||
|
dim: true,
|
||||||
|
},
|
||||||
|
{ key: 'scope', label: 'SCOPE', plain: '' },
|
||||||
|
{ key: 'key', label: 'KEY', plain: '' },
|
||||||
|
{ key: 'summary', label: 'SUMMARY', plain: '', optional: true, dim: true },
|
||||||
|
];
|
||||||
|
|
||||||
interface KtxKnowledgeDeps {
|
interface KtxKnowledgeDeps {
|
||||||
embeddingService?: KtxEmbeddingPort | null;
|
embeddingService?: KtxEmbeddingPort | null;
|
||||||
|
|
@ -62,17 +90,18 @@ export async function runKtxKnowledge(
|
||||||
const project = await loadKtxProject({ projectDir: args.projectDir });
|
const project = await loadKtxProject({ projectDir: args.projectDir });
|
||||||
if (args.command === 'list') {
|
if (args.command === 'list') {
|
||||||
const pages = await listLocalKnowledgePages(project, { userId: args.userId });
|
const pages = await listLocalKnowledgePages(project, { userId: args.userId });
|
||||||
if (args.json) {
|
const mode = resolveOutputMode({ explicit: args.output, json: args.json, io });
|
||||||
writeJsonResult(io, {
|
printList<LocalKnowledgeSummary>({
|
||||||
kind: 'list',
|
rows: pages,
|
||||||
data: { items: pages },
|
columns: WIKI_LIST_COLUMNS,
|
||||||
meta: { command: 'wiki list' },
|
groupBy: 'scope',
|
||||||
});
|
emptyMessage: `No local wiki pages found in ${project.projectDir}`,
|
||||||
return 0;
|
emptyHint: 'Add Markdown files under wiki/ or run `ktx ingest <connectionId>`.',
|
||||||
}
|
unit: 'page',
|
||||||
for (const page of pages) {
|
command: 'wiki list',
|
||||||
io.stdout.write(`${page.scope}\t${page.key}\t${page.summary}\n`);
|
mode,
|
||||||
}
|
io,
|
||||||
|
});
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (args.command === 'read') {
|
if (args.command === 'read') {
|
||||||
|
|
@ -101,30 +130,27 @@ export async function runKtxKnowledge(
|
||||||
embeddingService: wikiSearchEmbeddingService(project, deps),
|
embeddingService: wikiSearchEmbeddingService(project, deps),
|
||||||
limit: args.limit,
|
limit: args.limit,
|
||||||
});
|
});
|
||||||
if (args.json) {
|
const mode = resolveOutputMode({ explicit: args.output, json: args.json, io });
|
||||||
writeJsonResult(io, {
|
let emptyMessage = `No local wiki pages matched "${args.query}"`;
|
||||||
kind: 'list',
|
let emptyHint = 'Run `ktx wiki list` to inspect available pages.';
|
||||||
data: { items: results },
|
if (results.length === 0 && mode !== 'json') {
|
||||||
meta: { command: 'wiki search' },
|
|
||||||
});
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (results.length === 0) {
|
|
||||||
const pages = await listLocalKnowledgePages(project, { userId: args.userId });
|
const pages = await listLocalKnowledgePages(project, { userId: args.userId });
|
||||||
if (pages.length === 0) {
|
if (pages.length === 0) {
|
||||||
io.stderr.write(
|
emptyMessage = `No local wiki pages found in ${project.projectDir}`;
|
||||||
`No local wiki pages found in ${project.projectDir}. Add Markdown files under wiki/ or run \`ktx ingest <connectionId>\`.\n`,
|
emptyHint = 'Add Markdown files under wiki/ or run `ktx ingest <connectionId>`.';
|
||||||
);
|
|
||||||
} else {
|
|
||||||
io.stderr.write(
|
|
||||||
`No local wiki pages matched "${args.query}". Run \`ktx wiki list\` to inspect available pages.\n`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
for (const result of results) {
|
|
||||||
io.stdout.write(`${result.score}\t${result.scope}\t${result.key}\t${result.summary}\n`);
|
|
||||||
}
|
}
|
||||||
|
printList<LocalKnowledgeSearchResult>({
|
||||||
|
rows: results,
|
||||||
|
columns: WIKI_SEARCH_COLUMNS,
|
||||||
|
groupBy: 'scope',
|
||||||
|
emptyMessage,
|
||||||
|
emptyHint,
|
||||||
|
unit: 'page',
|
||||||
|
command: 'wiki search',
|
||||||
|
mode,
|
||||||
|
io,
|
||||||
|
});
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
type LocalSlSourceSummary,
|
type LocalSlSourceSummary,
|
||||||
type SemanticLayerQueryInput,
|
type SemanticLayerQueryInput,
|
||||||
} from '@ktx/context/sl';
|
} from '@ktx/context/sl';
|
||||||
|
import type { PrintListColumn } from './io/print-list.js';
|
||||||
import {
|
import {
|
||||||
createManagedPythonSemanticLayerComputePort,
|
createManagedPythonSemanticLayerComputePort,
|
||||||
type KtxManagedPythonInstallPolicy,
|
type KtxManagedPythonInstallPolicy,
|
||||||
|
|
@ -80,6 +81,24 @@ function slSearchEmbeddingService(project: KtxLocalProject, deps: KtxSlDeps): Kt
|
||||||
return provider ? new KtxIngestEmbeddingPortAdapter(provider) : null;
|
return provider ? new KtxIngestEmbeddingPortAdapter(provider) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function printSlSources(input: {
|
||||||
|
rows: ReadonlyArray<LocalSlSourceSummary>;
|
||||||
|
command: 'sl list';
|
||||||
|
output?: string;
|
||||||
|
json?: boolean;
|
||||||
|
io: KtxSlIo;
|
||||||
|
emptyMessage: string;
|
||||||
|
emptyHint?: string;
|
||||||
|
}): Promise<void>;
|
||||||
|
async function printSlSources(input: {
|
||||||
|
rows: ReadonlyArray<LocalSlSourceSearchResult>;
|
||||||
|
command: 'sl search';
|
||||||
|
output?: string;
|
||||||
|
json?: boolean;
|
||||||
|
io: KtxSlIo;
|
||||||
|
emptyMessage: string;
|
||||||
|
emptyHint?: string;
|
||||||
|
}): Promise<void>;
|
||||||
async function printSlSources(input: {
|
async function printSlSources(input: {
|
||||||
rows: ReadonlyArray<LocalSlSourceSummary | LocalSlSourceSearchResult>;
|
rows: ReadonlyArray<LocalSlSourceSummary | LocalSlSourceSearchResult>;
|
||||||
command: 'sl list' | 'sl search';
|
command: 'sl list' | 'sl search';
|
||||||
|
|
@ -87,22 +106,58 @@ async function printSlSources(input: {
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
io: KtxSlIo;
|
io: KtxSlIo;
|
||||||
emptyMessage: string;
|
emptyMessage: string;
|
||||||
|
emptyHint?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { resolveOutputMode } = await import('./io/mode.js');
|
const { resolveOutputMode } = await import('./io/mode.js');
|
||||||
const { printList } = await import('./io/print-list.js');
|
const { printList } = await import('./io/print-list.js');
|
||||||
const mode = resolveOutputMode({ explicit: input.output, json: input.json, io: input.io });
|
const mode = resolveOutputMode({ explicit: input.output, json: input.json, io: input.io });
|
||||||
printList({
|
|
||||||
rows: input.rows,
|
if (input.command === 'sl search') {
|
||||||
columns: [
|
const searchColumns: ReadonlyArray<PrintListColumn<LocalSlSourceSearchResult>> = [
|
||||||
|
{
|
||||||
|
key: 'score',
|
||||||
|
label: 'SCORE',
|
||||||
|
plain: 'score=',
|
||||||
|
role: 'badge',
|
||||||
|
prettyFormat: (value) => `${Math.round(Number(value) * 100)}%`,
|
||||||
|
dim: true,
|
||||||
|
},
|
||||||
{ key: 'connectionId', label: 'CONNECTION', plain: '' },
|
{ key: 'connectionId', label: 'CONNECTION', plain: '' },
|
||||||
{ key: 'name', label: 'NAME', plain: '' },
|
{ key: 'name', label: 'NAME', plain: '' },
|
||||||
{ key: 'columnCount', label: 'COLS', plain: 'columns=', dim: true },
|
{ key: 'columnCount', label: 'COLS', plain: 'columns=', dim: true },
|
||||||
{ key: 'measureCount', label: 'MEASURES', plain: 'measures=', dim: true },
|
{ key: 'measureCount', label: 'MEASURES', plain: 'measures=', dim: true },
|
||||||
{ key: 'joinCount', label: 'JOINS', plain: 'joins=', dim: true },
|
{ key: 'joinCount', label: 'JOINS', plain: 'joins=', dim: true },
|
||||||
{ key: 'description', label: 'DESCRIPTION', plain: false, optional: true, dim: true },
|
{ key: 'description', label: 'DESCRIPTION', plain: false, optional: true, dim: true },
|
||||||
],
|
];
|
||||||
|
printList<LocalSlSourceSearchResult>({
|
||||||
|
rows: input.rows as ReadonlyArray<LocalSlSourceSearchResult>,
|
||||||
|
columns: searchColumns,
|
||||||
|
groupBy: 'connectionId',
|
||||||
|
emptyMessage: input.emptyMessage,
|
||||||
|
emptyHint: input.emptyHint,
|
||||||
|
unit: 'source',
|
||||||
|
command: input.command,
|
||||||
|
mode,
|
||||||
|
io: input.io,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listColumns: ReadonlyArray<PrintListColumn<LocalSlSourceSummary>> = [
|
||||||
|
{ 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 },
|
||||||
|
];
|
||||||
|
printList<LocalSlSourceSummary>({
|
||||||
|
rows: input.rows as ReadonlyArray<LocalSlSourceSummary>,
|
||||||
|
columns: listColumns,
|
||||||
groupBy: 'connectionId',
|
groupBy: 'connectionId',
|
||||||
emptyMessage: input.emptyMessage,
|
emptyMessage: input.emptyMessage,
|
||||||
|
emptyHint: input.emptyHint,
|
||||||
|
unit: 'source',
|
||||||
command: input.command,
|
command: input.command,
|
||||||
mode,
|
mode,
|
||||||
io: input.io,
|
io: input.io,
|
||||||
|
|
@ -142,6 +197,7 @@ export async function runKtxSl(args: KtxSlArgs, io: KtxSlIo = process, deps: Ktx
|
||||||
await printSlSources({
|
await printSlSources({
|
||||||
rows: sources,
|
rows: sources,
|
||||||
emptyMessage: `No semantic-layer sources matched "${args.query}" in ${project.projectDir}`,
|
emptyMessage: `No semantic-layer sources matched "${args.query}" in ${project.projectDir}`,
|
||||||
|
emptyHint: 'Run `ktx sl list` to inspect available sources.',
|
||||||
command: 'sl search',
|
command: 'sl search',
|
||||||
output: args.output,
|
output: args.output,
|
||||||
json: args.json,
|
json: args.json,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue