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. 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 |
Expand Down
2 changes: 2 additions & 0 deletions skill-src/webcmd-usage/SKILL.src.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`, 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.
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`, 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.
Expand Down
143 changes: 143 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1724,6 +1724,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<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: '', 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);
Expand Down
103 changes: 63 additions & 40 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <fmt>', 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 <fmt>', 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<void> =>
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 <fmt>', 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 <fmt>', 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
Expand Down Expand Up @@ -1241,16 +1252,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 <fmt>', 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) => {
Expand Down Expand Up @@ -1771,11 +1787,13 @@ cli({
adapterCmd.command('path').argument('<command>').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 <fmt>', OUTPUT_FORMAT_HELP, 'table')
Expand Down Expand Up @@ -1888,10 +1906,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 <fmt>', 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')
Expand Down
Loading
Loading