Skip to content

Commit eb7b9dc

Browse files
Agnik47claude
andcommitted
feat: add structured output to report and status built-ins
Part of #175. `validate`, `verify`, `doctor`, bare `skills`, `daemon status`, and `profile list` returned stable data internally but rejected `-f/--format` and printed hand-written text only. Each now routes through the shared `resolveOutputFormat` + `render` path introduced in #190. `adapter status` already gained `-f` in #190, so it needed no change here. Each command keeps its human-readable report as the `table` rendering, which remains the default, and returns the underlying result object under any other format. Following the `convention-audit` precedent, the human text is chosen on the raw format rather than the TTY-resolved one, so no existing implicit behavior changes. `daemon status` gains a machine-readable projection that mirrors the text rendering, reporting `{ running: false }` when no daemon is reachable. `profile list` returns one row per profile covering both connected profiles and saved-but-disconnected aliases, aligning it with the hosted profile-list row set. Daemon guidance for those two goes to stderr under structured formats so stdout stays parseable. Bare `skills` and `skills list` now share one renderer instead of duplicating the row set with a hardcoded format. Browser read commands are intentionally left for a follow-up: they go through the session/bridge layer and need separate care around the streaming and JSONL-follow exclusions the issue calls out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVuD882dJuEwWKWBLaf7bs
1 parent 5d42052 commit eb7b9dc

5 files changed

Lines changed: 268 additions & 42 deletions

File tree

docs/cli-reference.mdx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,24 @@ webcmd hackernews top -f csv
160160

161161
Agents should use JSON unless they are presenting output to a human.
162162

163+
### Reports and status commands
164+
165+
`validate`, `verify`, `doctor`, `skills`, `adapter status`, `daemon status`, and `profile list` also accept `-f/--format`:
166+
167+
```bash
168+
webcmd validate -f json
169+
webcmd verify -f yaml
170+
webcmd doctor -f json
171+
webcmd daemon status -f json
172+
webcmd profile list -f json
173+
```
174+
175+
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`.
176+
177+
`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`.
178+
179+
`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.
180+
163181
## Global Flags
164182

165183
| Flag / Env | Purpose |

skills/webcmd-usage/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ Use this fallback order:
134134

135135
Command-specific flags such as `--limit` and `--filter` are not universal. Read `<site> <command> --help`.
136136

137+
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.
138+
137139
## Output Formats
138140

139141
- `json`: pretty-printed, 2-space indent. Best default for agents.

src/cli.test.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1724,6 +1724,149 @@ describe('profile list', () => {
17241724
});
17251725
});
17261726

1727+
describe('structured output for data-returning built-ins', () => {
1728+
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1729+
1730+
beforeEach(() => {
1731+
process.exitCode = undefined;
1732+
consoleLogSpy.mockClear();
1733+
vi.stubGlobal('fetch', vi.fn());
1734+
});
1735+
1736+
const stdout = () => consoleLogSpy.mock.calls.flat().join('\n');
1737+
1738+
// Later describes in this file install their own console.error spy at
1739+
// collection time, which would shadow a describe-level one here. Spy inside
1740+
// the test and restore, matching the local Session format tests below.
1741+
const captureStderr = async (run: () => Promise<void>): Promise<string> => {
1742+
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
1743+
try {
1744+
await run();
1745+
return spy.mock.calls.flat().join('\n');
1746+
} finally {
1747+
spy.mockRestore();
1748+
}
1749+
};
1750+
1751+
const daemonStatusResponse = (overrides: Record<string, unknown> = {}) => ({
1752+
ok: true,
1753+
json: async () => ({
1754+
ok: true,
1755+
pid: 123,
1756+
uptime: 12,
1757+
daemonVersion: PKG_VERSION,
1758+
runtimeConnected: true,
1759+
runtimeName: 'Cloak',
1760+
runtimeVersion: '1.0.3',
1761+
profiles: [],
1762+
pending: 0,
1763+
memoryMB: 20,
1764+
port: 9777,
1765+
...overrides,
1766+
}),
1767+
} as Response);
1768+
1769+
it('renders validate as JSON without the human report', async () => {
1770+
await createProgram('', '').parseAsync(['node', 'webcmd', 'validate', '-f', 'json']);
1771+
1772+
expect(JSON.parse(stdout())).toMatchObject({
1773+
ok: expect.any(Boolean),
1774+
errors: expect.any(Number),
1775+
warnings: expect.any(Number),
1776+
commands: expect.any(Number),
1777+
});
1778+
});
1779+
1780+
it('keeps the human validate report when no format is requested', async () => {
1781+
await createProgram('', '').parseAsync(['node', 'webcmd', 'validate']);
1782+
1783+
expect(() => JSON.parse(stdout())).toThrow();
1784+
});
1785+
1786+
it('renders verify as YAML and still sets the report exit code', async () => {
1787+
await createProgram('', '').parseAsync(['node', 'webcmd', 'verify', '-f', 'yaml']);
1788+
1789+
const parsed = yaml.load(stdout()) as { ok: boolean; validation: unknown };
1790+
expect(parsed).toMatchObject({ ok: expect.any(Boolean) });
1791+
expect(parsed.validation).toBeDefined();
1792+
expect(process.exitCode).toBe(parsed.ok ? 0 : 1);
1793+
});
1794+
1795+
it('renders the same skill rows for bare skills and skills list', async () => {
1796+
await createProgram('', '').parseAsync(['node', 'webcmd', 'skills', '-f', 'json']);
1797+
const bare = JSON.parse(stdout());
1798+
consoleLogSpy.mockClear();
1799+
1800+
await createProgram('', '').parseAsync(['node', 'webcmd', 'skills', 'list', '-f', 'json']);
1801+
expect(JSON.parse(stdout())).toEqual(bare);
1802+
});
1803+
1804+
it('renders daemon status as JSON', async () => {
1805+
vi.mocked(fetch).mockResolvedValue(daemonStatusResponse());
1806+
1807+
await createProgram('', '').parseAsync(['node', 'webcmd', 'daemon', 'status', '-f', 'json']);
1808+
1809+
expect(JSON.parse(stdout())).toMatchObject({
1810+
running: true,
1811+
stale: false,
1812+
pid: 123,
1813+
port: 9777,
1814+
runtimeConnected: true,
1815+
runtimeName: 'Cloak',
1816+
});
1817+
});
1818+
1819+
it('reports a stopped daemon as structured data rather than prose', async () => {
1820+
vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED'));
1821+
1822+
await createProgram('', '').parseAsync(['node', 'webcmd', 'daemon', 'status', '-f', 'json']);
1823+
1824+
expect(JSON.parse(stdout())).toEqual({ running: false });
1825+
});
1826+
1827+
it('renders profile list rows and marks disconnected saved profiles', async () => {
1828+
vi.mocked(fetch).mockResolvedValue(daemonStatusResponse({
1829+
profiles: [{ contextId: 'ctx_live', runtimeConnected: true, runtimeVersion: '1.0.3', pending: 0 }],
1830+
}));
1831+
1832+
await createProgram('', '').parseAsync(['node', 'webcmd', 'profile', 'list', '-f', 'json']);
1833+
1834+
expect(JSON.parse(stdout())).toEqual([
1835+
{ contextId: 'ctx_live', alias: '', default: false, connected: true, runtimeVersion: '1.0.3' },
1836+
]);
1837+
});
1838+
1839+
it('fails structured profile list with DAEMON_UNAVAILABLE instead of an empty array', async () => {
1840+
vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED'));
1841+
1842+
const stderr = await captureStderr(async () => {
1843+
await createProgram('', '').parseAsync(['node', 'webcmd', 'profile', 'list', '-f', 'json']);
1844+
});
1845+
1846+
expect(process.exitCode).toBe(1);
1847+
expect(stdout()).toBe('');
1848+
expect(stderr).toContain('Daemon is not running; profile list is incomplete.');
1849+
expect(stderr).toContain('Run webcmd doctor after opening Chrome.');
1850+
});
1851+
1852+
it.each([
1853+
['validate'],
1854+
['verify'],
1855+
['skills'],
1856+
['doctor'],
1857+
['daemon', 'status'],
1858+
['profile', 'list'],
1859+
])('rejects an unsupported format for %s', async (...command) => {
1860+
const stderr = await captureStderr(async () => {
1861+
await createProgram('', '').parseAsync(['node', 'webcmd', ...command, '-f', 'xml']);
1862+
});
1863+
1864+
expect(process.exitCode).toBe(2);
1865+
expect(stderr).toContain('Unknown output format "xml"');
1866+
expect(stdout()).toBe('');
1867+
});
1868+
});
1869+
17271870
describe('browser raw session commands', () => {
17281871
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
17291872
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);

src/cli.ts

Lines changed: 63 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -667,56 +667,67 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi
667667

668668
// ── Built-in: validate / verify ───────────────────────────────────────────
669669

670-
program
670+
const validateCmd = program
671671
.command('validate')
672672
.description('Validate CLI definitions')
673673
.argument('[target]', 'site or site/name')
674-
.action(async (target) => {
675-
const { validateClisWithTarget, renderValidationReport } = await import('./validate.js');
676-
console.log(renderValidationReport(validateClisWithTarget([BUILTIN_CLIS, USER_CLIS], target)));
677-
});
674+
.option('-f, --format <fmt>', OUTPUT_FORMAT_HELP, 'table');
675+
validateCmd.action(async (target, opts) => {
676+
const fmt = resolveOutputFormat(opts.format);
677+
if (fmt === null) return;
678+
const fmtExplicit = validateCmd.getOptionValueSource('format') === 'cli';
679+
const { validateClisWithTarget, renderValidationReport } = await import('./validate.js');
680+
const report = validateClisWithTarget([BUILTIN_CLIS, USER_CLIS], target);
681+
if (fmt === 'table') console.log(renderValidationReport(report));
682+
else await renderOutput(report, { fmt, fmtExplicit });
683+
});
678684

679-
program
685+
const verifyCmd = program
680686
.command('verify')
681687
.description('Validate + smoke test')
682688
.argument('[target]')
683689
.option('--smoke', 'Run smoke tests', false)
684-
.action(async (target, opts) => {
685-
const { verifyClis, renderVerifyReport } = await import('./verify.js');
686-
const r = await verifyClis({ builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, target, smoke: opts.smoke });
687-
console.log(renderVerifyReport(r));
688-
process.exitCode = r.ok ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR;
690+
.option('-f, --format <fmt>', OUTPUT_FORMAT_HELP, 'table');
691+
verifyCmd.action(async (target, opts) => {
692+
const fmt = resolveOutputFormat(opts.format);
693+
if (fmt === null) return;
694+
const fmtExplicit = verifyCmd.getOptionValueSource('format') === 'cli';
695+
const { verifyClis, renderVerifyReport } = await import('./verify.js');
696+
const r = await verifyClis({ builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, target, smoke: opts.smoke });
697+
if (fmt === 'table') console.log(renderVerifyReport(r));
698+
else await renderOutput(r, { fmt, fmtExplicit });
699+
process.exitCode = r.ok ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR;
700+
});
701+
702+
// Bare `skills` and `skills list` render the same rows; the only difference is
703+
// the invocation reported in the table footer.
704+
const renderSkillsList = (fmt: string, fmtExplicit: boolean, source: string): Promise<void> =>
705+
renderOutput(listWebcmdSkills(), {
706+
fmt,
707+
fmtExplicit,
708+
columns: ['name', 'description', 'version', 'path'],
709+
title: 'webcmd/skills/list',
710+
source,
689711
});
690712

691713
const skillsCmd = program
692714
.command('skills')
693715
.description('List, add, update, and remove bundled Webcmd skills')
694-
.action(() => {
695-
const rows = listWebcmdSkills();
696-
renderOutput(rows, {
697-
fmt: 'table',
698-
fmtExplicit: false,
699-
columns: ['name', 'description', 'version', 'path'],
700-
title: 'webcmd/skills/list',
701-
source: 'webcmd skills',
702-
});
703-
});
716+
.option('-f, --format <fmt>', OUTPUT_FORMAT_HELP, 'table');
717+
skillsCmd.action(async (opts) => {
718+
const fmt = resolveOutputFormat(opts.format);
719+
if (fmt === null) return;
720+
await renderSkillsList(fmt, skillsCmd.getOptionValueSource('format') === 'cli', 'webcmd skills');
721+
});
704722

705723
const skillsListCmd = skillsCmd
706724
.command('list')
707725
.description('List bundled Webcmd skills')
708726
.option('-f, --format <fmt>', OUTPUT_FORMAT_HELP, 'table');
709-
skillsListCmd.action((opts) => {
727+
skillsListCmd.action(async (opts) => {
710728
const fmt = resolveOutputFormat(opts.format);
711729
if (fmt === null) return;
712-
const rows = listWebcmdSkills();
713-
renderOutput(rows, {
714-
fmt,
715-
fmtExplicit: skillsListCmd.getOptionValueSource('format') === 'cli',
716-
columns: ['name', 'description', 'version', 'path'],
717-
title: 'webcmd/skills/list',
718-
source: 'webcmd skills list',
719-
});
730+
await renderSkillsList(fmt, skillsListCmd.getOptionValueSource('format') === 'cli', 'webcmd skills list');
720731
});
721732

722733
skillsCmd
@@ -1241,16 +1252,21 @@ cli({
12411252
}))));
12421253
// ── Built-in: doctor / completion ──────────────────────────────────────────
12431254

1244-
program
1255+
const doctorCmd = program
12451256
.command('doctor')
12461257
.description('Diagnose webcmd browser bridge connectivity')
12471258
.option('-v, --verbose', 'Debug output')
1248-
.action(async (opts) => {
1249-
applyVerbose(opts);
1250-
const { runBrowserDoctor, renderBrowserDoctorReport } = await import('./doctor.js');
1251-
const report = await runBrowserDoctor({ cliVersion: PKG_VERSION });
1252-
console.log(renderBrowserDoctorReport(report));
1253-
});
1259+
.option('-f, --format <fmt>', OUTPUT_FORMAT_HELP, 'table');
1260+
doctorCmd.action(async (opts) => {
1261+
applyVerbose(opts);
1262+
const fmt = resolveOutputFormat(opts.format);
1263+
if (fmt === null) return;
1264+
const fmtExplicit = doctorCmd.getOptionValueSource('format') === 'cli';
1265+
const { runBrowserDoctor, renderBrowserDoctorReport } = await import('./doctor.js');
1266+
const report = await runBrowserDoctor({ cliVersion: PKG_VERSION });
1267+
if (fmt === 'table') console.log(renderBrowserDoctorReport(report));
1268+
else await renderOutput(report, { fmt, fmtExplicit });
1269+
});
12541270

12551271
configureCompletionCommandSurface(program.command('completion'))
12561272
.action((shell: string) => {
@@ -1771,11 +1787,13 @@ cli({
17711787
adapterCmd.command('path').argument('<command>').action((commandKey: string) => reportLocalAdapterPath(commandKey));
17721788

17731789
// ── Built-in: browser profile selection ──────────────────────────────────
1790+
const PROFILE_LIST_COLUMNS = ['contextId', 'alias', 'default', 'connected', 'runtimeVersion'];
1791+
17741792
const profileCmd = program.command('profile').description('Manage webcmd browser runtime profiles');
17751793
// Snapshot before applyRootSubcommandSummaries() rewrites .description() to a child-name listing.
17761794
const originalProfileDescription = profileCmd.description();
17771795

1778-
profileCmd
1796+
const profileListCmd = profileCmd
17791797
.command('list')
17801798
.description('List Chrome and Chromium profiles available through the Cloak runtime')
17811799
.option('-f, --format <fmt>', OUTPUT_FORMAT_HELP, 'table')
@@ -1888,10 +1906,15 @@ cli({
18881906
const daemonCmd = program.command('daemon').description('Manage the webcmd daemon');
18891907
// Snapshot before applyRootSubcommandSummaries() rewrites .description() to a child-name listing.
18901908
const originalDaemonDescription = daemonCmd.description();
1891-
daemonCmd
1909+
const daemonStatusCmd = daemonCmd
18921910
.command('status')
18931911
.description('Show daemon status')
1894-
.action(async () => { await daemonStatus(); });
1912+
.option('-f, --format <fmt>', OUTPUT_FORMAT_HELP, 'table');
1913+
daemonStatusCmd.action(async (opts) => {
1914+
const fmt = resolveOutputFormat(opts.format);
1915+
if (fmt === null) return;
1916+
await daemonStatus({ fmt, fmtExplicit: daemonStatusCmd.getOptionValueSource('format') === 'cli' });
1917+
});
18951918
daemonCmd
18961919
.command('stop')
18971920
.description('Stop the daemon')

0 commit comments

Comments
 (0)