diff --git a/packages/cli/src/commands/knowledge-commands.ts b/packages/cli/src/commands/knowledge-commands.ts index d0c04a32..d2a93228 100644 --- a/packages/cli/src/commands/knowledge-commands.ts +++ b/packages/cli/src/commands/knowledge-commands.ts @@ -1,4 +1,4 @@ -import type { Command } from '@commander-js/extra-typings'; +import { type Command, Option } from '@commander-js/extra-typings'; import { type KtxCliCommandContext, parsePositiveIntegerOption, @@ -27,32 +27,64 @@ export function registerWikiCommands(program: Command, context: KtxCliCommandCon wiki .command('list') .description('List local wiki pages') - .option('--json', 'Print JSON output', false) .option('--user-id ', 'Local user id', 'local') - .action(async (options: { userId: string; json?: boolean }, command) => { - await runKnowledgeArgs(context, { - command: 'list', - projectDir: resolveCommandProjectDir(command), - userId: options.userId, - json: options.json, - }); - }); + .addOption( + new Option('--output ', '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('', 'Search query') - .option('--json', 'Print JSON output', false) .option('--user-id ', 'Local user id', 'local') .option('--limit ', 'Maximum search results', parsePositiveIntegerOption) - .action(async (query: string, options: { userId: string; json?: boolean; limit?: number }, command) => { - await runKnowledgeArgs(context, { - command: 'search', - projectDir: resolveCommandProjectDir(command), - query, - userId: options.userId, - json: options.json, - ...(options.limit !== undefined ? { limit: options.limit } : {}), - }); - }); + .addOption( + new Option('--output ', '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: { + 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 } : {}), + }); + }, + ); } diff --git a/packages/cli/src/io/print-list.test.ts b/packages/cli/src/io/print-list.test.ts index 4f413667..543cc71e 100644 --- a/packages/cli/src/io/print-list.test.ts +++ b/packages/cli/src/io/print-list.test.ts @@ -46,6 +46,7 @@ describe('printList — plain mode', () => { mode: 'plain', command: 'sl list', emptyMessage: 'No sources', + unit: 'source', io: r.io, }); expect(r.out()).toBe( @@ -62,9 +63,30 @@ describe('printList — plain mode', () => { mode: 'plain', command: 'sl list', emptyMessage: 'No sources', + unit: 'source', io: r.io, }); 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({ + 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', command: 'sl list', emptyMessage: 'No sources', + unit: 'source', io: r.io, }); const written = r.out(); @@ -97,6 +120,8 @@ describe('printList — json mode', () => { mode: 'json', command: 'sl list', emptyMessage: 'No sources', + emptyHint: 'ignored in json mode', + unit: 'source', io: r.io, }); expect(JSON.parse(r.out())).toEqual({ @@ -104,6 +129,7 @@ describe('printList — json mode', () => { data: { items: [] }, meta: { command: 'sl list' }, }); + expect(r.err()).toBe(''); }); }); @@ -122,6 +148,7 @@ describe('printList — pretty mode', () => { mode: 'pretty', command: 'sl list', emptyMessage: 'No sources', + unit: 'source', io: r.io, }); const out = stripAnsi(r.out()); @@ -143,6 +170,7 @@ describe('printList — pretty mode', () => { mode: 'pretty', command: 'sl list', emptyMessage: 'No semantic-layer sources found in /tmp/proj', + unit: 'source', io: r.io, }); 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`); }); + it('renders empty-state with hint and zero-count footer when emptyHint is provided', () => { + const r = recorder(); + printList({ + 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', () => { const r = recorder(); printList({ @@ -159,11 +206,103 @@ describe('printList — pretty mode', () => { mode: 'pretty', command: 'sl list', emptyMessage: 'No sources', + unit: 'source', io: r.io, }); const out = stripAnsi(r.out()); 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> = [ + { key: 'scope', label: 'SCOPE', plain: '' }, + { key: 'key', label: 'KEY', plain: '' }, + { key: 'summary', label: 'SUMMARY', plain: '', optional: true, dim: true }, + ]; + printList({ + 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> = [ + { + 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({ + 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> = [ + { + 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({ + 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 { diff --git a/packages/cli/src/io/print-list.ts b/packages/cli/src/io/print-list.ts index d2129d7d..bd7ab20a 100644 --- a/packages/cli/src/io/print-list.ts +++ b/packages/cli/src/io/print-list.ts @@ -16,6 +16,16 @@ export interface PrintListColumn { optional?: boolean; /** Pretty-mode hint: render this column dim. */ 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 { @@ -23,6 +33,11 @@ export interface PrintListArgs { columns: ReadonlyArray>; groupBy?: keyof Row & 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; mode: KtxOutputMode; io: KtxCliIo; @@ -57,6 +72,15 @@ function isEmpty(value: unknown): boolean { } function printListPlain(args: PrintListArgs): 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) { const cells: string[] = []; for (const col of args.columns) { @@ -114,52 +138,129 @@ function groupRows( return groups; } +interface ResolvedColumns { + badge: ReadonlyArray>; + name?: PrintListColumn; + metric: ReadonlyArray>; + suffix: ReadonlyArray>; +} + +function resolveColumns( + columns: ReadonlyArray>, + groupBy: (keyof Row & string) | undefined, +): ResolvedColumns { + const badge: PrintListColumn[] = []; + const metric: PrintListColumn[] = []; + const suffix: PrintListColumn[] = []; + let name: PrintListColumn | 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(col: PrintListColumn, 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(args: PrintListArgs): 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.bar}\n`); 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; } - // Identify role of each column. - // - 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 resolved = resolveColumns(columns, groupBy); const buckets = groupBy ? groupRows(rows, groupBy) : new Map([['', [...rows]]]); - const nameWidth = nameCol - ? Math.max(...rows.map((r) => String(r[nameCol.key] ?? '').length)) + const nameWidth = resolved.name + ? Math.max(...rows.map((r) => String(r[resolved.name!.key] ?? '').length)) : 0; + const badgeWidths = resolved.badge.map((col) => + Math.max(0, ...rows.map((r) => formatCellValue(col, r).length)), + ); + for (const [groupValue, groupRowList] of buckets) { if (groupBy) { 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) { 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} `); if (metrics.length > 0) segments.push(dim(metrics)); - const optionalSuffix = optionalCols - .map((c) => row[c.key]) - .filter((v) => !isEmpty(v)) - .map((v) => `${SYMBOLS.emDash} ${dim(String(v))}`) + + const optionalSuffix = resolved.suffix + .map((col) => { + 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(' '); if (optionalSuffix.length > 0) segments.push(optionalSuffix); @@ -169,5 +270,5 @@ function printListPretty(args: PrintListArgs): void { } 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`); } diff --git a/packages/cli/src/knowledge.ts b/packages/cli/src/knowledge.ts index b4585c0e..66109301 100644 --- a/packages/cli/src/knowledge.ts +++ b/packages/cli/src/knowledge.ts @@ -6,17 +6,28 @@ import { import { loadKtxProject } from '@ktx/context/project'; import { type LocalKnowledgeScope, + type LocalKnowledgeSearchResult, + type LocalKnowledgeSummary, listLocalKnowledgePages, readLocalKnowledgePage, searchLocalKnowledgePages, writeLocalKnowledgePage, } 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 = - | { 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: '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'; projectDir: string; @@ -30,10 +41,27 @@ export type KtxKnowledgeArgs = slRefs: string[]; }; -interface KtxKnowledgeIo { - stdout: { write(chunk: string): void }; - stderr: { write(chunk: string): void }; -} +type KtxKnowledgeIo = import('./cli-runtime.js').KtxCliIo; + +const WIKI_LIST_COLUMNS: ReadonlyArray> = [ + { key: 'scope', label: 'SCOPE', plain: '' }, + { key: 'key', label: 'KEY', plain: '' }, + { key: 'summary', label: 'SUMMARY', plain: '', optional: true, dim: true }, +]; + +const WIKI_SEARCH_COLUMNS: ReadonlyArray> = [ + { + 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 { embeddingService?: KtxEmbeddingPort | null; @@ -62,17 +90,18 @@ export async function runKtxKnowledge( const project = await loadKtxProject({ projectDir: args.projectDir }); if (args.command === 'list') { const pages = await listLocalKnowledgePages(project, { userId: args.userId }); - if (args.json) { - writeJsonResult(io, { - kind: 'list', - data: { items: pages }, - meta: { command: 'wiki list' }, - }); - return 0; - } - for (const page of pages) { - io.stdout.write(`${page.scope}\t${page.key}\t${page.summary}\n`); - } + const mode = resolveOutputMode({ explicit: args.output, json: args.json, io }); + printList({ + rows: pages, + columns: WIKI_LIST_COLUMNS, + groupBy: 'scope', + emptyMessage: `No local wiki pages found in ${project.projectDir}`, + emptyHint: 'Add Markdown files under wiki/ or run `ktx ingest `.', + unit: 'page', + command: 'wiki list', + mode, + io, + }); return 0; } if (args.command === 'read') { @@ -101,30 +130,27 @@ export async function runKtxKnowledge( embeddingService: wikiSearchEmbeddingService(project, deps), limit: args.limit, }); - if (args.json) { - writeJsonResult(io, { - kind: 'list', - data: { items: results }, - meta: { command: 'wiki search' }, - }); - return 0; - } - if (results.length === 0) { + 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.'; + if (results.length === 0 && mode !== 'json') { const pages = await listLocalKnowledgePages(project, { userId: args.userId }); if (pages.length === 0) { - io.stderr.write( - `No local wiki pages found in ${project.projectDir}. Add Markdown files under wiki/ or run \`ktx ingest \`.\n`, - ); - } else { - io.stderr.write( - `No local wiki pages matched "${args.query}". Run \`ktx wiki list\` to inspect available pages.\n`, - ); + emptyMessage = `No local wiki pages found in ${project.projectDir}`; + emptyHint = 'Add Markdown files under wiki/ or run `ktx ingest `.'; } - return 0; - } - for (const result of results) { - io.stdout.write(`${result.score}\t${result.scope}\t${result.key}\t${result.summary}\n`); } + printList({ + rows: results, + columns: WIKI_SEARCH_COLUMNS, + groupBy: 'scope', + emptyMessage, + emptyHint, + unit: 'page', + command: 'wiki search', + mode, + io, + }); return 0; } diff --git a/packages/cli/src/sl.ts b/packages/cli/src/sl.ts index baff239b..59c2a6f5 100644 --- a/packages/cli/src/sl.ts +++ b/packages/cli/src/sl.ts @@ -17,6 +17,7 @@ import { type LocalSlSourceSummary, type SemanticLayerQueryInput, } from '@ktx/context/sl'; +import type { PrintListColumn } from './io/print-list.js'; import { createManagedPythonSemanticLayerComputePort, type KtxManagedPythonInstallPolicy, @@ -80,6 +81,24 @@ function slSearchEmbeddingService(project: KtxLocalProject, deps: KtxSlDeps): Kt return provider ? new KtxIngestEmbeddingPortAdapter(provider) : null; } +async function printSlSources(input: { + rows: ReadonlyArray; + command: 'sl list'; + output?: string; + json?: boolean; + io: KtxSlIo; + emptyMessage: string; + emptyHint?: string; +}): Promise; +async function printSlSources(input: { + rows: ReadonlyArray; + command: 'sl search'; + output?: string; + json?: boolean; + io: KtxSlIo; + emptyMessage: string; + emptyHint?: string; +}): Promise; async function printSlSources(input: { rows: ReadonlyArray; command: 'sl list' | 'sl search'; @@ -87,22 +106,58 @@ async function printSlSources(input: { json?: boolean; io: KtxSlIo; emptyMessage: string; + emptyHint?: string; }): Promise { 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: [ + + if (input.command === 'sl search') { + const searchColumns: ReadonlyArray> = [ + { + key: 'score', + label: 'SCORE', + plain: 'score=', + role: 'badge', + prettyFormat: (value) => `${Math.round(Number(value) * 100)}%`, + dim: true, + }, { 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({ + rows: input.rows as ReadonlyArray, + columns: searchColumns, + groupBy: 'connectionId', + emptyMessage: input.emptyMessage, + emptyHint: input.emptyHint, + unit: 'source', + command: input.command, + mode, + io: input.io, + }); + return; + } + + const listColumns: ReadonlyArray> = [ + { 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({ + rows: input.rows as ReadonlyArray, + columns: listColumns, groupBy: 'connectionId', emptyMessage: input.emptyMessage, + emptyHint: input.emptyHint, + unit: 'source', command: input.command, mode, io: input.io, @@ -142,6 +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.', command: 'sl search', output: args.output, json: args.json,