Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/cli-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions skills/webcmd-usage/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ Use this fallback order:

Command-specific flags such as `--limit` and `--filter` are not universal. Read `<site> <command> --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.
Expand Down
141 changes: 141 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>): Promise<string> => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
await run();
return spy.mock.calls.flat().join('\n');
} finally {
spy.mockRestore();
}
};

const daemonStatusResponse = (overrides: Record<string, unknown> = {}) => ({
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);
Expand Down
Loading