diff --git a/docs/cli-reference.mdx b/docs/cli-reference.mdx index 956a414b..0c3a1c6d 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. If the daemon is unreachable or stale, `profile list -f json`/`-f yaml` fails with a `DAEMON_UNAVAILABLE` error (exit 1) and a restart hint instead of returning `[]` — an empty list and an unreadable runtime are different facts. + ## Global Flags | Flag / Env | Purpose | diff --git a/skill-src/webcmd-usage/SKILL.src.md b/skill-src/webcmd-usage/SKILL.src.md index 046384ef..c243786b 100644 --- a/skill-src/webcmd-usage/SKILL.src.md +++ b/skill-src/webcmd-usage/SKILL.src.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`, and fails with a `DAEMON_UNAVAILABLE` error (exit 1) instead of `[]` when the daemon is unreachable or stale. `daemon status -f json` returns `{ "running": false }` when no daemon is reachable; that guidance goes to stdout as data, not stderr. + ## Output Formats - `json`: pretty-printed, 2-space indent. Best default for agents. diff --git a/skills/webcmd-usage/SKILL.md b/skills/webcmd-usage/SKILL.md index ce65bef4..3a2e7034 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`, and fails with a `DAEMON_UNAVAILABLE` error (exit 1) instead of `[]` when the daemon is unreachable or stale. `daemon status -f json` returns `{ "running": false }` when no daemon is reachable; that guidance goes to stdout as data, not stderr. + ## 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 070bb425..4b8c2546 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1743,6 +1743,149 @@ 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: '', default: false, connected: true, runtimeVersion: '1.0.3' }, + ]); + }); + + it('fails structured profile list with DAEMON_UNAVAILABLE instead of an empty array', async () => { + vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED')); + + const stderr = await captureStderr(async () => { + await createProgram('', '').parseAsync(['node', 'webcmd', 'profile', 'list', '-f', 'json']); + }); + + expect(process.exitCode).toBe(1); + expect(stdout()).toBe(''); + expect(stderr).toContain('Daemon is not running; profile list is incomplete.'); + expect(stderr).toContain('Run webcmd doctor after opening Chrome.'); + }); + + 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 6390ff97..da953ffa 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -667,56 +667,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 @@ -1251,16 +1262,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) => { @@ -1781,11 +1797,13 @@ cli({ adapterCmd.command('path').argument('').action((commandKey: string) => reportLocalAdapterPath(commandKey)); // ── 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') .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table') @@ -1898,10 +1916,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;