45 KiB
CLI Output Harmonization Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Harmonize KTX CLI printing, logging, progress, and prompt behavior so command results are dev-friendly for humans while staying safe for JSON and scripted automation.
Architecture: Treat stdout as the result channel and stderr as the operational channel. Commander remains the command parser/output owner, Clack remains an interactive-only UI layer, and reusable @ktx/context code emits logs through explicit ports that default to noop. Dev-friendly result mode is a shared renderer policy: terminal users get compact pretty summaries, CI/non-TTY users get stable plain output, and --json gets exactly one parseable JSON payload on stdout.
Tech Stack: TypeScript, Commander 14, @clack/prompts 1.3, Vitest, pnpm workspace commands.
File Structure
- Modify:
packages/context/src/ingest/adapters/metabase/fetch.ts- Replace the module-level
console.*logger with an explicit optional logger parameter that defaults to noop.
- Replace the module-level
- Modify:
packages/context/src/ingest/adapters/metabase/client.ts- Change
defaultLoggerfromconsole.*to noop.
- Change
- Modify:
packages/context/src/ingest/adapters/looker/client.ts- Change
defaultLoggerfromconsole.*to noop.
- Change
- Modify:
packages/context/src/ingest/adapters/notion/fetch.ts- Add an optional logger parameter and replace module-level
console.warn.
- Add an optional logger parameter and replace module-level
- Modify:
packages/context/src/ingest/adapters/metabase/metabase.adapter.ts- Accept and forward a logger to
fetchMetabaseBundle().
- Accept and forward a logger to
- Modify:
packages/context/src/ingest/adapters/notion/notion.adapter.ts- Accept and forward a logger to
fetchNotionSnapshot().
- Accept and forward a logger to
- Modify:
packages/context/src/ingest/adapters/metabase/local-metabase.adapter.ts- Accept a logger option and pass it to the Metabase client factory/adapter.
- Modify:
packages/context/src/ingest/adapters/looker/local-looker.adapter.ts- Accept a logger option and pass it to
DefaultLookerConnectionClientFactory.
- Accept a logger option and pass it to
- Modify:
packages/context/src/ingest/local-adapters.ts- Accept a logger option and pass it to Metabase, Looker, and Notion local adapters.
- Modify:
packages/cli/src/io/mode.ts- Make the result-mode policy explicit and reusable for commands that need
plain/json/pretty.
- Make the result-mode policy explicit and reusable for commands that need
- Create:
packages/cli/src/io/logger.ts- Define
createCliOperationalLogger(io, mode)andcreateNoopOperationalLogger(). - In JSON mode, default to noop unless a future
--debugflag explicitly routes to stderr. - In plain/pretty/viz modes, route log/warn/error/debug through stderr.
- Define
- Modify:
packages/cli/src/ingest.ts- Move fan-out progress and plain ingest progress from stdout to stderr.
- Thread CLI operational logger into local ingest adapter creation/options.
- Keep final result summaries on stdout.
- Modify:
packages/cli/src/local-adapters.ts- Accept and thread logger options into Metabase/Looker/Notion local adapters.
- Modify:
packages/cli/src/clack.ts- Expand the Clack adapter so prompts, logs, and spinners are only used through one injectable surface.
- Modify:
packages/cli/src/managed-python-command.ts- Stop reading
process.stdin/process.stdoutdirectly in default prompt logic; use injected prompt deps orKtxCliIocapability.
- Stop reading
- Test:
packages/context/src/ingest/adapters/metabase/fetch.test.ts - Test:
packages/context/src/ingest/adapters/metabase/client.test.ts - Test:
packages/context/src/ingest/adapters/looker/client.test.ts - Test:
packages/context/src/ingest/adapters/notion/fetch.test.ts - Test:
packages/cli/src/ingest.test.ts - Test:
packages/cli/src/managed-python-command.test.ts - Test:
packages/cli/src/io/mode.test.ts - Create:
packages/cli/src/io/logger.test.ts
Output Contract
Implement and preserve these invariants:
--json: stdout contains exactly one JSON payload; stderr is empty unless the command fails before the payload or explicit debug is enabled.plain: stdout contains final command results in stable script-friendly text; progress goes to stderr.pretty: stdout contains a compact final summary optimized for terminal reading; progress, retries, warnings, and prompts go to stderr.viz: visual/TUI rendering is allowed only in an interactive terminal; unsupported terminals degrade once to plain final output and one stderr warning.- Reusable
@ktx/contextcode never callsconsole.*directly.
Context7 Findings Applied
Current Context7 docs for Commander recommend configureOutput() for custom stdout/stderr routing, exitOverride() for programmatic handling, and Commander-native help/error controls. KTX already does this in packages/cli/src/cli-program.ts, so this plan keeps Commander as the parser/help/error owner.
Current Context7 docs for Clack recommend semantic log.*, spinner, and explicit cancel/isCancel handling. This plan keeps Clack as an interactive-only UI adapter and makes cancellation behavior explicit rather than allowing cancellation to degrade into missing-input paths.
Task 1: Make Context Adapter Logging Explicit and Noop by Default
Files:
-
Modify:
packages/context/src/ingest/adapters/metabase/fetch.ts -
Modify:
packages/context/src/ingest/adapters/metabase/client.ts -
Modify:
packages/context/src/ingest/adapters/looker/client.ts -
Modify:
packages/context/src/ingest/adapters/notion/fetch.ts -
Test:
packages/context/src/ingest/adapters/metabase/fetch.test.ts -
Test:
packages/context/src/ingest/adapters/metabase/client.test.ts -
Test:
packages/context/src/ingest/adapters/looker/client.test.ts -
Test:
packages/context/src/ingest/adapters/notion/fetch.test.ts -
Step 1: Add a failing Metabase fetch test for no console output by default
Add this test near the top of the describe('fetchMetabaseBundle', ...) block in packages/context/src/ingest/adapters/metabase/fetch.test.ts, after the existing happy-path test setup helpers:
it('does not write Metabase fetch progress to console by default', async () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
await fetchMetabaseBundle({
pullConfig: { metabaseConnectionId, metabaseDatabaseId: 42 },
stagedDir,
ctx: makeFetchContext(),
clientFactory,
sourceStateReader,
});
expect(log).not.toHaveBeenCalled();
expect(warn).not.toHaveBeenCalled();
});
- Step 2: Add a failing Metabase fetch test for injected warning capture
Add this test to packages/context/src/ingest/adapters/metabase/fetch.test.ts:
it('routes Metabase fetch warnings through the injected logger', async () => {
const logger = {
log: vi.fn(),
warn: vi.fn(),
};
clientFactory.__client.getCard.mockRejectedValueOnce(new Error('card read failed'));
await fetchMetabaseBundle({
pullConfig: { metabaseConnectionId, metabaseDatabaseId: 42 },
stagedDir,
ctx: makeFetchContext(),
clientFactory,
sourceStateReader,
logger,
});
expect(logger.warn).toHaveBeenCalledWith('failed to load card 1: card read failed');
});
- Step 3: Run Metabase fetch tests and verify they fail
Run:
pnpm --filter @ktx/context exec vitest run src/ingest/adapters/metabase/fetch.test.ts
Expected: FAIL because fetchMetabaseBundle() does not accept logger and still uses module-level console.*.
- Step 4: Implement explicit Metabase fetch logger
In packages/context/src/ingest/adapters/metabase/fetch.ts, replace the module logger with this type and noop logger:
interface MetabaseFetchLogger {
log(message: string): void;
warn(message: string): void;
}
const noopMetabaseFetchLogger: MetabaseFetchLogger = {
log: () => undefined,
warn: () => undefined,
};
Update FetchMetabaseBundleParams:
export interface FetchMetabaseBundleParams {
pullConfig: unknown;
stagedDir: string;
ctx: FetchContext;
clientFactory: MetabaseClientFactory;
sourceStateReader: MetabaseSourceStateReader;
logger?: MetabaseFetchLogger;
}
At the start of fetchMetabaseBundle() after parsing pullConfig, add:
const logger = params.logger ?? noopMetabaseFetchLogger;
Leave the existing logger.warn(...) and logger.log(...) call sites in place; they now refer to the local logger variable.
- Step 5: Run Metabase fetch tests and verify they pass
Run:
pnpm --filter @ktx/context exec vitest run src/ingest/adapters/metabase/fetch.test.ts
Expected: PASS.
- Step 6: Add failing client default-logger tests
In packages/context/src/ingest/adapters/metabase/client.test.ts, add this test inside describe('MetabaseClient retry exhaustion', ...) before retry behavior tests:
it('does not warn to console when retrying by default', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
globalThis.fetch = vi
.fn<typeof fetch>()
.mockRejectedValueOnce(Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }))
.mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 }));
const client = new MetabaseClient({ apiUrl: 'https://metabase.example.test', apiKey: 'key' }, {
...DEFAULT_METABASE_CLIENT_CONFIG,
baseDelayMs: 0,
maxRetries: 1,
});
await client.getDatabases();
expect(warn).not.toHaveBeenCalled();
});
In packages/context/src/ingest/adapters/looker/client.test.ts, add this test inside describe('LookerClient', ...):
it('does not warn to console when optional prioritization inputs fail by default', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const fakeSdk = sdk({
search_dashboards: vi.fn().mockRejectedValue(new Error('dashboards unavailable')),
search_looks: vi.fn().mockRejectedValue(new Error('looks unavailable')),
});
const client = new LookerClient(params(), { sdkFactory: () => fakeSdk });
await expect(client.listContentSignals()).resolves.toEqual([]);
expect(warn).not.toHaveBeenCalled();
});
- Step 7: Run client tests and verify they fail
Run:
pnpm --filter @ktx/context exec vitest run src/ingest/adapters/metabase/client.test.ts src/ingest/adapters/looker/client.test.ts
Expected: FAIL because the client default loggers still call console.warn.
- Step 8: Make Metabase and Looker client defaults noop
In packages/context/src/ingest/adapters/metabase/client.ts, replace defaultLogger with:
const defaultLogger: MetabaseClientLogger = {
log: () => undefined,
warn: () => undefined,
error: () => undefined,
debug: () => undefined,
};
In packages/context/src/ingest/adapters/looker/client.ts, replace defaultLogger with:
const defaultLogger: LookerClientLogger = {
log: () => undefined,
warn: () => undefined,
error: () => undefined,
debug: () => undefined,
};
- Step 9: Add failing Notion fetch logger test
Update packages/context/src/ingest/adapters/notion/fetch.test.ts. Replace the existing console.warn spy expectation in it('logs skipped page materialization failures', ...) with an injected logger:
const logger = { warn: vi.fn() };
Update the fetchNotionSnapshot() call in that test to include:
logger,
Update the final assertion to:
expect(logger.warn).toHaveBeenCalledWith('Skipping Notion page page-1: Notion API failed');
- Step 10: Implement explicit Notion fetch logger
In packages/context/src/ingest/adapters/notion/fetch.ts, replace the module logger with:
interface NotionFetchLogger {
warn(message: string): void;
}
const noopNotionFetchLogger: NotionFetchLogger = {
warn: () => undefined,
};
Update FetchNotionSnapshotParams:
interface FetchNotionSnapshotParams {
client: NotionApi;
config: NotionPullConfig;
stagedDir: string;
logger?: NotionFetchLogger;
}
At the start of fetchNotionSnapshot(), add:
const logger = params.logger ?? noopNotionFetchLogger;
Thread this logger into helpers that currently use the module-level logger. If a helper does not receive params today, add a logger: NotionFetchLogger field to its input object and pass the local logger through.
- Step 11: Run context adapter tests
Run:
pnpm --filter @ktx/context exec vitest run src/ingest/adapters/metabase/fetch.test.ts src/ingest/adapters/metabase/client.test.ts src/ingest/adapters/looker/client.test.ts src/ingest/adapters/notion/fetch.test.ts
Expected: PASS.
- Step 12: Commit Task 1
git add packages/context/src/ingest/adapters/metabase/fetch.ts \
packages/context/src/ingest/adapters/metabase/fetch.test.ts \
packages/context/src/ingest/adapters/metabase/client.ts \
packages/context/src/ingest/adapters/metabase/client.test.ts \
packages/context/src/ingest/adapters/looker/client.ts \
packages/context/src/ingest/adapters/looker/client.test.ts \
packages/context/src/ingest/adapters/notion/fetch.ts \
packages/context/src/ingest/adapters/notion/fetch.test.ts
git commit -m "fix(context): make ingest adapter logging explicit"
Task 2: Thread Operational Loggers from CLI to Local Adapters
Files:
-
Create:
packages/cli/src/io/logger.ts -
Create:
packages/cli/src/io/logger.test.ts -
Modify:
packages/cli/src/local-adapters.ts -
Modify:
packages/context/src/ingest/adapters/metabase/metabase.adapter.ts -
Modify:
packages/context/src/ingest/adapters/metabase/local-metabase.adapter.ts -
Modify:
packages/context/src/ingest/adapters/looker/local-looker.adapter.ts -
Modify:
packages/context/src/ingest/local-adapters.ts -
Modify:
packages/context/src/ingest/adapters/notion/notion.adapter.ts -
Test:
packages/cli/src/ingest.test.ts -
Step 1: Write CLI logger tests
Create packages/cli/src/io/logger.test.ts:
import { describe, expect, it, vi } from 'vitest';
import { createCliOperationalLogger, createNoopOperationalLogger } from './logger.js';
function makeIo() {
let stdout = '';
let stderr = '';
return {
io: {
stdout: {
write: (chunk: string) => {
stdout += chunk;
},
},
stderr: {
write: (chunk: string) => {
stderr += chunk;
},
},
},
stdout: () => stdout,
stderr: () => stderr,
};
}
describe('createCliOperationalLogger', () => {
it('routes operational messages to stderr outside JSON mode', () => {
const io = makeIo();
const logger = createCliOperationalLogger(io.io, 'plain');
logger.log('progress');
logger.warn('warning');
logger.error('failure');
logger.debug?.('debug');
expect(io.stdout()).toBe('');
expect(io.stderr()).toBe('progress\nwarning\nfailure\ndebug\n');
});
it('suppresses operational messages in JSON mode by default', () => {
const io = makeIo();
const logger = createCliOperationalLogger(io.io, 'json');
logger.log('progress');
logger.warn('warning');
logger.error('failure');
logger.debug?.('debug');
expect(io.stdout()).toBe('');
expect(io.stderr()).toBe('');
});
});
describe('createNoopOperationalLogger', () => {
it('never writes', () => {
const logger = createNoopOperationalLogger();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
logger.log('progress');
logger.warn('warning');
logger.error('failure');
logger.debug?.('debug');
expect(warn).not.toHaveBeenCalled();
});
});
- Step 2: Run the logger tests and verify they fail
Run:
pnpm --filter @ktx/cli exec vitest run src/io/logger.test.ts
Expected: FAIL because packages/cli/src/io/logger.ts does not exist.
- Step 3: Implement
packages/cli/src/io/logger.ts
Create packages/cli/src/io/logger.ts:
import type { KtxCliIo } from '../cli-runtime.js';
import type { KtxOutputMode } from './mode.js';
export interface KtxOperationalLogger {
log(message: string): void;
warn(message: string): void;
error(message: string): void;
debug?(message: string): void;
}
export type KtxOperationalOutputMode = KtxOutputMode | 'viz';
function writeLine(io: KtxCliIo, message: string): void {
io.stderr.write(message.endsWith('\n') ? message : `${message}\n`);
}
export function createNoopOperationalLogger(): KtxOperationalLogger {
return {
log: () => undefined,
warn: () => undefined,
error: () => undefined,
debug: () => undefined,
};
}
export function createCliOperationalLogger(
io: KtxCliIo,
mode: KtxOperationalOutputMode,
): KtxOperationalLogger {
if (mode === 'json') {
return createNoopOperationalLogger();
}
return {
log: (message) => writeLine(io, message),
warn: (message) => writeLine(io, message),
error: (message) => writeLine(io, message),
debug: (message) => writeLine(io, message),
};
}
- Step 4: Run logger tests and verify they pass
Run:
pnpm --filter @ktx/cli exec vitest run src/io/logger.test.ts
Expected: PASS.
- Step 5: Update adapter constructors to accept loggers
In packages/context/src/ingest/adapters/metabase/metabase.adapter.ts, update the deps interface:
import type { MetabaseFetchLogger } from './fetch.js';
export interface MetabaseSourceAdapterDeps {
clientFactory: MetabaseClientFactory;
sourceStateReader: MetabaseSourceStateReader;
logger?: MetabaseFetchLogger;
}
Forward the logger in fetch():
...(this.deps.logger ? { logger: this.deps.logger } : {}),
In packages/context/src/ingest/adapters/metabase/fetch.ts, export the logger interface:
export interface MetabaseFetchLogger {
log(message: string): void;
warn(message: string): void;
}
In packages/context/src/ingest/adapters/metabase/local-metabase.adapter.ts, update options:
import type { MetabaseClientLogger } from './client.js';
import type { MetabaseFetchLogger } from './fetch.js';
interface CreateLocalMetabaseSourceAdapterOptions {
env?: NodeJS.ProcessEnv;
defaultClientConfig?: MetabaseClientConfig;
logger?: MetabaseClientLogger & MetabaseFetchLogger;
}
Pass options.logger to DefaultMetabaseConnectionClientFactory and MetabaseSourceAdapter:
options.defaultClientConfig ?? DEFAULT_METABASE_CLIENT_CONFIG,
options.logger,
...(options.logger ? { logger: options.logger } : {}),
In packages/context/src/ingest/adapters/looker/local-looker.adapter.ts, update the factory call:
import type { LookerClientLogger } from './client.js';
export function createLocalLookerSourceAdapter(
project: KtxLocalProject,
env: NodeJS.ProcessEnv = process.env,
logger?: LookerClientLogger,
): LookerSourceAdapter {
const connectionFactory = new DefaultLookerConnectionClientFactory(createLocalLookerCredentialResolver(project, env), {
...(logger ? { logger } : {}),
});
return new LookerSourceAdapter({
clientFactory: new DefaultLookerClientFactory(connectionFactory),
});
}
In packages/context/src/ingest/adapters/notion/notion.adapter.ts, import NotionFetchLogger, add an optional logger dep, and pass it to fetchNotionSnapshot():
import type { NotionFetchLogger } from './fetch.js';
export interface NotionSourceAdapterDeps {
onPullSucceeded?: NotionPullSucceededHandler;
logger?: NotionFetchLogger;
}
await fetchNotionSnapshot({
client: new NotionClient(config.authToken),
config,
stagedDir,
...(this.deps.logger ? { logger: this.deps.logger } : {}),
});
- Step 6: Update CLI local adapter factory
In packages/context/src/ingest/local-adapters.ts, extend the shared local-adapter options with a logger. Import the narrow adapter logger types and use a structural intersection so one CLI operational logger can satisfy all adapters:
import type { LookerClientLogger } from './adapters/looker/client.js';
import type { MetabaseClientLogger } from './adapters/metabase/client.js';
import type { MetabaseFetchLogger } from './adapters/metabase/fetch.js';
import type { NotionFetchLogger } from './adapters/notion/fetch.js';
type LocalIngestOperationalLogger =
& MetabaseClientLogger
& MetabaseFetchLogger
& LookerClientLogger
& NotionFetchLogger;
export interface DefaultLocalIngestAdaptersOptions {
// keep existing fields
logger?: LocalIngestOperationalLogger;
}
Thread options.logger through the existing adapter construction points:
createLocalMetabaseSourceAdapter(project, {
logger: options.logger,
});
const lookerConnectionFactory = new DefaultLookerConnectionClientFactory(
createLocalLookerCredentialResolver(project, options.looker?.env),
{
...(options.logger ? { logger: options.logger } : {}),
},
);
new NotionSourceAdapter({
...(options.logger ? { logger: options.logger } : {}),
});
In packages/cli/src/local-adapters.ts, extend the existing CLI adapter options type with the CLI logger. Preserve the current extends DefaultLocalIngestAdaptersOptions shape:
import type { KtxOperationalLogger } from './io/logger.js';
export interface KtxCliLocalIngestAdaptersOptions extends DefaultLocalIngestAdaptersOptions {
historicSqlConnectionId?: string;
sqlAnalysis?: SqlAnalysisClient;
logger?: KtxOperationalLogger;
}
Because packages/cli/src/local-adapters.ts already forwards ...options into createDefaultLocalIngestAdapters(project, { ...options, ... }), do not add a parallel Notion local factory. The new logger field should flow through that existing options object.
- Step 7: Add a failing CLI JSON-safety test for adapter logs
In packages/cli/src/ingest.test.ts, add a test near prints metabase fan-out JSON results:
it('keeps metabase JSON stdout free of operational adapter logs', async () => {
const projectDir = join(tempDir, 'project');
await writeMetabaseConfig(projectDir);
const io = makeIo();
await expect(
runKtxIngest(
{
command: 'run',
projectDir,
connectionId: 'prod-metabase',
adapter: 'metabase',
outputMode: 'json',
},
io.io,
{
runLocalMetabaseIngest: async (input) => {
input.adapters.find((adapter) => adapter.source === 'metabase');
return {
metabaseConnectionId: 'prod-metabase',
status: 'all_succeeded',
totals: { workUnits: 0, failedWorkUnits: 0 },
children: [],
};
},
},
),
).resolves.toBe(0);
expect(() => JSON.parse(io.stdout())).not.toThrow();
expect(io.stderr()).toBe('');
});
- Step 8: Thread the CLI operational logger through
runKtxIngest()
In packages/cli/src/ingest.ts, import:
import { createCliOperationalLogger } from './io/logger.js';
After resolving env and before adapterOptions, create:
const operationalLogger = createCliOperationalLogger(io, args.outputMode);
Add the logger to adapterOptions:
logger: operationalLogger,
Preserve existing localIngestOptions.logger; if both are present, localIngestOptions.logger should continue to flow into runLocalIngest() while adapterOptions.logger controls adapter/client operational output.
- Step 9: Run CLI ingest tests
Run:
pnpm --filter @ktx/cli exec vitest run src/ingest.test.ts src/io/logger.test.ts
Expected: PASS.
- Step 10: Commit Task 2
git add packages/cli/src/io/logger.ts \
packages/cli/src/io/logger.test.ts \
packages/cli/src/local-adapters.ts \
packages/cli/src/ingest.ts \
packages/cli/src/ingest.test.ts \
packages/context/src/ingest/local-adapters.ts \
packages/context/src/ingest/adapters/metabase/metabase.adapter.ts \
packages/context/src/ingest/adapters/metabase/local-metabase.adapter.ts \
packages/context/src/ingest/adapters/looker/local-looker.adapter.ts \
packages/context/src/ingest/adapters/notion/notion.adapter.ts
git commit -m "feat(cli): route ingest adapter logs through operational logger"
Task 3: Move Progress to stderr and Keep stdout Result-Only
Files:
-
Modify:
packages/cli/src/ingest.ts -
Test:
packages/cli/src/ingest.test.ts -
Test:
packages/cli/src/ingest-viz.test.ts -
Step 1: Add failing fan-out progress channel test
In packages/cli/src/ingest.test.ts, update the existing fan-out progress test or add this new one near the Metabase fan-out tests:
it('writes metabase fan-out progress to stderr and final result to stdout', async () => {
const projectDir = join(tempDir, 'project');
await writeMetabaseConfig(projectDir);
const io = makeIo({ isTTY: true });
await expect(
runKtxIngest(
{
command: 'run',
projectDir,
connectionId: 'prod-metabase',
adapter: 'metabase',
outputMode: 'plain',
},
io.io,
{
runLocalMetabaseIngest: async (input) => {
input.progress?.onMetabaseFanoutPlanned({
metabaseConnectionId: 'prod-metabase',
children: [{ metabaseDatabaseId: 1, targetConnectionId: 'warehouse_a' }],
});
input.progress?.onMetabaseChildStarted({
metabaseConnectionId: 'prod-metabase',
metabaseDatabaseId: 1,
targetConnectionId: 'warehouse_a',
jobId: 'metabase-child-1',
});
return {
metabaseConnectionId: 'prod-metabase',
status: 'all_succeeded',
totals: { workUnits: 0, failedWorkUnits: 0 },
children: [],
};
},
},
),
).resolves.toBe(0);
expect(io.stderr()).toContain('Metabase ingest: prod-metabase');
expect(io.stderr()).toContain('status=running job=metabase-child-1');
expect(io.stdout()).toContain('Metabase fan-out: all_succeeded');
expect(io.stdout()).not.toContain('status=running job=metabase-child-1');
});
- Step 2: Add failing plain progress channel test
In packages/cli/src/ingest.test.ts, add this test near the local ingest progress tests:
it('writes plain TTY ingest progress to stderr and final report to stdout', async () => {
const projectDir = join(tempDir, 'project');
await writeWarehouseConfig(projectDir);
const sourceDir = join(tempDir, 'source');
await mkdir(join(sourceDir, 'orders'), { recursive: true });
await writeFile(join(sourceDir, 'orders', 'orders.json'), '{"name":"orders"}\n', 'utf-8');
const runLocal = vi.fn(async (input: RunLocalIngestOptions) => completedLocalBundleRun(input, 'local-job-1'));
const io = makeIo({ isTTY: true });
await expect(
runKtxIngest(
{
command: 'run',
projectDir,
connectionId: 'warehouse',
adapter: 'fake',
sourceDir,
outputMode: 'plain',
},
io.io,
{ runLocalIngest: runLocal },
),
).resolves.toBe(0);
expect(io.stderr()).toContain('[5%] Fetching source files for warehouse/fake');
expect(io.stdout()).toContain('Report: report-live-1');
expect(io.stdout()).not.toContain('[5%]');
});
- Step 3: Run ingest tests and verify they fail
Run:
pnpm --filter @ktx/cli exec vitest run src/ingest.test.ts
Expected: FAIL because progress currently writes to stdout.
- Step 4: Change Metabase fan-out progress to stderr
In packages/cli/src/ingest.ts, update createMetabaseFanoutProgress():
function createMetabaseFanoutProgress(
connectionId: string,
io: KtxIngestIo,
): LocalMetabaseFanoutProgress {
io.stderr.write(`Metabase ingest: ${connectionId}\n`);
io.stderr.write('Checking mappings and scheduled-pull targets...\n');
return {
onMetabaseFanoutPlanned(event) {
io.stderr.write(`Targets: ${pluralize(event.children.length, 'mapped database')}\n`);
for (const child of event.children) {
io.stderr.write(`- database=${child.metabaseDatabaseId} target=${child.targetConnectionId} status=queued\n`);
}
},
onMetabaseChildStarted(event) {
io.stderr.write(
`- database=${event.metabaseDatabaseId} target=${event.targetConnectionId} status=running job=${event.jobId}\n`,
);
},
onMetabaseChildCompleted(event) {
io.stderr.write(
`- database=${event.metabaseDatabaseId} target=${event.targetConnectionId} status=${event.status} job=${event.jobId}\n`,
);
},
};
}
- Step 5: Change plain ingest progress renderer to stderr
In packages/cli/src/ingest.ts, update only the write() helper inside createPlainIngestProgressRenderer():
const write = (percent: number, message: string) => {
const nextPercent = Math.max(lastPercent, Math.max(0, Math.min(100, percent)));
lastPercent = nextPercent;
io.stderr.write(`[${nextPercent}%] ${message}\n`);
};
- Step 6: Update existing tests that intentionally asserted progress on stdout
Search:
rg -n "\\[5%\\]|Metabase ingest:|status=running job|\\[15%\\]" packages/cli/src/ingest.test.ts packages/cli/src/ingest-viz.test.ts
For progress assertions, move expected text from io.stdout() to io.stderr(). Keep final result/report assertions on io.stdout().
- Step 7: Run ingest and viz tests
Run:
pnpm --filter @ktx/cli exec vitest run src/ingest.test.ts src/ingest-viz.test.ts
Expected: PASS.
- Step 8: Commit Task 3
git add packages/cli/src/ingest.ts packages/cli/src/ingest.test.ts packages/cli/src/ingest-viz.test.ts
git commit -m "fix(cli): keep ingest progress off stdout"
Task 4: Centralize Clack Prompt and Cancellation Behavior
Files:
-
Modify:
packages/cli/src/clack.ts -
Modify:
packages/cli/src/managed-python-command.ts -
Test:
packages/cli/src/managed-python-command.test.ts -
Test: setup prompt tests only if touched by implementation
-
Step 1: Add failing managed-runtime injected prompt test
In packages/cli/src/managed-python-command.test.ts, add this test:
it('uses injected runtime confirmation instead of reading process TTY directly', async () => {
const io = makeIo();
const installRuntime = vi.fn(async (): Promise<ManagedPythonRuntimeInstallResult> => ({
layout: layout(),
manifest: manifest(['core']),
}));
const confirmInstall = vi.fn(async () => true);
await expect(
createManagedPythonSemanticLayerComputePort({
cliVersion: '0.2.0',
installPolicy: 'prompt',
io: io.io,
readStatus: async () => missingStatus(),
installRuntime,
confirmInstall,
createPythonCompute: () => ({ query: vi.fn(), validate: vi.fn(), close: vi.fn() }),
}),
).resolves.toBeTruthy();
expect(confirmInstall).toHaveBeenCalledWith(
'KTX needs to install the core Python runtime. This downloads Python dependencies with uv. Continue?',
);
expect(io.stderr()).toContain('Installing KTX Python runtime (core) with uv...');
});
This test should already pass for the injected case. It protects the desired seam before changing the default path.
- Step 2: Add failing default prompt IO-capability test
In packages/cli/src/managed-python-command.test.ts, add:
it('can decide default runtime prompting from injected io capabilities', async () => {
const io = makeIo();
Object.assign(io.io.stdout, { isTTY: false });
await expect(
createManagedPythonSemanticLayerComputePort({
cliVersion: '0.2.0',
installPolicy: 'prompt',
io: io.io,
readStatus: async () => missingStatus(),
installRuntime: vi.fn(),
createPythonCompute: () => ({ query: vi.fn(), validate: vi.fn(), close: vi.fn() }),
}),
).rejects.toThrow('KTX Python runtime installation was cancelled');
});
If this passes without code changes, keep it as a regression test and continue. The implementation still removes direct process reads.
- Step 3: Expand the Clack adapter
In packages/cli/src/clack.ts, replace the file contents with:
import { cancel, confirm, isCancel, log, spinner } from '@clack/prompts';
export interface KtxCliSpinner {
start(message: string): void;
stop(message: string): void;
error(message: string): void;
}
export interface KtxCliPromptAdapter {
confirm(options: { message: string; initialValue?: boolean }): Promise<boolean>;
cancel(message: string): void;
log: {
info(message: string): void;
warn(message: string): void;
error(message: string): void;
success(message: string): void;
step(message: string): void;
};
spinner(): KtxCliSpinner;
}
export class KtxCliPromptCancelledError extends Error {
constructor(message = 'Operation cancelled.') {
super(message);
this.name = 'KtxCliPromptCancelledError';
}
}
export function createClackSpinner(): KtxCliSpinner {
return spinner();
}
export function createClackPromptAdapter(): KtxCliPromptAdapter {
return {
async confirm(options) {
const value = await confirm(options);
if (isCancel(value)) {
cancel('Operation cancelled.');
throw new KtxCliPromptCancelledError();
}
return value;
},
cancel(message) {
cancel(message);
},
log: {
info(message) {
log.info(message);
},
warn(message) {
log.warn(message);
},
error(message) {
log.error(message);
},
success(message) {
log.success(message);
},
step(message) {
log.step(message);
},
},
spinner() {
return createClackSpinner();
},
};
}
- Step 4: Update managed runtime default confirm
In packages/cli/src/managed-python-command.ts, change imports:
import { createClackPromptAdapter } from './clack.js';
Remove direct imports from @clack/prompts.
Update ManagedPythonCommandDeps:
confirmInstall?: (message: string, io: KtxCliIo) => Promise<boolean>;
Replace defaultConfirmInstall() with:
async function defaultConfirmInstall(message: string, io: KtxCliIo): Promise<boolean> {
if (io.stdout.isTTY !== true) {
return false;
}
const prompts = createClackPromptAdapter();
return await prompts.confirm({ message, initialValue: true });
}
Update the call site:
const confirmInstall = options.confirmInstall ?? defaultConfirmInstall;
const confirmed = await confirmInstall(installPrompt(feature), options.io);
- Step 5: Update test types for
confirmInstall
In packages/cli/src/managed-python-command.test.ts, update any injected confirmInstall mocks to accept the second io argument if TypeScript requires it:
const confirmInstall = vi.fn(async (_message: string) => true);
No behavior change is required for tests that ignore the second argument.
- Step 6: Run managed runtime tests
Run:
pnpm --filter @ktx/cli exec vitest run src/managed-python-command.test.ts
Expected: PASS.
- Step 7: Run setup prompt tests touched by shared Clack exports
Run:
pnpm --filter @ktx/cli exec vitest run src/setup.test.ts src/setup-databases.test.ts src/setup-models.test.ts src/setup-sources.test.ts src/setup-embeddings.test.ts
Expected: PASS. If this is slow locally, run it once and inspect the full output rather than repeatedly filtering.
- Step 8: Commit Task 4
git add packages/cli/src/clack.ts packages/cli/src/managed-python-command.ts packages/cli/src/managed-python-command.test.ts
git commit -m "refactor(cli): centralize Clack prompt handling"
Task 5: Make Dev-Friendly Result Mode Reusable
Files:
-
Modify:
packages/cli/src/io/mode.ts -
Modify:
packages/cli/src/io/print-list.ts -
Test:
packages/cli/src/io/mode.test.ts -
Test:
packages/cli/src/sl.test.ts -
Optional later consumers:
packages/cli/src/knowledge.ts,packages/cli/src/connection.ts,packages/cli/src/runtime.ts -
Step 1: Add output mode tests for the contract
Create or update packages/cli/src/io/mode.test.ts:
import { describe, expect, it } from 'vitest';
import { resolveOutputMode } from './mode.js';
function io(isTTY?: boolean) {
return {
stdout: {
isTTY,
write: () => undefined,
},
stderr: {
write: () => undefined,
},
};
}
describe('resolveOutputMode', () => {
it('prefers explicit JSON over every other output setting', () => {
expect(resolveOutputMode({ json: true, explicit: 'pretty', io: io(true), env: { KTX_OUTPUT: 'plain' } })).toBe(
'json',
);
});
it('uses pretty for interactive terminals by default', () => {
expect(resolveOutputMode({ io: io(true), env: {} })).toBe('pretty');
});
it('uses plain in CI even when stdout looks like a TTY', () => {
expect(resolveOutputMode({ io: io(true), env: { CI: 'true' } })).toBe('plain');
});
it('uses plain when stdout is redirected', () => {
expect(resolveOutputMode({ io: io(false), env: {} })).toBe('plain');
});
it('rejects invalid KTX_OUTPUT values', () => {
expect(() => resolveOutputMode({ io: io(false), env: { KTX_OUTPUT: 'verbose' } })).toThrow(
'Invalid KTX_OUTPUT value: verbose. Expected one of pretty, plain, json.',
);
});
});
- Step 2: Run output mode tests
Run:
pnpm --filter @ktx/cli exec vitest run src/io/mode.test.ts
Expected: PASS or FAIL only if the file does not exist yet. Implement the test file if needed.
- Step 3: Make
printListoutput explicitly dev-friendly
In packages/cli/src/io/print-list.ts, preserve the existing pretty renderer but add a small JSON helper for result envelopes:
export interface KtxJsonResultEnvelope<T> {
kind: string;
data: T;
meta?: Record<string, unknown>;
}
export function writeJsonResult<T>(io: KtxCliIo, envelope: KtxJsonResultEnvelope<T>): void {
io.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
}
Update printListJson():
function printListJson<Row extends object>(args: PrintListArgs<Row>): void {
writeJsonResult(args.io, {
kind: 'list',
data: { items: args.rows },
meta: { command: args.command },
});
}
- Step 4: Add JSON helper tests through
sl list
In packages/cli/src/sl.test.ts, ensure sl list --json parses as exactly one envelope. If no such test exists, add:
it('prints sl list JSON as a single result envelope', async () => {
const projectDir = await createProjectWithSlSource();
const listIo = makeIo();
await expect(
runKtxSl({ command: 'list', projectDir, json: true }, listIo.io),
).resolves.toBe(0);
expect(listIo.stderr()).toBe('');
expect(JSON.parse(listIo.stdout())).toMatchObject({
kind: 'list',
data: {
items: expect.any(Array),
},
meta: {
command: 'sl list',
},
});
});
Use the existing project/source helper names from sl.test.ts; do not invent new helpers if the file already has a fixture builder.
- Step 5: Run
sland output tests
Run:
pnpm --filter @ktx/cli exec vitest run src/io/mode.test.ts src/sl.test.ts
Expected: PASS.
- Step 6: Commit Task 5
git add packages/cli/src/io/mode.ts packages/cli/src/io/mode.test.ts packages/cli/src/io/print-list.ts packages/cli/src/sl.test.ts
git commit -m "feat(cli): formalize dev-friendly result output"
Task 6: Regression Sweep for stdout/JSON Safety
Files:
-
Modify tests only if gaps are found:
packages/cli/src/index.test.tspackages/cli/src/ingest.test.tspackages/cli/src/scan.test.tspackages/cli/src/runtime.test.tspackages/cli/src/setup.test.tspackages/cli/src/connection.test.ts
-
Step 1: Search remaining direct console calls in shipped packages
Run:
rg -n "console\\.(log|warn|error|debug)" packages/cli/src packages/context/src -g '!*.test.ts'
Expected: no matches in shipped code. If matches remain in scripts or tests, leave them alone. If matches remain under packages/context/src or packages/cli/src, convert them to injected logger/io or justify them in the final handoff.
- Step 2: Search direct process stdout/stderr reads in CLI code
Run:
rg -n "process\\.(stdout|stderr|stdin)" packages/cli/src -g '!*.test.ts'
Expected allowed matches:
- terminal sizing fallbacks such as
process.stdout.columns - startup profiling to
process.stderr runKtxCli(..., io = process)entry defaults
Any prompt behavior or command result printing found here should be converted to KtxCliIo.
- Step 3: Add index-level JSON safety tests for representative commands
In packages/cli/src/index.test.ts, add a table-driven test for commands that should produce parseable JSON without stderr:
it('keeps representative JSON command stdout parseable', async () => {
const commands = [
{
argv: ['--project-dir', tempDir, 'setup', 'status', '--json'],
deps: {},
},
{
argv: ['--project-dir', tempDir, 'sl', 'list', '--json'],
deps: {},
},
];
for (const command of commands) {
const testIo = makeIo();
await runKtxCli(command.argv, testIo.io, command.deps);
expect(() => JSON.parse(testIo.stdout())).not.toThrow();
expect(testIo.stderr()).toBe('');
}
});
Use existing setup helpers in index.test.ts so tempDir contains a valid KTX project before the test runs.
- Step 4: Run focused CLI regression tests
Run:
pnpm --filter @ktx/cli exec vitest run src/index.test.ts src/ingest.test.ts src/sl.test.ts src/runtime.test.ts
Expected: PASS.
- Step 5: Run package type checks
Run:
pnpm --filter @ktx/context run type-check
pnpm --filter @ktx/cli run type-check
Expected: PASS.
- Step 6: Run package tests
Run:
pnpm --filter @ktx/context run test
pnpm --filter @ktx/cli run test
Expected: PASS. If slow tests are excluded by package scripts, also run affected slow CLI tests explicitly:
pnpm --filter @ktx/cli exec vitest run src/ingest.test.ts src/ingest-viz.test.ts src/setup.test.ts src/setup-databases.test.ts src/setup-models.test.ts src/setup-sources.test.ts src/setup-embeddings.test.ts --testTimeout 30000
- Step 7: Run pre-commit on changed files
Run:
uv run pre-commit run --files \
packages/context/src/ingest/adapters/metabase/fetch.ts \
packages/context/src/ingest/adapters/metabase/fetch.test.ts \
packages/context/src/ingest/adapters/metabase/client.ts \
packages/context/src/ingest/adapters/metabase/client.test.ts \
packages/context/src/ingest/adapters/looker/client.ts \
packages/context/src/ingest/adapters/looker/client.test.ts \
packages/context/src/ingest/adapters/notion/fetch.ts \
packages/context/src/ingest/adapters/notion/fetch.test.ts \
packages/context/src/ingest/adapters/metabase/metabase.adapter.ts \
packages/context/src/ingest/adapters/metabase/local-metabase.adapter.ts \
packages/context/src/ingest/adapters/looker/local-looker.adapter.ts \
packages/context/src/ingest/adapters/notion/notion.adapter.ts \
packages/cli/src/io/logger.ts \
packages/cli/src/io/logger.test.ts \
packages/cli/src/io/mode.ts \
packages/cli/src/io/mode.test.ts \
packages/cli/src/io/print-list.ts \
packages/cli/src/local-adapters.ts \
packages/cli/src/ingest.ts \
packages/cli/src/ingest.test.ts \
packages/cli/src/ingest-viz.test.ts \
packages/cli/src/clack.ts \
packages/cli/src/managed-python-command.ts \
packages/cli/src/managed-python-command.test.ts \
packages/cli/src/sl.test.ts \
packages/cli/src/index.test.ts
Expected: PASS. If pre-commit is unavailable or version-pinned beyond the local uv, state the blocker and run the closest package checks above.
- Step 8: Commit regression sweep
git add packages/cli/src packages/context/src
git commit -m "test(cli): cover output channel invariants"
Self-Review
Spec coverage: This plan covers the requested harmonization across Commander, Clack, stdout/stderr, JSON safety, adapter logging, progress rendering, and dev-friendly result output. It explicitly targets implementation inconsistencies found in packages/context adapters, packages/cli/src/ingest.ts, packages/cli/src/clack.ts, packages/cli/src/managed-python-command.ts, and shared output helpers.
Marker scan: The plan avoids unfinished-marker text. The only conditional instruction is to use existing fixture helper names in sl.test.ts and index.test.ts, because those names must be confirmed in the file at execution time to avoid inventing duplicate helpers.
Type consistency: The plan uses KtxCliIo, KtxOutputMode, KtxOperationalLogger, MetabaseFetchLogger, MetabaseClientLogger, LookerClientLogger, and NotionFetchLogger consistently. The one cross-package rule is intentional: reusable context code owns noop defaults; CLI code owns stderr/noop operational routing.