diff --git a/docs/cli-reference.mdx b/docs/cli-reference.mdx index 956a414b..fc726578 100644 --- a/docs/cli-reference.mdx +++ b/docs/cli-reference.mdx @@ -160,6 +160,24 @@ webcmd hackernews top -f csv Agents should use JSON unless they are presenting output to a human. +### Reports and status commands + +`validate`, `verify`, `doctor`, `skills`, `adapter status`, `daemon status`, and `profile list` also accept `-f/--format`: + +```bash +webcmd validate -f json +webcmd verify -f yaml +webcmd doctor -f json +webcmd daemon status -f json +webcmd profile list -f json +``` + +Each keeps its human-readable report as the `table` rendering, which stays the default. Pass another format to get the underlying result object instead — the validation report for `validate`, the verify report for `verify`, the diagnostic report for `doctor`, and a row set for `profile list`. + +`daemon status -f json` returns `{ "running": false }` when no daemon is reachable, and otherwise reports `running`, `stale`, `pid`, `version`, `uptimeMs`, `runtimeConnected`, `profiles`, `memoryMB`, and `port`. + +`profile list` returns one row per profile with `contextId`, `alias`, `default`, `connected`, and `runtimeVersion`, covering both connected profiles and saved aliases that are not currently connected. Daemon guidance such as "Daemon is not running" is written to stderr in these formats, so stdout stays parseable. + ## Global Flags | Flag / Env | Purpose | diff --git a/skills/webcmd-usage/SKILL.md b/skills/webcmd-usage/SKILL.md index ce65bef4..94dcf05a 100644 --- a/skills/webcmd-usage/SKILL.md +++ b/skills/webcmd-usage/SKILL.md @@ -134,6 +134,8 @@ Use this fallback order: Command-specific flags such as `--limit` and `--filter` are not universal. Read ` --help`. +Report and status commands — `validate`, `verify`, `doctor`, `skills`, `adapter status`, `daemon status`, and `profile list` — default to a human-readable `table` rendering and return their underlying result object under any other format. Use `-f json` when parsing them. `profile list -f json` returns rows of `contextId`, `alias`, `default`, `connected`, and `runtimeVersion`; `daemon status -f json` returns `{ "running": false }` when no daemon is reachable. Daemon guidance for these two goes to stderr so stdout stays parseable. + ## Output Formats - `json`: pretty-printed, 2-space indent. Best default for agents. diff --git a/src/cli.test.ts b/src/cli.test.ts index 21e669c3..071c460a 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1666,6 +1666,147 @@ describe('profile list', () => { }); }); +describe('structured output for data-returning built-ins', () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + beforeEach(() => { + process.exitCode = undefined; + consoleLogSpy.mockClear(); + vi.stubGlobal('fetch', vi.fn()); + }); + + const stdout = () => consoleLogSpy.mock.calls.flat().join('\n'); + + // Later describes in this file install their own console.error spy at + // collection time, which would shadow a describe-level one here. Spy inside + // the test and restore, matching the local Session format tests below. + const captureStderr = async (run: () => Promise): Promise => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await run(); + return spy.mock.calls.flat().join('\n'); + } finally { + spy.mockRestore(); + } + }; + + const daemonStatusResponse = (overrides: Record = {}) => ({ + ok: true, + json: async () => ({ + ok: true, + pid: 123, + uptime: 12, + daemonVersion: PKG_VERSION, + runtimeConnected: true, + runtimeName: 'Cloak', + runtimeVersion: '1.0.3', + profiles: [], + pending: 0, + memoryMB: 20, + port: 9777, + ...overrides, + }), + } as Response); + + it('renders validate as JSON without the human report', async () => { + await createProgram('', '').parseAsync(['node', 'webcmd', 'validate', '-f', 'json']); + + expect(JSON.parse(stdout())).toMatchObject({ + ok: expect.any(Boolean), + errors: expect.any(Number), + warnings: expect.any(Number), + commands: expect.any(Number), + }); + }); + + it('keeps the human validate report when no format is requested', async () => { + await createProgram('', '').parseAsync(['node', 'webcmd', 'validate']); + + expect(() => JSON.parse(stdout())).toThrow(); + }); + + it('renders verify as YAML and still sets the report exit code', async () => { + await createProgram('', '').parseAsync(['node', 'webcmd', 'verify', '-f', 'yaml']); + + const parsed = yaml.load(stdout()) as { ok: boolean; validation: unknown }; + expect(parsed).toMatchObject({ ok: expect.any(Boolean) }); + expect(parsed.validation).toBeDefined(); + expect(process.exitCode).toBe(parsed.ok ? 0 : 1); + }); + + it('renders the same skill rows for bare skills and skills list', async () => { + await createProgram('', '').parseAsync(['node', 'webcmd', 'skills', '-f', 'json']); + const bare = JSON.parse(stdout()); + consoleLogSpy.mockClear(); + + await createProgram('', '').parseAsync(['node', 'webcmd', 'skills', 'list', '-f', 'json']); + expect(JSON.parse(stdout())).toEqual(bare); + }); + + it('renders daemon status as JSON', async () => { + vi.mocked(fetch).mockResolvedValue(daemonStatusResponse()); + + await createProgram('', '').parseAsync(['node', 'webcmd', 'daemon', 'status', '-f', 'json']); + + expect(JSON.parse(stdout())).toMatchObject({ + running: true, + stale: false, + pid: 123, + port: 9777, + runtimeConnected: true, + runtimeName: 'Cloak', + }); + }); + + it('reports a stopped daemon as structured data rather than prose', async () => { + vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED')); + + await createProgram('', '').parseAsync(['node', 'webcmd', 'daemon', 'status', '-f', 'json']); + + expect(JSON.parse(stdout())).toEqual({ running: false }); + }); + + it('renders profile list rows and marks disconnected saved profiles', async () => { + vi.mocked(fetch).mockResolvedValue(daemonStatusResponse({ + profiles: [{ contextId: 'ctx_live', runtimeConnected: true, runtimeVersion: '1.0.3', pending: 0 }], + })); + + await createProgram('', '').parseAsync(['node', 'webcmd', 'profile', 'list', '-f', 'json']); + + expect(JSON.parse(stdout())).toEqual([ + { contextId: 'ctx_live', alias: null, default: false, connected: true, runtimeVersion: '1.0.3' }, + ]); + }); + + it('keeps profile list daemon guidance off stdout when a format is requested', async () => { + vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED')); + + const stderr = await captureStderr(async () => { + await createProgram('', '').parseAsync(['node', 'webcmd', 'profile', 'list', '-f', 'json']); + }); + + expect(JSON.parse(stdout())).toEqual([]); + expect(stderr).toContain('Daemon is not running'); + }); + + it.each([ + ['validate'], + ['verify'], + ['skills'], + ['doctor'], + ['daemon', 'status'], + ['profile', 'list'], + ])('rejects an unsupported format for %s', async (...command) => { + const stderr = await captureStderr(async () => { + await createProgram('', '').parseAsync(['node', 'webcmd', ...command, '-f', 'xml']); + }); + + expect(process.exitCode).toBe(2); + expect(stderr).toContain('Unknown output format "xml"'); + expect(stdout()).toBe(''); + }); +}); + describe('browser raw session commands', () => { const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); diff --git a/src/cli.ts b/src/cli.ts index 234d93c3..e3fbe8a9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -664,56 +664,67 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi // ── Built-in: validate / verify ─────────────────────────────────────────── - program + const validateCmd = program .command('validate') .description('Validate CLI definitions') .argument('[target]', 'site or site/name') - .action(async (target) => { - const { validateClisWithTarget, renderValidationReport } = await import('./validate.js'); - console.log(renderValidationReport(validateClisWithTarget([BUILTIN_CLIS, USER_CLIS], target))); - }); + .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + validateCmd.action(async (target, opts) => { + const fmt = resolveOutputFormat(opts.format); + if (fmt === null) return; + const fmtExplicit = validateCmd.getOptionValueSource('format') === 'cli'; + const { validateClisWithTarget, renderValidationReport } = await import('./validate.js'); + const report = validateClisWithTarget([BUILTIN_CLIS, USER_CLIS], target); + if (fmt === 'table') console.log(renderValidationReport(report)); + else await renderOutput(report, { fmt, fmtExplicit }); + }); - program + const verifyCmd = program .command('verify') .description('Validate + smoke test') .argument('[target]') .option('--smoke', 'Run smoke tests', false) - .action(async (target, opts) => { - const { verifyClis, renderVerifyReport } = await import('./verify.js'); - const r = await verifyClis({ builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, target, smoke: opts.smoke }); - console.log(renderVerifyReport(r)); - process.exitCode = r.ok ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR; + .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + verifyCmd.action(async (target, opts) => { + const fmt = resolveOutputFormat(opts.format); + if (fmt === null) return; + const fmtExplicit = verifyCmd.getOptionValueSource('format') === 'cli'; + const { verifyClis, renderVerifyReport } = await import('./verify.js'); + const r = await verifyClis({ builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, target, smoke: opts.smoke }); + if (fmt === 'table') console.log(renderVerifyReport(r)); + else await renderOutput(r, { fmt, fmtExplicit }); + process.exitCode = r.ok ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR; + }); + + // Bare `skills` and `skills list` render the same rows; the only difference is + // the invocation reported in the table footer. + const renderSkillsList = (fmt: string, fmtExplicit: boolean, source: string): Promise => + renderOutput(listWebcmdSkills(), { + fmt, + fmtExplicit, + columns: ['name', 'description', 'version', 'path'], + title: 'webcmd/skills/list', + source, }); const skillsCmd = program .command('skills') .description('List, add, update, and remove bundled Webcmd skills') - .action(() => { - const rows = listWebcmdSkills(); - renderOutput(rows, { - fmt: 'table', - fmtExplicit: false, - columns: ['name', 'description', 'version', 'path'], - title: 'webcmd/skills/list', - source: 'webcmd skills', - }); - }); + .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + skillsCmd.action(async (opts) => { + const fmt = resolveOutputFormat(opts.format); + if (fmt === null) return; + await renderSkillsList(fmt, skillsCmd.getOptionValueSource('format') === 'cli', 'webcmd skills'); + }); const skillsListCmd = skillsCmd .command('list') .description('List bundled Webcmd skills') .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); - skillsListCmd.action((opts) => { + skillsListCmd.action(async (opts) => { const fmt = resolveOutputFormat(opts.format); if (fmt === null) return; - const rows = listWebcmdSkills(); - renderOutput(rows, { - fmt, - fmtExplicit: skillsListCmd.getOptionValueSource('format') === 'cli', - columns: ['name', 'description', 'version', 'path'], - title: 'webcmd/skills/list', - source: 'webcmd skills list', - }); + await renderSkillsList(fmt, skillsListCmd.getOptionValueSource('format') === 'cli', 'webcmd skills list'); }); skillsCmd @@ -1238,16 +1249,21 @@ cli({ })))); // ── Built-in: doctor / completion ────────────────────────────────────────── - program + const doctorCmd = program .command('doctor') .description('Diagnose webcmd browser bridge connectivity') .option('-v, --verbose', 'Debug output') - .action(async (opts) => { - applyVerbose(opts); - const { runBrowserDoctor, renderBrowserDoctorReport } = await import('./doctor.js'); - const report = await runBrowserDoctor({ cliVersion: PKG_VERSION }); - console.log(renderBrowserDoctorReport(report)); - }); + .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + doctorCmd.action(async (opts) => { + applyVerbose(opts); + const fmt = resolveOutputFormat(opts.format); + if (fmt === null) return; + const fmtExplicit = doctorCmd.getOptionValueSource('format') === 'cli'; + const { runBrowserDoctor, renderBrowserDoctorReport } = await import('./doctor.js'); + const report = await runBrowserDoctor({ cliVersion: PKG_VERSION }); + if (fmt === 'table') console.log(renderBrowserDoctorReport(report)); + else await renderOutput(report, { fmt, fmtExplicit }); + }); configureCompletionCommandSurface(program.command('completion')) .action((shell: string) => { @@ -1747,58 +1763,114 @@ cli({ .action(handleAdapterOverride); // ── Built-in: browser profile selection ────────────────────────────────── + const PROFILE_LIST_COLUMNS = ['contextId', 'alias', 'default', 'connected', 'runtimeVersion']; + const profileCmd = program.command('profile').description('Manage webcmd browser runtime profiles'); // Snapshot before applyRootSubcommandSummaries() rewrites .description() to a child-name listing. const originalProfileDescription = profileCmd.description(); - profileCmd + const profileListCmd = profileCmd .command('list') .description('List Chrome and Chromium profiles available through the Cloak runtime') - .action(async () => { - const status = await fetchDaemonStatus(); - const config = loadProfileConfig(); - const profiles = status?.profiles ?? []; - if (!status) { - console.log('Daemon is not running. Run webcmd doctor after opening Chrome.'); - return; - } - if (isDaemonStale(status, PKG_VERSION) || !Array.isArray(status.profiles)) { - console.log(`Daemon ${formatDaemonVersion(status)} is stale for CLI v${PKG_VERSION}.`); - console.log('Run: webcmd daemon restart'); - return; + .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + profileListCmd.action(async (opts) => { + const fmt = resolveOutputFormat(opts.format); + if (fmt === null) return; + const fmtExplicit = profileListCmd.getOptionValueSource('format') === 'cli'; + const asTable = fmt === 'table'; + const status = await fetchDaemonStatus(); + const config = loadProfileConfig(); + + // Daemon-state guidance is prose, not data. On the structured path it goes to + // stderr so stdout stays a parseable row set. + const notice = (...lines: string[]) => { + for (const line of lines) { + if (asTable) console.log(line); + else console.error(line); } - if (profiles.length === 0) { - console.log('No Cloak runtime profiles are active.'); - console.log('Run a browser-backed command or webcmd login to create one.'); - return; + }; + + if (!status) { + notice('Daemon is not running. Run webcmd doctor after opening Chrome.'); + if (!asTable) await renderOutput([], { fmt, fmtExplicit, columns: PROFILE_LIST_COLUMNS }); + return; + } + if (isDaemonStale(status, PKG_VERSION) || !Array.isArray(status.profiles)) { + notice( + `Daemon ${formatDaemonVersion(status)} is stale for CLI v${PKG_VERSION}.`, + 'Run: webcmd daemon restart', + ); + if (!asTable) await renderOutput([], { fmt, fmtExplicit, columns: PROFILE_LIST_COLUMNS }); + return; + } + + const profiles = status.profiles; + const knownContextIds = new Set(profiles.map((profile) => profile.contextId)); + const disconnectedAliases = Object.entries(config.aliases) + .filter(([, contextId]) => !knownContextIds.has(contextId)); + + if (!asTable) { + const rows = [ + ...profiles.map((profile) => ({ + contextId: profile.contextId, + alias: aliasForContextId(config, profile.contextId) ?? null, + default: config.defaultContextId === profile.contextId, + connected: true, + runtimeVersion: profile.runtimeVersion ?? null, + })), + ...disconnectedAliases.map(([alias, contextId]) => ({ + contextId, + alias, + default: config.defaultContextId === contextId, + connected: false, + runtimeVersion: null, + })), + ]; + // A default that is neither connected nor aliased still belongs in the set. + if (config.defaultContextId + && !knownContextIds.has(config.defaultContextId) + && !disconnectedAliases.some(([, contextId]) => contextId === config.defaultContextId)) { + rows.push({ + contextId: config.defaultContextId, + alias: null, + default: true, + connected: false, + runtimeVersion: null, + }); } + await renderOutput(rows, { fmt, fmtExplicit, columns: PROFILE_LIST_COLUMNS }); + return; + } - const knownContextIds = new Set(profiles.map((profile) => profile.contextId)); - console.log('Available Cloak profiles'); + if (profiles.length === 0) { + console.log('No Cloak runtime profiles are active.'); + console.log('Run a browser-backed command or webcmd login to create one.'); + return; + } + + console.log('Available Cloak profiles'); + console.log(); + for (const profile of profiles) { + const alias = aliasForContextId(config, profile.contextId); + const defaultMark = config.defaultContextId === profile.contextId ? ' default' : ''; + const aliasText = alias ? ` ${alias}` : ''; + const version = profile.runtimeVersion ? ` v${profile.runtimeVersion}` : ' version unknown'; + console.log(` ${profile.contextId}${aliasText}${defaultMark} — connected${version}`); + } + + if (disconnectedAliases.length > 0 || (config.defaultContextId && !knownContextIds.has(config.defaultContextId))) { console.log(); - for (const profile of profiles) { - const alias = aliasForContextId(config, profile.contextId); - const defaultMark = config.defaultContextId === profile.contextId ? ' default' : ''; - const aliasText = alias ? ` ${alias}` : ''; - const version = profile.runtimeVersion ? ` v${profile.runtimeVersion}` : ' version unknown'; - console.log(` ${profile.contextId}${aliasText}${defaultMark} — connected${version}`); + console.log('Disconnected saved profiles:'); + const shown = new Set(); + for (const [alias, contextId] of disconnectedAliases) { + shown.add(contextId); + console.log(` ${contextId} ${alias} — not connected`); } - - const disconnectedAliases = Object.entries(config.aliases) - .filter(([, contextId]) => !knownContextIds.has(contextId)); - if (disconnectedAliases.length > 0 || (config.defaultContextId && !knownContextIds.has(config.defaultContextId))) { - console.log(); - console.log('Disconnected saved profiles:'); - const shown = new Set(); - for (const [alias, contextId] of disconnectedAliases) { - shown.add(contextId); - console.log(` ${contextId} ${alias} — not connected`); - } - if (config.defaultContextId && !shown.has(config.defaultContextId) && !knownContextIds.has(config.defaultContextId)) { - console.log(` ${config.defaultContextId} — default, not connected`); - } + if (config.defaultContextId && !shown.has(config.defaultContextId) && !knownContextIds.has(config.defaultContextId)) { + console.log(` ${config.defaultContextId} — default, not connected`); } - }); + } + }); profileCmd .command('rename') @@ -1833,10 +1905,15 @@ cli({ const daemonCmd = program.command('daemon').description('Manage the webcmd daemon'); // Snapshot before applyRootSubcommandSummaries() rewrites .description() to a child-name listing. const originalDaemonDescription = daemonCmd.description(); - daemonCmd + const daemonStatusCmd = daemonCmd .command('status') .description('Show daemon status') - .action(async () => { await daemonStatus(); }); + .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + daemonStatusCmd.action(async (opts) => { + const fmt = resolveOutputFormat(opts.format); + if (fmt === null) return; + await daemonStatus({ fmt, fmtExplicit: daemonStatusCmd.getOptionValueSource('format') === 'cli' }); + }); daemonCmd .command('stop') .description('Stop the daemon') diff --git a/src/commands/daemon.ts b/src/commands/daemon.ts index ab69e22c..8a3c5bc8 100644 --- a/src/commands/daemon.ts +++ b/src/commands/daemon.ts @@ -5,15 +5,55 @@ * webcmd daemon restart — graceful shutdown, then start a fresh daemon */ -import { fetchDaemonStatus, requestDaemonShutdown } from '../browser/daemon-transport.js'; +import { fetchDaemonStatus, requestDaemonShutdown, type DaemonStatus } from '../browser/daemon-transport.js'; import { restartDaemon } from '../browser/daemon-lifecycle.js'; import { formatDuration } from '../download/progress.js'; import { log } from '../logger.js'; import { PKG_VERSION } from '../version.js'; import { formatDaemonVersion, isDaemonStale } from '../browser/daemon-version.js'; +import { render } from '../output.js'; -export async function daemonStatus(): Promise { +/** Machine-readable projection of `daemon status`, mirroring the text rendering. */ +function daemonStatusData(status: DaemonStatus | null): Record { + if (!status) return { running: false }; + const stale = isDaemonStale(status, PKG_VERSION); + return { + running: true, + stale, + pid: status.pid, + version: formatDaemonVersion(status), + cliVersion: PKG_VERSION, + uptimeMs: Math.round(status.uptime * 1000), + runtimeConnected: status.runtimeConnected, + runtimeName: status.runtimeName, + runtimeVersion: status.runtimeVersion ?? null, + profileRequired: status.profileRequired === true, + profileDisconnected: status.profileDisconnected === true, + profiles: (status.profiles ?? []).map(profile => ({ + contextId: profile.contextId, + runtimeConnected: profile.runtimeConnected, + runtimeVersion: profile.runtimeVersion ?? null, + })), + memoryMB: status.memoryMB, + port: status.port, + }; +} + +export interface DaemonStatusOptions { + fmt?: string; + fmtExplicit?: boolean; +} + +export async function daemonStatus(opts: DaemonStatusOptions = {}): Promise { + const fmt = opts.fmt ?? 'table'; + const fmtExplicit = opts.fmtExplicit ?? false; const status = await fetchDaemonStatus(); + + if (fmt !== 'table') { + await render(daemonStatusData(status), { fmt, fmtExplicit }); + return; + } + if (!status) { console.log('Daemon: not running'); return;