Merge branch 'main' into copy-claude-code-backend-spec

This commit is contained in:
Andrey Avtomonov 2026-05-16 01:53:07 +02:00 committed by GitHub
commit 89b934cefa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1866 additions and 318 deletions

View file

@ -315,14 +315,14 @@ function padVisual(text: string, width: number): string {
}
function renderTestAll(io: KtxCliIo, rows: ReadonlyArray<ConnectionTestRow>): void {
io.stdout.write(`${SYMBOLS.barStart} connection test --all\n`);
io.stdout.write(`${SYMBOLS.bar}\n`);
io.stdout.write(`${bold('connection test --all')}\n`);
if (rows.length === 0) {
io.stdout.write(`${SYMBOLS.barEnd} No connections configured. Run \`ktx setup\` to add one.\n`);
io.stdout.write(`\n No connections configured. Run \`ktx setup\` to add one.\n\n`);
return;
}
io.stdout.write('\n');
const okLabel = green('✓ ok');
const failLabel = red('✗ failed');
const idWidth = Math.max(...rows.map((r) => r.connectionId.length));
@ -334,17 +334,17 @@ function renderTestAll(io: KtxCliIo, rows: ReadonlyArray<ConnectionTestRow>): vo
const driver = dim(padVisual(row.driver, driverWidth));
const status = padVisual(row.ok ? okLabel : failLabel, statusWidth);
const detail = dim(row.detail);
io.stdout.write(`${SYMBOLS.bar} ${SYMBOLS.item} ${id} ${driver} ${status} ${detail}\n`);
io.stdout.write(` ${id} ${driver} ${status} ${detail}\n`);
}
const failed = rows.filter((r) => !r.ok).length;
const passed = rows.length - failed;
io.stdout.write(`${SYMBOLS.bar}\n`);
io.stdout.write('\n');
const summary =
failed === 0
? `${rows.length} tested ${dim(SYMBOLS.middot)} ${green(`${passed} passed`)}`
: `${rows.length} tested ${dim(SYMBOLS.middot)} ${green(`${passed} passed`)} ${dim(SYMBOLS.middot)} ${red(`${failed} failed`)}`;
io.stdout.write(`${SYMBOLS.barEnd} ${summary}\n`);
io.stdout.write(`${summary}\n`);
}
async function runTestAll(

View file

@ -139,7 +139,7 @@ function stripAnsi(s: string): string {
}
describe('printList — pretty mode', () => {
it('renders a Clack-style header, grouped rows, and footer', () => {
it('renders a bold header, grouped rows, and footer', () => {
const r = recorder();
printList<SlRow>({
rows: [ORDERS, USERS],
@ -152,13 +152,14 @@ describe('printList — pretty mode', () => {
io: r.io,
});
const out = stripAnsi(r.out());
expect(out).toContain(`${SYMBOLS.barStart} sl list`);
expect(out).toContain(`${SYMBOLS.group} warehouse`);
expect(out).toContain('sl list');
expect(out).toContain('warehouse');
expect(out).toContain('(2 sources)');
expect(out).toMatch(new RegExp(`${escapeRegExp(SYMBOLS.item)} orders\\s+5 cols ${escapeRegExp(SYMBOLS.middot)} 3 measures ${escapeRegExp(SYMBOLS.middot)} 1 join\\b`));
expect(out).toMatch(new RegExp(`${escapeRegExp(SYMBOLS.item)} users\\s+8 cols ${escapeRegExp(SYMBOLS.middot)} 2 measures ${escapeRegExp(SYMBOLS.middot)} 2 joins\\b`));
expect(out).toMatch(/orders\s+5 cols/);
expect(out).toMatch(new RegExp(`3 measures ${escapeRegExp(SYMBOLS.middot)} 1 join\\b`));
expect(out).toMatch(new RegExp(`2 measures ${escapeRegExp(SYMBOLS.middot)} 2 joins\\b`));
expect(out).toContain(`${SYMBOLS.emDash} User profile + auth`);
expect(out).toContain(`${SYMBOLS.barEnd} 2 sources`);
expect(out).toContain('2 sources');
});
it('renders an empty-state message when no rows', () => {
@ -174,11 +175,11 @@ describe('printList — pretty mode', () => {
io: r.io,
});
const out = stripAnsi(r.out());
expect(out).toContain(`${SYMBOLS.barStart} sl list`);
expect(out).toContain(`${SYMBOLS.barEnd} No semantic-layer sources found in /tmp/proj`);
expect(out).toContain('sl list');
expect(out).toContain('No semantic-layer sources found in /tmp/proj');
});
it('renders empty-state with hint and zero-count footer when emptyHint is provided', () => {
it('renders empty-state with hint when emptyHint is provided', () => {
const r = recorder();
printList<SlRow>({
rows: [],
@ -192,9 +193,8 @@ describe('printList — pretty mode', () => {
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`);
expect(out).toContain('No sources matched "foo"');
expect(out).toContain('Run `ktx sl list` to see available sources.');
});
it('singularizes the footer when there is one row', () => {
@ -210,7 +210,7 @@ describe('printList — pretty mode', () => {
io: r.io,
});
const out = stripAnsi(r.out());
expect(out).toContain(`${SYMBOLS.barEnd} 1 source`);
expect(out).toContain('1 source');
});
it('uses the provided unit in pluralization and group counts', () => {
@ -236,10 +236,10 @@ describe('printList — pretty mode', () => {
});
const out = stripAnsi(r.out());
expect(out).toContain('(2 pages)');
expect(out).toContain(`${SYMBOLS.barEnd} 2 pages`);
expect(out).toContain('2 pages');
});
it('renders a leading dim badge column with prettyFormat in pretty mode', () => {
it('renders a leading 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>> = [
@ -270,9 +270,8 @@ describe('printList — pretty mode', () => {
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+`));
expect(out).toMatch(/87%\s+alpha\s+/);
expect(out).toMatch(/4%\s+beta\s+/);
});
it('emits the badge column in plain mode using its plain prefix', () => {

View file

@ -18,7 +18,7 @@ export interface PrintListColumn<Row> {
dim?: boolean;
/**
* Pretty-mode role override. When omitted, role is auto-detected:
* - `'badge'` leading dim cell before the name column (right-padded across rows).
* - `'badge'` leading 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`.
@ -202,20 +202,19 @@ function printListPretty<Row extends object>(args: PrintListArgs<Row>): void {
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`);
io.stdout.write(`${bold(command)}\n`);
if (rows.length === 0) {
io.stdout.write(`\n ${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`);
io.stdout.write(` ${dim(emptyHint)}\n`);
}
io.stdout.write('\n');
return;
}
io.stdout.write('\n');
const resolved = resolveColumns(columns, groupBy);
const buckets = groupBy ? groupRows(rows, groupBy) : new Map<string, Row[]>([['', [...rows]]]);
@ -231,14 +230,14 @@ function printListPretty<Row extends object>(args: PrintListArgs<Row>): void {
for (const [groupValue, groupRowList] of buckets) {
if (groupBy) {
io.stdout.write(
`${SYMBOLS.bar} ${SYMBOLS.group} ${bold(groupValue)} ${dim(`(${pluralize(groupRowList.length, unit)})`)}\n`,
` ${bold(groupValue)} ${dim(`(${pluralize(groupRowList.length, unit)})`)}\n`,
);
}
for (const row of groupRowList) {
const segments: string[] = [];
resolved.badge.forEach((col, idx) => {
segments.push(dim(formatCellValue(col, row).padStart(badgeWidths[idx] ?? 0)));
segments.push(formatCellValue(col, row).padStart(badgeWidths[idx] ?? 0));
});
if (resolved.name) {
@ -265,10 +264,10 @@ function printListPretty<Row extends object>(args: PrintListArgs<Row>): void {
if (optionalSuffix.length > 0) segments.push(optionalSuffix);
const indent = groupBy ? ' ' : ' ';
io.stdout.write(`${SYMBOLS.bar}${indent}${SYMBOLS.item} ${segments.join(' ')}\n`);
io.stdout.write(`${indent}${segments.join(' ')}\n`);
}
io.stdout.write('\n');
}
io.stdout.write(`${SYMBOLS.bar}\n`);
io.stdout.write(`${SYMBOLS.barEnd} ${pluralize(rows.length, unit)}\n`);
io.stdout.write(`${pluralize(rows.length, unit)}\n`);
}

View file

@ -15,11 +15,6 @@ function detectUnicodeSupport(env: NodeJS.ProcessEnv = process.env): boolean {
const unicode = detectUnicodeSupport();
export const SYMBOLS = {
bar: unicode ? '│' : '|',
barStart: unicode ? '◇' : 'o',
barEnd: unicode ? '└' : '—',
group: unicode ? '●' : '*',
item: unicode ? '◆' : '*',
middot: unicode ? '·' : '-',
emDash: unicode ? '—' : '--',
} as const;
@ -43,3 +38,7 @@ export function green(text: string): string {
export function red(text: string): string {
return styleText('red', text);
}
export function yellow(text: string): string {
return styleText('yellow', text);
}

View file

@ -91,6 +91,9 @@ describe('setup agents', () => {
expect(skill).toContain('must not print secrets');
expect(skill).toContain('status --json');
expect(skill).toContain('sl list --json');
expect(skill).toContain('sl query');
expect(skill).toContain('--format json');
expect(skill).not.toContain('sl query --json');
expect(skill).not.toContain('agent ');
expect(skill).not.toContain('sql execute');
expect(await readKtxAgentInstallManifest(tempDir)).toMatchObject({
@ -150,6 +153,8 @@ describe('setup agents', () => {
expect(skill).not.toContain('`ktx agent');
expect(skill).toContain('status --json');
expect(skill).toContain('sl query');
expect(skill).toContain('--format json');
expect(skill).not.toContain('sl query --json');
expect(skill).not.toContain('sql execute');
});

View file

@ -310,7 +310,8 @@ function ktxCommandLine(launcher: KtxCliLauncher, args: string[]): string {
}
function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLauncher }): string {
const projectDirArgs = ['--json', '--project-dir', input.projectDir];
const projectDirArgs = ['--project-dir', input.projectDir];
const jsonProjectDirArgs = ['--json', ...projectDirArgs];
return [
'---',
'name: ktx',
@ -327,9 +328,9 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
'',
'Available commands:',
'',
`- \`${ktxCommandLine(input.launcher, ['status', ...projectDirArgs])}\``,
`- \`${ktxCommandLine(input.launcher, ['sl', 'list', ...projectDirArgs])}\``,
`- \`${ktxCommandLine(input.launcher, ['sl', 'search', '<text>', ...projectDirArgs, '--connection-id', '<id>'])}\``,
`- \`${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',
'query',
@ -338,11 +339,13 @@ function cliInstructionContent(input: { projectDir: string; launcher: KtxCliLaun
'<id>',
'--query-file',
'<path>',
'--format',
'json',
'--execute',
'--max-rows',
'100',
])}\``,
`- \`${ktxCommandLine(input.launcher, ['wiki', 'search', '<query>', ...projectDirArgs, '--limit', '10'])}\``,
`- \`${ktxCommandLine(input.launcher, ['wiki', 'search', '<query>', ...jsonProjectDirArgs, '--limit', '10'])}\``,
'',
'Use semantic-layer queries before direct database access. Do not print secrets or credential references.',
'',

View file

@ -15,6 +15,13 @@ import {
ignoredClaudeCodePromptCachingFields,
} from './claude-code-prompt-caching.js';
import type { DoctorCheck } from './doctor.js';
import {
bold as _bold,
dim as _dim,
green,
red,
yellow,
} from './io/symbols.js';
import { KTX_NEXT_STEP_DIRECT_COMMANDS } from './next-steps.js';
type ProjectStatusLevel = 'ok' | 'warn' | 'fail';
@ -747,13 +754,11 @@ export async function buildProjectStatus(project: KtxLocalProject, options: Buil
const SYMBOL: Record<ProjectStatusLevel, string> = { ok: '✓', warn: '⚠', fail: '✗' };
function ansi(useColor: boolean, code: string, text: string, closer = '39'): string {
return useColor ? `\u001b[${code}m${text}\u001b[${closer}m` : text;
function colorForLevel(useColor: boolean, level: ProjectStatusLevel, text: string): string {
if (!useColor) return text;
return level === 'ok' ? green(text) : level === 'warn' ? yellow(text) : red(text);
}
function colorFor(level: ProjectStatusLevel): string {
return level === 'ok' ? '32' : level === 'warn' ? '33' : '31';
}
function abbreviateHome(filePath: string, env: NodeJS.ProcessEnv): string {
const home = env.HOME;
@ -775,9 +780,9 @@ export function renderProjectStatus(status: ProjectStatus, options: RenderProjec
const verbose = options.verbose ?? false;
const useColor = options.useColor ?? false;
const env = options.env ?? process.env;
const dim = (s: string) => ansi(useColor, '2', s, '22');
const bold = (s: string) => ansi(useColor, '1', s, '22');
const color = (level: ProjectStatusLevel, s: string) => ansi(useColor, colorFor(level), s);
const dim = useColor ? _dim : (s: string) => s;
const bold = useColor ? _bold : (s: string) => s;
const color = (level: ProjectStatusLevel, s: string) => colorForLevel(useColor, level, s);
const sym = (level: ProjectStatusLevel) => color(level, SYMBOL[level]);
const lines: string[] = [];