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
7 changes: 7 additions & 0 deletions docs/cli-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ The old `web read` command has been renamed to `web fetch-browser`.

```bash
webcmd hackernews top -f table
webcmd hackernews top -f plain
webcmd hackernews top -f json
webcmd hackernews top -f yaml
webcmd hackernews top -f md
Expand Down Expand Up @@ -131,10 +132,16 @@ webcmd plugin install github:agentrhq/webcmd/plugins/ycombinator

Hosted mode supports the same `plugin search` and `plugin install` grammar for Webcmd-verified marketplace adapters. Other plugin management commands remain local-only in hosted mode.

`plugin list`, `plugin search`, and the `plugin catalog` commands honor the universal `-f/--format` flag from the [Output Formats](#output-formats) section. For example, `webcmd plugin list -f json` returns a JSON array of installed plugins (or `[]` when none are installed) and `-f yaml` emits YAML. Unknown format names are rejected with a usage error.

Note: `plugin list` always renders the human-friendly grouped listing when the effective format is `table` — whether that is the default or an explicit `-f table`, and regardless of TTY — so script-like automation can rely on `-f json` or `-f yaml` for machine-readable output.

## External CLIs

Agents can expose local tools through Webcmd, such as `gh`, `docker`, `vercel`, or internal CLIs.

`external list` honors the universal `-f/--format` flag (for example `webcmd external list -f json` or `-f yaml`). Without an explicit `-f`, it emits a table on a TTY and YAML when output is not a TTY.

Prompt example:

```text
Expand Down
3 changes: 2 additions & 1 deletion skills/webcmd-usage/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ Command-specific flags such as `--limit`, `--tab`, and `--filter` are not univer
- `yaml`: default when output is not a TTY and `-f` is not explicit.
- `table`: color-coded and grouped for humans.
- `md`, `csv`: tabular dumps.
- Unsupported format names are rejected with a usage error; `yml` and `markdown` are accepted aliases for `yaml` and `md`.

Some commands override the default through `cmd.defaultFormat`; read `--help`.

Expand Down Expand Up @@ -191,7 +192,7 @@ webcmd plugin catalog add <source>
webcmd plugin catalog remove <id>
```

Plugins are installable extensions pulled from git or local paths. Use `plugin search` for marketplace discovery and `plugin list` for already-installed plugins. Main-repo community CLIs are exposed through the root plugin catalog manifest, not bundled into npm's `clis/` set.
Plugins are installable extensions pulled from git or local paths. Use `plugin search` for marketplace discovery and `plugin list` for already-installed plugins. `webcmd plugin list -f json` returns an empty array `[]` when no plugins are installed. Note: `plugin list` renders its human-friendly grouped listing whenever the effective format is `table` (default or explicit, TTY or non-TTY); use `-f json` or `-f yaml` for machine-readable output. Main-repo community CLIs are exposed through the root plugin catalog manifest, not bundled into npm's `clis/` set.

> **Note:** The repository's `plugins/` directory is not shipped in the npm package. Find the required plugin with `webcmd plugin search`, then install its `installSource` with `webcmd plugin install <installSource>`.

Expand Down
5 changes: 3 additions & 2 deletions src/builtin-command-surface.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { OUTPUT_FORMAT_HELP } from './command-surface.js';
import type { Command } from 'commander';

export const LIST_COMMAND_DESCRIPTION = 'List all available CLI commands';
export const LIST_FORMAT_DESCRIPTION = 'Output format: table, json, yaml, md, csv';
export const LIST_FORMAT_DESCRIPTION = OUTPUT_FORMAT_HELP;
export const COMPLETION_COMMAND_DESCRIPTION = 'Output shell completion script';
export const COMPLETION_SHELL_DESCRIPTION = 'Shell type: bash, zsh, or fish';

Expand All @@ -25,7 +26,7 @@ export function configurePluginSearchSurface(command: Command): Command {
return command
.description('Search installable marketplace plugins')
.argument('[query]', 'Search query matched against plugin name and description')
.option('-f, --format <fmt>', 'Output format: table, json', 'table');
.option('-f, --format <fmt>', OUTPUT_FORMAT_HELP, 'table');
}

/** Configure plugin installation grammar shared by local and hosted runtimes. */
Expand Down
172 changes: 168 additions & 4 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
Expand All @@ -14,6 +14,7 @@ import {
formatRootHelp,
toPresentableCommand,
} from './command-presentation.js';
import { parseOutputFormat } from './command-surface.js';
import { render as renderOutput } from './output.js';

const {
Expand Down Expand Up @@ -366,7 +367,7 @@ name: 'search',
}
});

it.each(['json', 'yaml', 'yml'])(
it.each(['json', 'yaml', 'yml', 'md', 'csv', 'plain'])(
'renders local list %s through the shared list presentation',
async (format) => {
const registry = getRegistry();
Expand All @@ -384,7 +385,8 @@ name: 'search',
args: [{ name: 'limit', type: 'int', default: 20, help: 'Maximum issues' }],
columns: ['number', 'title'],
});
const presentation = commandListPresentation([toPresentableCommand(command)], format);
const normalized = parseOutputFormat(format);
const presentation = commandListPresentation([toPresentableCommand(command)], normalized);

const outputSpy = vi.mocked(console.log);
outputSpy.mockClear();
Expand All @@ -394,7 +396,7 @@ name: 'search',

outputSpy.mockClear();
renderOutput(presentation.rows, {
fmt: format,
fmt: normalized,
columns: presentation.columns,
title: 'webcmd/list',
source: 'webcmd list',
Expand Down Expand Up @@ -3702,3 +3704,165 @@ describe('renderVerifyPreview', () => {
expect(out).not.toContain('xxxxxxxxxxx'); // never 11 consecutive
});
});

describe('output format normalization across builtin command families', () => {
let stdoutSpy: ReturnType<typeof vi.spyOn>;
let stderrSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
process.exitCode = undefined;
stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
process.exitCode = undefined;
stdoutSpy.mockClear();
stderrSpy.mockClear();
});

async function run(...args: string[]) {
stdoutSpy.mockClear();
stderrSpy.mockClear();
await createProgram('', '').parseAsync(['node', 'webcmd', ...args]);
return {
stdout: stdoutSpy.mock.calls.flat().join('\n'),
stderr: stderrSpy.mock.calls.flat().join('\n'),
exitCode: process.exitCode,
};
}

it('skills list renders YAML implicitly on non-TTY and stays a table with explicit -f table', async () => {
const implicit = await run('skills', 'list');
expect(implicit.exitCode).toBeUndefined();
const rows = yaml.load(implicit.stdout) as Array<Record<string, unknown>>;
expect(Array.isArray(rows)).toBe(true);
expect(rows.length).toBeGreaterThan(0);
expect(rows[0]).toHaveProperty('name');

const explicit = await run('skills', 'list', '-f', 'table');
expect(explicit.exitCode).toBeUndefined();
expect(explicit.stdout).toContain('webcmd/skills/list');
expect(explicit.stdout).toContain('items | webcmd skills list');
});

it('skills list -f json emits a JSON array', async () => {
const { stdout, exitCode } = await run('skills', 'list', '-f', 'json');
expect(exitCode).toBeUndefined();
const rows = JSON.parse(stdout);
expect(Array.isArray(rows)).toBe(true);
expect(rows[0]).toHaveProperty('name');
});

it.each(['xml', 'jsonl'])('skills list rejects the unsupported %s format with a usage error', async (format) => {
const { stderr, exitCode } = await run('skills', 'list', '-f', format);
expect(exitCode).toBe(2);
expect(stderr).toContain(`Unknown output format "${format}"`);
expect(stderr).toContain('Supported formats: table, plain, json, yaml, md, csv');
});

it('plugin list -f json emits valid JSON and -f yaml emits valid YAML', async () => {
const json = await run('plugin', 'list', '-f', 'json');
expect(json.exitCode).toBeUndefined();
expect(Array.isArray(JSON.parse(json.stdout))).toBe(true);

const yamlOut = await run('plugin', 'list', '-f', 'yaml');
expect(yamlOut.exitCode).toBeUndefined();
expect(Array.isArray(yaml.load(yamlOut.stdout))).toBe(true);
});

it('plugin list rejects an unsupported format with a usage error', async () => {
const { stderr, exitCode } = await run('plugin', 'list', '-f', 'xml');
expect(exitCode).toBe(2);
expect(stderr).toContain('Unknown output format "xml"');
});

it('plugin search rejects an unsupported format before any network call', async () => {
const { stderr, exitCode } = await run('plugin', 'search', '-f', 'xml');
expect(exitCode).toBe(2);
expect(stderr).toContain('Unknown output format "xml"');
});

it('plugin catalog list -f json emits the catalog object and -f yaml the sources', async () => {
const json = await run('plugin', 'catalog', 'list', '-f', 'json');
expect(json.exitCode).toBeUndefined();
const catalog = JSON.parse(json.stdout);
expect(Array.isArray(catalog.sources)).toBe(true);

const yamlOut = await run('plugin', 'catalog', 'list', '-f', 'yaml');
expect(yamlOut.exitCode).toBeUndefined();
const sources = yaml.load(yamlOut.stdout) as Array<Record<string, unknown>>;
expect(Array.isArray(sources)).toBe(true);
expect(sources[0]).toHaveProperty('id');
});

it('external list renders YAML implicitly on non-TTY and stays a table with explicit -f table', async () => {
const implicit = await run('external', 'list');
expect(implicit.exitCode).toBeUndefined();
const rows = yaml.load(implicit.stdout) as Array<Record<string, unknown>>;
expect(Array.isArray(rows)).toBe(true);
expect(rows.length).toBeGreaterThan(0);
expect(rows[0]).toHaveProperty('name');

const explicit = await run('external', 'list', '-f', 'table');
expect(explicit.exitCode).toBeUndefined();
expect(explicit.stdout).toContain('items | webcmd external list');
});

it('convention-audit renders structured JSON through the shared renderer', async () => {
const { stdout, exitCode } = await run('convention-audit', '-f', 'json');
expect(exitCode).toBeUndefined();
const report = JSON.parse(stdout);
expect(report).toHaveProperty('ok');
expect(report).toHaveProperty('summary');
});

it('convention-audit rejects an unsupported format with a usage error', async () => {
const { stderr, exitCode } = await run('convention-audit', '-f', 'xml');
expect(exitCode).toBe(2);
expect(stderr).toContain('Unknown output format "xml"');
});

it.each(['status', 'refresh'] as const)('auth %s validates the output format and renders JSON on -f json', async (subcommand) => {
const unsupported = await run('auth', subcommand, '-f', 'xml');
expect(unsupported.exitCode).toBe(2);
expect(unsupported.stderr).toContain('Unknown output format "xml"');

process.exitCode = undefined;
const json = await run('auth', subcommand, '-f', 'json');
expect(json.exitCode).toBeUndefined();
expect(Array.isArray(JSON.parse(json.stdout))).toBe(true);
});

it('webcmd list rejects an unsupported format with a usage error', async () => {
const { stderr, exitCode } = await run('list', '-f', 'xml');
expect(exitCode).toBe(2);
expect(stderr).toContain('Unknown output format "xml"');
});

it('webcmd list and convention-audit accept case-insensitive and aliased formats', async () => {
const yamlList = await run('list', '-f', 'yaml');
const upperList = await run('list', '-f', 'YAML');
expect(upperList.exitCode).toBeUndefined();
expect(upperList.stdout).toBe(yamlList.stdout);

const audit = await run('convention-audit', '-f', 'YAML');
expect(audit.exitCode).toBeUndefined();
expect(yaml.load(audit.stdout)).toMatchObject({ summary: expect.anything() });
});

it('advertises the canonical format list across builtin and shared surfaces', () => {
const program = createProgram('', '');
const list = program.commands.find(cmd => cmd.name() === 'list')!;
const plugin = program.commands.find(cmd => cmd.name() === 'plugin')!;
const search = plugin.commands.find(cmd => cmd.name() === 'search')!;
const skillsList = program.commands.find(cmd => cmd.name() === 'skills')!.commands.find(cmd => cmd.name() === 'list')!;

for (const cmd of [list, search, skillsList]) {
const help = cmd.helpInformation();
expect(help).toContain('Output format: table, plain, json, yaml, md, csv');
expect(help).not.toContain('markdown');
expect(help).not.toContain('yml');
}
});
});
Loading