feat(cli): add --fast flag and Local data section to ktx status (#198)

Add --fast to skip checks requiring external communication (Claude Code
auth probe and Postgres pg_stat_statements probe); skipped checks render
as `-` and carry `"status": "skipped"` in JSON output. Always show a new
Local data section sourced from .ktx/db.sqlite (ingest run counts and
last-completed per connection, knowledge page counts by scope, semantic
layer source/dictionary value counts) plus on-disk sizes for .ktx/db.sqlite,
.ktx/cache/, raw-sources/, wiki/global/, and semantic-layer/. Wrap the
remaining slow probes in a @clack/prompts spinner when stdout is a TTY.
This commit is contained in:
Andrey Avtomonov 2026-05-21 14:13:03 +02:00 committed by GitHub
parent 50c7bbc957
commit 1c7131c6c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 740 additions and 13 deletions

View file

@ -21,6 +21,7 @@ ktx status [options]
| `--json` | Print JSON output | `false` | | `--json` | Print JSON output | `false` |
| `-v`, `--verbose` | Show every check, including passing ones | `false` | | `-v`, `--verbose` | Show every check, including passing ones | `false` |
| `--validate` | Only validate the `ktx.yaml` schema; skip readiness checks | `false` | | `--validate` | Only validate the `ktx.yaml` schema; skip readiness checks | `false` |
| `--fast` | Skip checks that require external communication (Postgres query-history probe, Claude Code auth probe) | `false` |
| `--no-input` | Disable interactive terminal input | - | | `--no-input` | Disable interactive terminal input | - |
## Examples ## Examples
@ -38,6 +39,9 @@ ktx status --verbose
# Validate ktx.yaml without running readiness checks # Validate ktx.yaml without running readiness checks
ktx status --validate ktx status --validate
# Skip slow probes (Postgres pg_stat_statements, Claude Code auth)
ktx status --fast
# Check a project from another directory # Check a project from another directory
ktx status --project-dir ./analytics ktx status --project-dir ./analytics
``` ```
@ -49,7 +53,16 @@ ktx status --project-dir ./analytics
For `llm.provider.backend: claude-code`, `ktx status` checks that the local For `llm.provider.backend: claude-code`, `ktx status` checks that the local
Claude Code session is usable. If auth fails, run the Claude Code CLI login Claude Code session is usable. If auth fails, run the Claude Code CLI login
flow, then rerun `ktx status`. flow, then rerun `ktx status`. Use `--fast` to skip this probe (useful in CI
or offline contexts); skipped checks render as `-` and carry
`"status": "skipped"` in JSON output.
A `Local data` section summarises what the project has accumulated locally:
ingest run counts, last completed timestamp per connection, knowledge page
counts by scope, semantic-layer source and dictionary value counts, and the
on-disk size of `.ktx/db.sqlite`, `.ktx/cache/`, `raw-sources/`, `wiki/global/`,
and `semantic-layer/`. These are read from `.ktx/db.sqlite` and local file
stats, and are always shown (they do not require external communication).
```json ```json
{ {

View file

@ -18,10 +18,11 @@ export function registerStatusCommands(program: Command, context: KtxCliCommandC
.option('--json', 'Print JSON output', false) .option('--json', 'Print JSON output', false)
.option('-v, --verbose', 'Show every check, including passing ones', false) .option('-v, --verbose', 'Show every check, including passing ones', false)
.option('--validate', 'Only validate the ktx.yaml schema; skip readiness checks', false) .option('--validate', 'Only validate the ktx.yaml schema; skip readiness checks', false)
.option('--fast', 'Skip checks that require external communication (DB probes, auth probes)', false)
.option('--no-input', 'Disable interactive terminal input') .option('--no-input', 'Disable interactive terminal input')
.action( .action(
async ( async (
options: { json?: boolean; verbose?: boolean; validate?: boolean; input?: boolean }, options: { json?: boolean; verbose?: boolean; validate?: boolean; fast?: boolean; input?: boolean },
command, command,
) => { ) => {
const runner = context.deps.doctor ?? (await import('../doctor.js')).runKtxDoctor; const runner = context.deps.doctor ?? (await import('../doctor.js')).runKtxDoctor;
@ -64,6 +65,7 @@ export function registerStatusCommands(program: Command, context: KtxCliCommandC
projectDir: resolveCommandProjectDir(command), projectDir: resolveCommandProjectDir(command),
outputMode: outputMode(options), outputMode: outputMode(options),
verbose: options.verbose === true, verbose: options.verbose === true,
fast: options.fast === true,
...inputMode(options), ...inputMode(options),
}, },
context.io, context.io,

View file

@ -42,6 +42,7 @@ export type KtxDoctorArgs =
outputMode: KtxDoctorOutputMode; outputMode: KtxDoctorOutputMode;
inputMode?: KtxDoctorInputMode; inputMode?: KtxDoctorInputMode;
verbose?: boolean; verbose?: boolean;
fast?: boolean;
} }
| { | {
command: 'validate'; command: 'validate';
@ -619,7 +620,15 @@ export async function runKtxDoctor(
return 1; return 1;
} }
const project = await loadKtxProject({ projectDir: args.projectDir }); const project = await loadKtxProject({ projectDir: args.projectDir });
const projectStatus = await buildProjectStatus(project, { ...deps, configIssues: validation.issues }); const fast = args.fast ?? false;
const useSpinner =
!fast && args.outputMode === 'plain' && io.stdout.isTTY === true;
const projectStatus = await buildProjectStatus(project, {
...deps,
configIssues: validation.issues,
fast,
useSpinner,
});
const verbose = args.verbose ?? false; const verbose = args.verbose ?? false;
const toolchainChecks = verbose ? await runSetupChecks() : undefined; const toolchainChecks = verbose ? await runSetupChecks() : undefined;
if (args.outputMode === 'json') { if (args.outputMode === 'json') {

View file

@ -1064,7 +1064,7 @@ describe('runKtxCli', () => {
expect(setup).not.toHaveBeenCalled(); expect(setup).not.toHaveBeenCalled();
expect(doctor).toHaveBeenCalledWith( expect(doctor).toHaveBeenCalledWith(
{ command: 'project', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled', verbose: false }, { command: 'project', projectDir: tempDir, outputMode: 'json', inputMode: 'disabled', verbose: false, fast: false },
statusIo.io, statusIo.io,
); );
expect(statusIo.stderr()).toBe(''); expect(statusIo.stderr()).toBe('');

View file

@ -1,6 +1,14 @@
import { describe, expect, it } from 'vitest'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { buildDefaultKtxProjectConfig, type KtxLocalProject, type KtxProjectConfig } from '@ktx/context/project'; import { buildDefaultKtxProjectConfig, type KtxLocalProject, type KtxProjectConfig } from '@ktx/context/project';
import { buildProjectStatus } from './status-project.js'; import {
buildLocalStatsStatus,
buildProjectStatus,
renderProjectStatus,
} from './status-project.js';
function projectWithConfig(config: KtxProjectConfig): KtxLocalProject { function projectWithConfig(config: KtxProjectConfig): KtxLocalProject {
return { return {
@ -124,3 +132,278 @@ describe('buildProjectStatus embeddings', () => {
expect(status.verdictReason).toMatch(/embedding credentials missing/); expect(status.verdictReason).toMatch(/embedding credentials missing/);
}); });
}); });
function withPostgresQueryHistory(config: KtxProjectConfig): KtxProjectConfig {
return {
...config,
connections: {
...config.connections,
analytics: {
driver: 'postgres',
url: 'env:ANALYTICS_DATABASE_URL',
context: { queryHistory: { enabled: true } },
} as KtxProjectConfig['connections'][string],
},
};
}
describe('buildProjectStatus --fast', () => {
it('skips claude-code probe and Postgres query-history probe', async () => {
let claudeProbeCalls = 0;
let pgProbeCalls = 0;
const project = projectWithConfig(withPostgresQueryHistory(baseProjectConfig()));
const status = await buildProjectStatus(project, {
env: { ANALYTICS_DATABASE_URL: 'postgres://example' },
fast: true,
claudeCodeAuthProbe: async () => {
claudeProbeCalls += 1;
return { ok: true };
},
postgresQueryHistoryProbe: async () => {
pgProbeCalls += 1;
throw new Error('should not be called');
},
});
expect(claudeProbeCalls).toBe(0);
expect(pgProbeCalls).toBe(0);
expect(status.llm.status).toBe('skipped');
expect(status.llm.detail).toMatch(/--fast/);
expect(status.queryHistory).toHaveLength(1);
expect(status.queryHistory[0]).toMatchObject({
connection: 'analytics',
status: 'skipped',
});
expect(status.verdict).not.toBe('blocked');
});
it('does not call probes lazily when fast and reports skipped in render', async () => {
const project = projectWithConfig(withPostgresQueryHistory(baseProjectConfig()));
const status = await buildProjectStatus(project, {
env: { ANALYTICS_DATABASE_URL: 'postgres://example' },
fast: true,
claudeCodeAuthProbe: stubClaudeCodeAuthProbe,
postgresQueryHistoryProbe: async () => {
throw new Error('should not be called');
},
});
const rendered = renderProjectStatus(status, { verbose: false, useColor: false });
expect(rendered).toContain('auth probe skipped (--fast)');
expect(rendered).toContain('pg_stat_statements probe skipped (--fast)');
});
});
describe('buildLocalStatsStatus', () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'ktx-status-stats-'));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
function projectIn(dir: string): KtxLocalProject {
return {
projectDir: dir,
configPath: join(dir, 'ktx.yaml'),
config: baseProjectConfig(),
coreConfig: {} as KtxLocalProject['coreConfig'],
git: {} as KtxLocalProject['git'],
fileStore: {} as KtxLocalProject['fileStore'],
};
}
it('returns unavailable when .ktx/db.sqlite is missing', async () => {
const stats = await buildLocalStatsStatus(projectIn(tempDir));
expect(stats.unavailable).toMatch(/no \.ktx\/db\.sqlite/);
expect(stats.ingest.totalCompletedRuns).toBe(0);
expect(stats.projectDir.dbSqliteBytes).toBeNull();
});
it('reads counts from a seeded SQLite DB and walks projectDir folders', async () => {
await mkdir(join(tempDir, '.ktx'), { recursive: true });
await mkdir(join(tempDir, '.ktx', 'cache'), { recursive: true });
await writeFile(join(tempDir, '.ktx', 'cache', 'a.bin'), Buffer.alloc(2048));
await mkdir(join(tempDir, 'raw-sources', 'analytics'), { recursive: true });
await writeFile(join(tempDir, 'raw-sources', 'analytics', 'snap.json'), 'x'.repeat(100));
await writeFile(join(tempDir, 'raw-sources', 'analytics', 'snap.bin'), Buffer.alloc(512));
await mkdir(join(tempDir, 'wiki', 'global', 'sub'), { recursive: true });
await writeFile(join(tempDir, 'wiki', 'global', 'one.md'), '# one');
await writeFile(join(tempDir, 'wiki', 'global', 'sub', 'two.md'), '# two');
await mkdir(join(tempDir, 'semantic-layer'), { recursive: true });
await writeFile(join(tempDir, 'semantic-layer', 'orders.yaml'), 'name: orders');
await writeFile(join(tempDir, 'semantic-layer', 'users.yml'), 'name: users');
const dbPath = join(tempDir, '.ktx', 'db.sqlite');
const db = new Database(dbPath);
db.exec(`
CREATE TABLE local_ingest_reports (
run_id TEXT PRIMARY KEY,
adapter TEXT NOT NULL,
connection_id TEXT NOT NULL,
status TEXT NOT NULL,
completed_at TEXT NOT NULL,
raw_content_hashes_json TEXT NOT NULL,
body_json TEXT NOT NULL
);
INSERT INTO local_ingest_reports VALUES
('r1', 'live-database', 'analytics', 'done', '2026-04-01T10:00:00Z', '{}', '{}'),
('r2', 'live-database', 'analytics', 'done', '2026-05-10T10:00:00Z', '{}', '{}'),
('r3', 'notion', 'docs', 'done', '2026-05-01T10:00:00Z', '{}', '{}'),
('r4', 'notion', 'docs', 'error', '2026-05-02T10:00:00Z', '{}', '{}');
CREATE TABLE knowledge_pages (
path TEXT PRIMARY KEY,
key TEXT NOT NULL,
scope TEXT NOT NULL,
scope_id TEXT,
summary TEXT NOT NULL,
content TEXT NOT NULL,
tags TEXT NOT NULL,
search_text TEXT NOT NULL DEFAULT '',
embedding_json TEXT
);
INSERT INTO knowledge_pages VALUES
('a.md', 'a', 'GLOBAL', NULL, '', '', '[]', '', NULL),
('b.md', 'b', 'GLOBAL', NULL, '', '', '[]', '', NULL),
('c.md', 'c', 'PROJECT', NULL, '', '', '[]', '', NULL);
CREATE TABLE local_sl_sources (
connection_id TEXT NOT NULL,
source_name TEXT NOT NULL,
search_text TEXT NOT NULL,
embedding_json TEXT,
content_hash TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (connection_id, source_name)
);
INSERT INTO local_sl_sources VALUES
('analytics', 'orders', '', NULL, NULL, '2026-05-10T10:00:00Z'),
('analytics', 'users', '', NULL, NULL, '2026-05-10T10:00:00Z');
CREATE TABLE local_sl_dictionary_values (
connection_id TEXT NOT NULL,
source_name TEXT NOT NULL,
column_name TEXT NOT NULL,
value TEXT NOT NULL,
value_lower TEXT NOT NULL,
cardinality INTEGER,
updated_at TEXT NOT NULL,
PRIMARY KEY (connection_id, source_name, column_name, value)
);
INSERT INTO local_sl_dictionary_values VALUES
('analytics', 'orders', 'status', 'open', 'open', 1, '2026-05-10T10:00:00Z'),
('analytics', 'orders', 'status', 'closed', 'closed', 1, '2026-05-10T10:00:00Z');
`);
db.close();
const stats = await buildLocalStatsStatus(projectIn(tempDir));
expect(stats.unavailable).toBeUndefined();
expect(stats.ingest.totalCompletedRuns).toBe(3);
expect(stats.ingest.perConnection).toEqual([
{ connectionId: 'analytics', adapter: 'live-database', lastCompletedAt: '2026-05-10T10:00:00Z' },
{ connectionId: 'docs', adapter: 'notion', lastCompletedAt: '2026-05-01T10:00:00Z' },
]);
expect(stats.knowledgePages).toEqual([
{ scope: 'GLOBAL', count: 2 },
{ scope: 'PROJECT', count: 1 },
]);
expect(stats.semanticLayer).toEqual([
{ connectionId: 'analytics', sourceCount: 2, dictionaryValueCount: 2 },
]);
expect(stats.projectDir.dbSqliteBytes).toBeGreaterThan(0);
expect(stats.projectDir.ktxCacheBytes).toBe(2048);
expect(stats.projectDir.rawSources).toEqual({ fileCount: 2, bytes: 612 });
expect(stats.projectDir.wikiGlobalMarkdownCount).toBe(2);
expect(stats.projectDir.semanticLayerYamlCount).toBe(2);
});
it('tolerates a SQLite DB missing some tables', async () => {
await mkdir(join(tempDir, '.ktx'), { recursive: true });
const dbPath = join(tempDir, '.ktx', 'db.sqlite');
const db = new Database(dbPath);
db.exec(`
CREATE TABLE local_ingest_reports (
run_id TEXT PRIMARY KEY,
adapter TEXT NOT NULL,
connection_id TEXT NOT NULL,
status TEXT NOT NULL,
completed_at TEXT NOT NULL,
raw_content_hashes_json TEXT NOT NULL,
body_json TEXT NOT NULL
);
INSERT INTO local_ingest_reports VALUES
('r1', 'live-database', 'analytics', 'done', '2026-05-10T10:00:00Z', '{}', '{}');
`);
db.close();
const stats = await buildLocalStatsStatus(projectIn(tempDir));
expect(stats.unavailable).toBeUndefined();
expect(stats.ingest.totalCompletedRuns).toBe(1);
expect(stats.knowledgePages).toEqual([]);
expect(stats.semanticLayer).toEqual([]);
});
});
describe('renderProjectStatus Local data', () => {
it('renders the Local data section with seeded stats', async () => {
const project = projectWithConfig(baseProjectConfig());
const status = await buildProjectStatus(project, { claudeCodeAuthProbe: stubClaudeCodeAuthProbe });
status.localStats = {
ingest: {
totalCompletedRuns: 3,
perConnection: [
{ connectionId: 'analytics', adapter: 'live-database', lastCompletedAt: new Date(Date.now() - 60 * 60 * 1000).toISOString() },
],
},
knowledgePages: [
{ scope: 'GLOBAL', count: 2 },
{ scope: 'PROJECT', count: 1 },
],
semanticLayer: [
{ connectionId: 'analytics', sourceCount: 12, dictionaryValueCount: 200 },
],
projectDir: {
dbSqliteBytes: 4096,
ktxCacheBytes: 1_048_576,
rawSources: { fileCount: 5, bytes: 200 },
wikiGlobalMarkdownCount: 7,
semanticLayerYamlCount: 3,
},
};
const rendered = renderProjectStatus(status, { useColor: false });
expect(rendered).toContain('Local data');
expect(rendered).toContain('3 completed runs');
expect(rendered).toContain('GLOBAL=2');
expect(rendered).toContain('PROJECT=1');
expect(rendered).toContain('12 sources · 200 dictionary values');
expect(rendered).toContain('db=4.00 KiB');
expect(rendered).toContain('cache=1.00 MiB');
expect(rendered).toContain('wiki=7 md');
expect(rendered).toContain('semantic-layer=3 yaml');
});
it('renders unavailable note when DB is missing', async () => {
const project = projectWithConfig(baseProjectConfig());
const status = await buildProjectStatus(project, { claudeCodeAuthProbe: stubClaudeCodeAuthProbe });
status.localStats = {
ingest: { totalCompletedRuns: 0, perConnection: [] },
knowledgePages: [],
semanticLayer: [],
projectDir: {
dbSqliteBytes: null,
ktxCacheBytes: 0,
rawSources: { fileCount: 0, bytes: 0 },
wikiGlobalMarkdownCount: 0,
semanticLayerYamlCount: 0,
},
unavailable: 'no .ktx/db.sqlite yet',
};
const rendered = renderProjectStatus(status, { useColor: false });
expect(rendered).toContain('Local data');
expect(rendered).toContain('no .ktx/db.sqlite yet');
});
});

View file

@ -1,4 +1,6 @@
import { basename } from 'node:path'; import type { Dirent } from 'node:fs';
import { stat as statAsync, readdir as readdirAsync } from 'node:fs/promises';
import { basename, join } from 'node:path';
import { runClaudeCodeAuthProbe } from '@ktx/context'; import { runClaudeCodeAuthProbe } from '@ktx/context';
import type { import type {
KtxConfigIssue, KtxConfigIssue,
@ -8,6 +10,7 @@ import type {
KtxProjectEmbeddingConfig, KtxProjectEmbeddingConfig,
KtxProjectLlmConfig, KtxProjectLlmConfig,
} from '@ktx/context/project'; } from '@ktx/context/project';
import { ktxLocalStateDbPath } from '@ktx/context/project';
import type { PostgresPgssProbeResult } from '@ktx/context/ingest'; import type { PostgresPgssProbeResult } from '@ktx/context/ingest';
import { import {
formatClaudeCodePromptCachingFix, formatClaudeCodePromptCachingFix,
@ -24,7 +27,7 @@ import {
} from './io/symbols.js'; } from './io/symbols.js';
import { KTX_NEXT_STEP_DIRECT_COMMANDS } from './next-steps.js'; import { KTX_NEXT_STEP_DIRECT_COMMANDS } from './next-steps.js';
type ProjectStatusLevel = 'ok' | 'warn' | 'fail'; type ProjectStatusLevel = 'ok' | 'warn' | 'fail' | 'skipped';
type ProjectVerdict = 'ready' | 'partial' | 'blocked'; type ProjectVerdict = 'ready' | 'partial' | 'blocked';
interface ProjectStatusLine { interface ProjectStatusLine {
@ -99,6 +102,42 @@ function hasOwnField(value: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(value, key); return Object.prototype.hasOwnProperty.call(value, key);
} }
interface LocalStatsIngestPerConnection {
connectionId: string;
adapter: string;
lastCompletedAt: string;
}
interface LocalStatsSemanticLayerEntry {
connectionId: string;
sourceCount: number;
dictionaryValueCount: number;
}
interface LocalStatsKnowledgeEntry {
scope: string;
count: number;
}
interface LocalStatsProjectDir {
dbSqliteBytes: number | null;
ktxCacheBytes: number;
rawSources: { fileCount: number; bytes: number };
wikiGlobalMarkdownCount: number;
semanticLayerYamlCount: number;
}
export interface LocalStatsStatus {
ingest: {
totalCompletedRuns: number;
perConnection: LocalStatsIngestPerConnection[];
};
knowledgePages: LocalStatsKnowledgeEntry[];
semanticLayer: LocalStatsSemanticLayerEntry[];
projectDir: LocalStatsProjectDir;
unavailable?: string;
}
export interface ProjectStatus { export interface ProjectStatus {
projectName: string; projectName: string;
projectDir: string; projectDir: string;
@ -110,6 +149,7 @@ export interface ProjectStatus {
queryHistory: QueryHistoryStatus[]; queryHistory: QueryHistoryStatus[];
pipeline: PipelineStatus; pipeline: PipelineStatus;
warnings: WarningItem[]; warnings: WarningItem[];
localStats: LocalStatsStatus;
verdict: ProjectVerdict; verdict: ProjectVerdict;
verdictReason: string; verdictReason: string;
nextActions: string[]; nextActions: string[];
@ -152,6 +192,8 @@ async function buildLlmStatus(
projectDir: string; projectDir: string;
env: NodeJS.ProcessEnv; env: NodeJS.ProcessEnv;
claudeCodeAuthProbe?: ClaudeCodeAuthProbe; claudeCodeAuthProbe?: ClaudeCodeAuthProbe;
fast?: boolean;
useSpinner?: boolean;
}, },
): Promise<LlmStatus> { ): Promise<LlmStatus> {
const env = options.env; const env = options.env;
@ -208,8 +250,18 @@ async function buildLlmStatus(
} }
if (backend === 'claude-code') { if (backend === 'claude-code') {
const modelName = model ?? 'sonnet'; const modelName = model ?? 'sonnet';
if (options.fast === true) {
return {
backend,
model: modelName,
status: 'skipped',
detail: 'auth probe skipped (--fast)',
};
}
const probe = options.claudeCodeAuthProbe ?? runClaudeCodeAuthProbe; const probe = options.claudeCodeAuthProbe ?? runClaudeCodeAuthProbe;
const auth = await probe({ projectDir: options.projectDir, model: modelName, env }); const auth = await withSpinner(options.useSpinner === true, 'Probing Claude Code authentication', () =>
probe({ projectDir: options.projectDir, model: modelName, env }),
);
if (auth.ok) { if (auth.ok) {
return { return {
backend, backend,
@ -461,8 +513,22 @@ async function buildQueryHistoryStatus(
continue; continue;
} }
if (options.fast === true) {
statuses.push({
connection: connectionId,
dialect: 'postgres',
status: 'skipped',
detail: 'pg_stat_statements probe skipped (--fast)',
});
continue;
}
try { try {
const result = await probe({ projectDir: project.projectDir, connectionId, connection, env }); const result = await withSpinner(
options.useSpinner === true,
`Probing pg_stat_statements on ${connectionId}`,
() => probe({ projectDir: project.projectDir, connectionId, connection, env }),
);
statuses.push({ statuses.push({
connection: connectionId, connection: connectionId,
dialect: 'postgres', dialect: 'postgres',
@ -641,7 +707,7 @@ function buildVerdict(
reasons.push('embedding credentials missing'); reasons.push('embedding credentials missing');
} }
} }
const missing = connections.filter((c) => c.status !== 'ok').length; const missing = connections.filter((c) => c.status !== 'ok' && c.status !== 'skipped').length;
if (missing > 0) reasons.push(`${missing} connection${missing === 1 ? '' : 's'} need configuration`); if (missing > 0) reasons.push(`${missing} connection${missing === 1 ? '' : 's'} need configuration`);
const queryHistoryWarnings = queryHistory.filter((entry) => entry.status === 'warn').length; const queryHistoryWarnings = queryHistory.filter((entry) => entry.status === 'warn').length;
if (queryHistoryWarnings > 0) { if (queryHistoryWarnings > 0) {
@ -669,6 +735,27 @@ export interface BuildProjectStatusOptions {
postgresQueryHistoryProbe?: PostgresQueryHistoryProbe; postgresQueryHistoryProbe?: PostgresQueryHistoryProbe;
claudeCodeAuthProbe?: ClaudeCodeAuthProbe; claudeCodeAuthProbe?: ClaudeCodeAuthProbe;
configIssues?: KtxConfigIssue[]; configIssues?: KtxConfigIssue[];
fast?: boolean;
useSpinner?: boolean;
}
async function withSpinner<T>(
useSpinner: boolean,
label: string,
run: () => Promise<T>,
): Promise<T> {
if (!useSpinner) return run();
const { spinner } = await import('@clack/prompts');
const s = spinner();
s.start(label);
try {
const result = await run();
s.stop(label);
return result;
} catch (error) {
s.stop(`${label} — failed`);
throw error;
}
} }
function buildConfigStatus(issues: KtxConfigIssue[] | undefined): ConfigStatus { function buildConfigStatus(issues: KtxConfigIssue[] | undefined): ConfigStatus {
@ -683,6 +770,219 @@ function buildConfigStatus(issues: KtxConfigIssue[] | undefined): ConfigStatus {
}; };
} }
interface DirSummary {
fileCount: number;
bytes: number;
}
async function summarizeDir(
dir: string,
filter?: (entry: Dirent, fullPath: string) => boolean,
maxDepth = 10,
): Promise<DirSummary> {
let fileCount = 0;
let bytes = 0;
const walk = async (current: string, depth: number): Promise<void> => {
if (depth > maxDepth) return;
let entries: Dirent[];
try {
entries = await readdirAsync(current, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const full = join(current, entry.name);
if (entry.isDirectory()) {
await walk(full, depth + 1);
continue;
}
if (!entry.isFile()) continue;
if (filter && !filter(entry, full)) continue;
try {
const s = await statAsync(full);
fileCount += 1;
bytes += s.size;
} catch {
// skip individual stat failures
}
}
};
await walk(dir, 0);
return { fileCount, bytes };
}
function isMarkdownEntry(entry: Dirent): boolean {
return entry.isFile() && /\.mdx?$/i.test(entry.name);
}
function isYamlEntry(entry: Dirent): boolean {
return entry.isFile() && /\.ya?ml$/i.test(entry.name);
}
async function fileSizeOrNull(filePath: string): Promise<number | null> {
try {
const s = await statAsync(filePath);
return s.isFile() ? s.size : null;
} catch {
return null;
}
}
function tryQuery<T>(run: () => T, fallback: T): T {
try {
return run();
} catch {
return fallback;
}
}
export async function buildLocalStatsStatus(project: KtxLocalProject): Promise<LocalStatsStatus> {
const dbPath = ktxLocalStateDbPath(project);
const dbSqliteBytes = await fileSizeOrNull(dbPath);
const projectDirSummary: LocalStatsProjectDir = {
dbSqliteBytes,
ktxCacheBytes: (await summarizeDir(join(project.projectDir, '.ktx', 'cache'))).bytes,
rawSources: await summarizeDir(join(project.projectDir, 'raw-sources')),
wikiGlobalMarkdownCount: (
await summarizeDir(join(project.projectDir, 'wiki', 'global'), isMarkdownEntry)
).fileCount,
semanticLayerYamlCount: (
await summarizeDir(join(project.projectDir, 'semantic-layer'), isYamlEntry)
).fileCount,
};
if (dbSqliteBytes === null) {
return {
ingest: { totalCompletedRuns: 0, perConnection: [] },
knowledgePages: [],
semanticLayer: [],
projectDir: projectDirSummary,
unavailable: 'no .ktx/db.sqlite yet',
};
}
let database: import('better-sqlite3').Database | null = null;
try {
const { default: Database } = await import('better-sqlite3');
database = new Database(dbPath, { readonly: true, fileMustExist: true });
const db = database;
const totalCompletedRuns = tryQuery(
() =>
(
db
.prepare(`SELECT COUNT(*) AS n FROM local_ingest_reports WHERE status = 'done'`)
.get() as { n: number } | undefined
)?.n ?? 0,
0,
);
const ingestRows = tryQuery(
() =>
db
.prepare(
`SELECT connection_id, adapter, MAX(completed_at) AS last_completed_at
FROM local_ingest_reports
WHERE status = 'done'
GROUP BY connection_id, adapter`,
)
.all() as Array<{ connection_id: string; adapter: string; last_completed_at: string }>,
[] as Array<{ connection_id: string; adapter: string; last_completed_at: string }>,
);
const perConnectionMap = new Map<string, LocalStatsIngestPerConnection>();
for (const row of ingestRows) {
const existing = perConnectionMap.get(row.connection_id);
if (!existing || row.last_completed_at > existing.lastCompletedAt) {
perConnectionMap.set(row.connection_id, {
connectionId: row.connection_id,
adapter: row.adapter,
lastCompletedAt: row.last_completed_at,
});
}
}
const perConnection = [...perConnectionMap.values()].sort((left, right) =>
left.connectionId.localeCompare(right.connectionId),
);
const knowledgeRows = tryQuery(
() =>
db
.prepare(
`SELECT scope, COUNT(*) AS n FROM knowledge_pages GROUP BY scope ORDER BY scope`,
)
.all() as Array<{ scope: string; n: number }>,
[] as Array<{ scope: string; n: number }>,
);
const knowledgePages: LocalStatsKnowledgeEntry[] = knowledgeRows.map((row) => ({
scope: row.scope,
count: row.n,
}));
const sourceRows = tryQuery(
() =>
db
.prepare(
`SELECT connection_id, COUNT(*) AS n FROM local_sl_sources GROUP BY connection_id`,
)
.all() as Array<{ connection_id: string; n: number }>,
[] as Array<{ connection_id: string; n: number }>,
);
const dictionaryRows = tryQuery(
() =>
db
.prepare(
`SELECT connection_id, COUNT(*) AS n FROM local_sl_dictionary_values GROUP BY connection_id`,
)
.all() as Array<{ connection_id: string; n: number }>,
[] as Array<{ connection_id: string; n: number }>,
);
const slMap = new Map<string, LocalStatsSemanticLayerEntry>();
for (const row of sourceRows) {
slMap.set(row.connection_id, {
connectionId: row.connection_id,
sourceCount: row.n,
dictionaryValueCount: 0,
});
}
for (const row of dictionaryRows) {
const existing = slMap.get(row.connection_id) ?? {
connectionId: row.connection_id,
sourceCount: 0,
dictionaryValueCount: 0,
};
existing.dictionaryValueCount = row.n;
slMap.set(row.connection_id, existing);
}
const semanticLayer = [...slMap.values()].sort((left, right) =>
left.connectionId.localeCompare(right.connectionId),
);
return {
ingest: { totalCompletedRuns, perConnection },
knowledgePages,
semanticLayer,
projectDir: projectDirSummary,
};
} catch (error) {
return {
ingest: { totalCompletedRuns: 0, perConnection: [] },
knowledgePages: [],
semanticLayer: [],
projectDir: projectDirSummary,
unavailable: failureDetail(error),
};
} finally {
if (database) {
try {
database.close();
} catch {
// ignore close failures
}
}
}
}
export async function buildProjectStatus(project: KtxLocalProject, options: BuildProjectStatusOptions = {}): Promise<ProjectStatus> { export async function buildProjectStatus(project: KtxLocalProject, options: BuildProjectStatusOptions = {}): Promise<ProjectStatus> {
const env = options.env ?? process.env; const env = options.env ?? process.env;
const config = project.config; const config = project.config;
@ -692,6 +992,8 @@ export async function buildProjectStatus(project: KtxLocalProject, options: Buil
projectDir: project.projectDir, projectDir: project.projectDir,
env, env,
claudeCodeAuthProbe: options.claudeCodeAuthProbe, claudeCodeAuthProbe: options.claudeCodeAuthProbe,
fast: options.fast,
useSpinner: options.useSpinner,
}); });
const embeddings = buildEmbeddingsStatus(config.ingest.embeddings, env); const embeddings = buildEmbeddingsStatus(config.ingest.embeddings, env);
const storage = buildStorageStatus(config); const storage = buildStorageStatus(config);
@ -701,6 +1003,7 @@ export async function buildProjectStatus(project: KtxLocalProject, options: Buil
const queryHistory = await buildQueryHistoryStatus(project, options); const queryHistory = await buildQueryHistoryStatus(project, options);
const pipeline = buildPipelineStatus(config); const pipeline = buildPipelineStatus(config);
const warnings = buildWarnings(config, connections, llm, embeddings); const warnings = buildWarnings(config, connections, llm, embeddings);
const localStats = await buildLocalStatsStatus(project);
const { verdict, reason, nextActions } = buildVerdict(llm, embeddings, connections, queryHistory, warnings); const { verdict, reason, nextActions } = buildVerdict(llm, embeddings, connections, queryHistory, warnings);
return { return {
@ -714,6 +1017,7 @@ export async function buildProjectStatus(project: KtxLocalProject, options: Buil
queryHistory, queryHistory,
pipeline, pipeline,
warnings, warnings,
localStats,
verdict, verdict,
verdictReason: reason, verdictReason: reason,
nextActions, nextActions,
@ -742,11 +1046,51 @@ export async function buildProjectStatus(project: KtxLocalProject, options: Buil
// ─── Rendering ────────────────────────────────────────────────────────────── // ─── Rendering ──────────────────────────────────────────────────────────────
const SYMBOL: Record<ProjectStatusLevel, string> = { ok: '✓', warn: '⚠', fail: '✗' }; const SYMBOL: Record<ProjectStatusLevel, string> = { ok: '✓', warn: '⚠', fail: '✗', skipped: '-' };
function colorForLevel(useColor: boolean, level: ProjectStatusLevel, text: string): string { function colorForLevel(useColor: boolean, level: ProjectStatusLevel, text: string): string {
if (!useColor) return text; if (!useColor) return text;
return level === 'ok' ? green(text) : level === 'warn' ? yellow(text) : red(text); if (level === 'ok') return green(text);
if (level === 'warn') return yellow(text);
if (level === 'fail') return red(text);
return _dim(text);
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ['KiB', 'MiB', 'GiB', 'TiB'];
let value = bytes / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
return `${value.toFixed(precision)} ${units[unitIndex]}`;
}
const RELATIVE_TIME_DIVISIONS: Array<{ amount: number; name: Intl.RelativeTimeFormatUnit }> = [
{ amount: 60, name: 'second' },
{ amount: 60, name: 'minute' },
{ amount: 24, name: 'hour' },
{ amount: 7, name: 'day' },
{ amount: 4.34524, name: 'week' },
{ amount: 12, name: 'month' },
{ amount: Number.POSITIVE_INFINITY, name: 'year' },
];
function formatRelativeFromNow(iso: string): string {
const parsed = Date.parse(iso);
if (Number.isNaN(parsed)) return iso;
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
let duration = (parsed - Date.now()) / 1000;
for (const division of RELATIVE_TIME_DIVISIONS) {
if (Math.abs(duration) < division.amount) {
return formatter.format(Math.round(duration), division.name);
}
duration /= division.amount;
}
return iso;
} }
@ -758,6 +1102,79 @@ function abbreviateHome(filePath: string, env: NodeJS.ProcessEnv): string {
return filePath; return filePath;
} }
function renderLocalStats(
lines: string[],
stats: LocalStatsStatus,
dim: (text: string) => string,
bold: (text: string) => string,
): void {
lines.push(` ${bold('Local data')}`);
if (stats.unavailable) {
lines.push(` ${dim(`(—) ${stats.unavailable}`)}`);
lines.push('');
return;
}
const localLabelWidth = Math.max(
'Ingest'.length,
'Knowledge'.length,
'Semantic layer'.length,
'Disk'.length,
);
const lLabel = (text: string) => text.padEnd(localLabelWidth);
const ingest = stats.ingest;
const ingestSummary =
ingest.totalCompletedRuns === 0
? dim('no completed runs yet')
: `${ingest.totalCompletedRuns} completed run${ingest.totalCompletedRuns === 1 ? '' : 's'}`;
lines.push(` ${lLabel('Ingest')} ${ingestSummary}`);
if (ingest.perConnection.length > 0) {
const nameWidth = Math.max(...ingest.perConnection.map((entry) => entry.connectionId.length));
const adapterWidth = Math.max(...ingest.perConnection.map((entry) => entry.adapter.length));
for (const entry of ingest.perConnection) {
lines.push(
` ${entry.connectionId.padEnd(nameWidth)} ${dim(entry.adapter.padEnd(adapterWidth))} ${dim(`last ${formatRelativeFromNow(entry.lastCompletedAt)}`)}`,
);
}
}
if (stats.knowledgePages.length === 0) {
lines.push(` ${lLabel('Knowledge')} ${dim('no pages yet')}`);
} else {
const knowledgeText = stats.knowledgePages
.map((entry) => `${entry.scope}=${entry.count}`)
.join(` ${dim('·')} `);
lines.push(` ${lLabel('Knowledge')} ${knowledgeText}`);
}
if (stats.semanticLayer.length === 0) {
lines.push(` ${lLabel('Semantic layer')} ${dim('no indexed sources yet')}`);
} else {
const nameWidth = Math.max(...stats.semanticLayer.map((entry) => entry.connectionId.length));
let firstLine = true;
for (const entry of stats.semanticLayer) {
const prefix = firstLine ? lLabel('Semantic layer') : ' '.repeat(localLabelWidth);
lines.push(
` ${prefix} ${entry.connectionId.padEnd(nameWidth)} ${dim(`${entry.sourceCount} source${entry.sourceCount === 1 ? '' : 's'} · ${entry.dictionaryValueCount} dictionary value${entry.dictionaryValueCount === 1 ? '' : 's'}`)}`,
);
firstLine = false;
}
}
const disk = stats.projectDir;
const diskBits: string[] = [];
diskBits.push(`db=${disk.dbSqliteBytes === null ? '' : formatBytes(disk.dbSqliteBytes)}`);
diskBits.push(`cache=${formatBytes(disk.ktxCacheBytes)}`);
diskBits.push(
`raw-sources=${disk.rawSources.fileCount} file${disk.rawSources.fileCount === 1 ? '' : 's'} (${formatBytes(disk.rawSources.bytes)})`,
);
diskBits.push(`wiki=${disk.wikiGlobalMarkdownCount} md`);
diskBits.push(`semantic-layer=${disk.semanticLayerYamlCount} yaml`);
lines.push(` ${lLabel('Disk')} ${dim(diskBits.join(` ${dim('·')} `))}`);
lines.push('');
}
export interface RenderProjectStatusOptions { export interface RenderProjectStatusOptions {
verbose?: boolean; verbose?: boolean;
useColor?: boolean; useColor?: boolean;
@ -859,6 +1276,9 @@ export function renderProjectStatus(status: ProjectStatus, options: RenderProjec
lines.push(` ${pLabel('Research agent')} ${agentDetail}`); lines.push(` ${pLabel('Research agent')} ${agentDetail}`);
lines.push(''); lines.push('');
// Local data
renderLocalStats(lines, status.localStats, dim, bold);
// Warnings // Warnings
if (status.warnings.length > 0) { if (status.warnings.length > 0) {
lines.push(` ${bold('Warnings')}`); lines.push(` ${bold('Warnings')}`);