Skip to content

Commit af84f96

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 f8d8d78 commit af84f96

5 files changed

Lines changed: 359 additions & 81 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. Daemon guidance such as "Daemon is not running" is written to stderr in these formats, so stdout stays parseable.
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`; `daemon status -f json` returns `{ "running": false }` when no daemon is reachable. Daemon guidance for these two goes to stderr so stdout stays parseable.
138+
137139
## Output Formats
138140

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

src/cli.test.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1666,6 +1666,147 @@ describe('profile list', () => {
16661666
});
16671667
});
16681668

1669+
describe('structured output for data-returning built-ins', () => {
1670+
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1671+
1672+
beforeEach(() => {
1673+
process.exitCode = undefined;
1674+
consoleLogSpy.mockClear();
1675+
vi.stubGlobal('fetch', vi.fn());
1676+
});
1677+
1678+
const stdout = () => consoleLogSpy.mock.calls.flat().join('\n');
1679+
1680+
// Later describes in this file install their own console.error spy at
1681+
// collection time, which would shadow a describe-level one here. Spy inside
1682+
// the test and restore, matching the local Session format tests below.
1683+
const captureStderr = async (run: () => Promise<void>): Promise<string> => {
1684+
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
1685+
try {
1686+
await run();
1687+
return spy.mock.calls.flat().join('\n');
1688+
} finally {
1689+
spy.mockRestore();
1690+
}
1691+
};
1692+
1693+
const daemonStatusResponse = (overrides: Record<string, unknown> = {}) => ({
1694+
ok: true,
1695+
json: async () => ({
1696+
ok: true,
1697+
pid: 123,
1698+
uptime: 12,
1699+
daemonVersion: PKG_VERSION,
1700+
runtimeConnected: true,
1701+
runtimeName: 'Cloak',
1702+
runtimeVersion: '1.0.3',
1703+
profiles: [],
1704+
pending: 0,
1705+
memoryMB: 20,
1706+
port: 9777,
1707+
...overrides,
1708+
}),
1709+
} as Response);
1710+
1711+
it('renders validate as JSON without the human report', async () => {
1712+
await createProgram('', '').parseAsync(['node', 'webcmd', 'validate', '-f', 'json']);
1713+
1714+
expect(JSON.parse(stdout())).toMatchObject({
1715+
ok: expect.any(Boolean),
1716+
errors: expect.any(Number),
1717+
warnings: expect.any(Number),
1718+
commands: expect.any(Number),
1719+
});
1720+
});
1721+
1722+
it('keeps the human validate report when no format is requested', async () => {
1723+
await createProgram('', '').parseAsync(['node', 'webcmd', 'validate']);
1724+
1725+
expect(() => JSON.parse(stdout())).toThrow();
1726+
});
1727+
1728+
it('renders verify as YAML and still sets the report exit code', async () => {
1729+
await createProgram('', '').parseAsync(['node', 'webcmd', 'verify', '-f', 'yaml']);
1730+
1731+
const parsed = yaml.load(stdout()) as { ok: boolean; validation: unknown };
1732+
expect(parsed).toMatchObject({ ok: expect.any(Boolean) });
1733+
expect(parsed.validation).toBeDefined();
1734+
expect(process.exitCode).toBe(parsed.ok ? 0 : 1);
1735+
});
1736+
1737+
it('renders the same skill rows for bare skills and skills list', async () => {
1738+
await createProgram('', '').parseAsync(['node', 'webcmd', 'skills', '-f', 'json']);
1739+
const bare = JSON.parse(stdout());
1740+
consoleLogSpy.mockClear();
1741+
1742+
await createProgram('', '').parseAsync(['node', 'webcmd', 'skills', 'list', '-f', 'json']);
1743+
expect(JSON.parse(stdout())).toEqual(bare);
1744+
});
1745+
1746+
it('renders daemon status as JSON', async () => {
1747+
vi.mocked(fetch).mockResolvedValue(daemonStatusResponse());
1748+
1749+
await createProgram('', '').parseAsync(['node', 'webcmd', 'daemon', 'status', '-f', 'json']);
1750+
1751+
expect(JSON.parse(stdout())).toMatchObject({
1752+
running: true,
1753+
stale: false,
1754+
pid: 123,
1755+
port: 9777,
1756+
runtimeConnected: true,
1757+
runtimeName: 'Cloak',
1758+
});
1759+
});
1760+
1761+
it('reports a stopped daemon as structured data rather than prose', async () => {
1762+
vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED'));
1763+
1764+
await createProgram('', '').parseAsync(['node', 'webcmd', 'daemon', 'status', '-f', 'json']);
1765+
1766+
expect(JSON.parse(stdout())).toEqual({ running: false });
1767+
});
1768+
1769+
it('renders profile list rows and marks disconnected saved profiles', async () => {
1770+
vi.mocked(fetch).mockResolvedValue(daemonStatusResponse({
1771+
profiles: [{ contextId: 'ctx_live', runtimeConnected: true, runtimeVersion: '1.0.3', pending: 0 }],
1772+
}));
1773+
1774+
await createProgram('', '').parseAsync(['node', 'webcmd', 'profile', 'list', '-f', 'json']);
1775+
1776+
expect(JSON.parse(stdout())).toEqual([
1777+
{ contextId: 'ctx_live', alias: null, default: false, connected: true, runtimeVersion: '1.0.3' },
1778+
]);
1779+
});
1780+
1781+
it('keeps profile list daemon guidance off stdout when a format is requested', async () => {
1782+
vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED'));
1783+
1784+
const stderr = await captureStderr(async () => {
1785+
await createProgram('', '').parseAsync(['node', 'webcmd', 'profile', 'list', '-f', 'json']);
1786+
});
1787+
1788+
expect(JSON.parse(stdout())).toEqual([]);
1789+
expect(stderr).toContain('Daemon is not running');
1790+
});
1791+
1792+
it.each([
1793+
['validate'],
1794+
['verify'],
1795+
['skills'],
1796+
['doctor'],
1797+
['daemon', 'status'],
1798+
['profile', 'list'],
1799+
])('rejects an unsupported format for %s', async (...command) => {
1800+
const stderr = await captureStderr(async () => {
1801+
await createProgram('', '').parseAsync(['node', 'webcmd', ...command, '-f', 'xml']);
1802+
});
1803+
1804+
expect(process.exitCode).toBe(2);
1805+
expect(stderr).toContain('Unknown output format "xml"');
1806+
expect(stdout()).toBe('');
1807+
});
1808+
});
1809+
16691810
describe('browser raw session commands', () => {
16701811
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
16711812
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);

0 commit comments

Comments
 (0)