Skip to content
Closed
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,10 +307,11 @@ opencli xiaoyuzhou transcript 69dd0c98e2c8be31551f6a33 --output ./xiaoyuzhou-tra

## Output Formats

All built-in commands support `--format` / `-f` with `table` (default), `json`, `yaml`, `md`, and `csv`.
Registry-backed commands, `opencli list`, and `opencli plugin list` support `--format` / `-f` with `yaml` (default), `json`, `plain`, `md`, and `csv`. Terminal rich layout is removed; text formats stay available.

```bash
opencli bilibili hot -f json # Pipe to jq or LLMs
opencli bilibili hot -f plain # Plain text response
opencli bilibili hot -f csv # Spreadsheet-friendly
opencli bilibili hot -v # Verbose: show pipeline debug steps
```
Expand Down
32 changes: 32 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,38 @@ describe('built-in browser commands verbose wiring', () => {
);
});

it('renders generate output as summary by default', async () => {
const program = createProgram('', '');

mockGenerateVerifiedFromUrl.mockResolvedValueOnce({
status: 'success',
adapter: { command: 'demo/top' },
stats: { endpoint_count: 1, api_endpoint_count: 1, candidate_count: 1, verified: true, repair_attempted: false, explore_dir: '/tmp/demo' },
});

await program.parseAsync(['node', 'opencli', 'generate', 'https://example.com']);

expect(mockRenderGenerateVerifiedSummary).toHaveBeenCalled();
expect(consoleLogSpy).toHaveBeenCalledWith('generate-summary');
});

it('renders generate output as json when requested', async () => {
const program = createProgram('', '');

mockGenerateVerifiedFromUrl.mockResolvedValueOnce({
status: 'blocked',
reason: 'no-viable-api-surface',
stage: 'explore',
confidence: 'high',
stats: { endpoint_count: 0, api_endpoint_count: 0, candidate_count: 0, verified: false, repair_attempted: false, explore_dir: '/tmp/demo' },
});

await program.parseAsync(['node', 'opencli', 'generate', 'https://example.com', '--format', 'json']);

expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('"status": "blocked"'));
expect(mockRenderGenerateVerifiedSummary).not.toHaveBeenCalled();
});

it('enables OPENCLI_VERBOSE for record via the real CLI command', async () => {
const program = createProgram('', '');

Expand Down
125 changes: 7 additions & 118 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import { fileURLToPath } from 'node:url';
import { Command } from 'commander';
import { styleText } from 'node:util';
import { findPackageRoot, getBuiltEntryCandidates } from './package-paths.js';
import { type CliCommand, fullName, getRegistry, strategyLabel } from './registry.js';
import { serializeCommand, formatArgSummary } from './serialization.js';
import { fullName, getRegistry } from './registry.js';
import { serializeCommand } from './serialization.js';
import { render as renderOutput } from './output.js';
import { getBrowserFactory, browserSession } from './runtime.js';
import { PKG_VERSION } from './version.js';
Expand Down Expand Up @@ -199,74 +199,13 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command
program
.command('list')
.description('List all available CLI commands')
.option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table')
.option('-f, --format <fmt>', 'Output format: yaml, json, plain, md, csv', 'yaml')
.option('--json', 'JSON output (deprecated)')
.action((opts) => {
const registry = getRegistry();
const commands = [...new Set(registry.values())].sort((a, b) => fullName(a).localeCompare(fullName(b)));
const fmt = opts.json && opts.format === 'table' ? 'json' : opts.format;
const isStructured = fmt === 'json' || fmt === 'yaml';

if (fmt !== 'table') {
const rows = isStructured
? commands.map(serializeCommand)
: commands.map(c => ({
command: fullName(c),
site: c.site,
name: c.name,
aliases: c.aliases?.join(', ') ?? '',
description: c.description,
strategy: strategyLabel(c),
browser: !!c.browser,
args: formatArgSummary(c.args),
}));
renderOutput(rows, {
fmt,
columns: ['command', 'site', 'name', 'aliases', 'description', 'strategy', 'browser', 'args',
...(isStructured ? ['columns', 'domain'] : [])],
title: 'opencli/list',
source: 'opencli list',
});
return;
}

// Table (default) — grouped by site
const sites = new Map<string, CliCommand[]>();
for (const cmd of commands) {
const g = sites.get(cmd.site) ?? [];
g.push(cmd);
sites.set(cmd.site, g);
}

console.log();
console.log(styleText('bold', ' opencli') + styleText('dim', ' — available commands'));
console.log();
for (const [site, cmds] of sites) {
console.log(styleText(['bold', 'cyan'], ` ${site}`));
for (const cmd of cmds) {
const label = strategyLabel(cmd);
const tag = label === 'public'
? styleText('green', '[public]')
: styleText('yellow', `[${label}]`);
const aliases = cmd.aliases?.length ? styleText('dim', ` (aliases: ${cmd.aliases.join(', ')})`) : '';
console.log(` ${cmd.name} ${tag}${aliases}${cmd.description ? styleText('dim', ` — ${cmd.description}`) : ''}`);
}
console.log();
}

const externalClis = loadExternalClis();
if (externalClis.length > 0) {
console.log(styleText(['bold', 'cyan'], ' external CLIs'));
for (const ext of externalClis) {
const isInstalled = isBinaryInstalled(ext.binary);
const tag = isInstalled ? styleText('green', '[installed]') : styleText('yellow', '[auto-install]');
console.log(` ${ext.name} ${tag}${ext.description ? styleText('dim', ` — ${ext.description}`) : ''}`);
}
console.log();
}

console.log(styleText('dim', ` ${commands.length} built-in commands across ${sites.size} sites, ${externalClis.length} external CLIs`));
console.log();
const fmt = opts.json ? 'json' : opts.format;
renderOutput(commands.map(serializeCommand), { fmt });
});

// ── Built-in: validate / verify ───────────────────────────────────────────
Expand Down Expand Up @@ -1094,60 +1033,10 @@ cli({
pluginCmd
.command('list')
.description('List installed plugins')
.option('-f, --format <fmt>', 'Output format: table, json', 'table')
.option('-f, --format <fmt>', 'Output format: yaml, json, plain, md, csv', 'yaml')
.action(async (opts) => {
const { listPlugins } = await import('./plugin.js');
const plugins = listPlugins();
if (plugins.length === 0) {
console.log(styleText('dim', ' No plugins installed.'));
console.log(styleText('dim', ' Install one with: opencli plugin install github:user/repo'));
return;
}
if (opts.format === 'json') {
renderOutput(plugins, {
fmt: 'json',
columns: ['name', 'commands', 'source'],
title: 'opencli/plugins',
source: 'opencli plugin list',
});
return;
}
console.log();
console.log(styleText('bold', ' Installed plugins'));
console.log();

// Group by monorepo
const standalone = plugins.filter((p) => !p.monorepoName);
const monoGroups = new Map<string, typeof plugins>();
for (const p of plugins) {
if (!p.monorepoName) continue;
const g = monoGroups.get(p.monorepoName) ?? [];
g.push(p);
monoGroups.set(p.monorepoName, g);
}

for (const p of standalone) {
const version = p.version ? styleText('green', ` @${p.version}`) : '';
const desc = p.description ? styleText('dim', ` — ${p.description}`) : '';
const cmds = p.commands.length > 0 ? styleText('dim', ` (${p.commands.join(', ')})`) : '';
const src = p.source ? styleText('dim', ` ← ${p.source}`) : '';
console.log(` ${styleText('cyan', p.name)}${version}${desc}${cmds}${src}`);
}

for (const [mono, group] of monoGroups) {
console.log();
console.log(styleText(['bold', 'magenta'], ` 📦 ${mono}`) + styleText('dim', ' (monorepo)'));
for (const p of group) {
const version = p.version ? styleText('green', ` @${p.version}`) : '';
const desc = p.description ? styleText('dim', ` — ${p.description}`) : '';
const cmds = p.commands.length > 0 ? styleText('dim', ` (${p.commands.join(', ')})`) : '';
console.log(` ${styleText('cyan', p.name)}${version}${desc}${cmds}`);
}
}

console.log();
console.log(styleText('dim', ` ${plugins.length} plugin(s) installed`));
console.log();
renderOutput(listPlugins(), { fmt: opts.format });
});

pluginCmd
Expand Down
27 changes: 25 additions & 2 deletions src/commanderAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ describe('commanderAdapter default formats', () => {
process.exitCode = undefined;
});

it('uses the command defaultFormat when the user keeps the default table format', async () => {
it('preserves a command defaultFormat when the user does not override it', async () => {
const program = new Command();
const siteCmd = program.command('gemini');
registerCommandToProgram(siteCmd, cmd);
Expand All @@ -279,7 +279,7 @@ describe('commanderAdapter default formats', () => {
);
});

it('respects an explicit user format over the command defaultFormat', async () => {
it('still respects an explicit user format override', async () => {
const program = new Command();
const siteCmd = program.command('gemini');
registerCommandToProgram(siteCmd, cmd);
Expand All @@ -291,6 +291,29 @@ describe('commanderAdapter default formats', () => {
expect.objectContaining({ fmt: 'json' }),
);
});

it('defaults to yaml for commands without an explicit defaultFormat', async () => {
const yamlDefaultCmd: CliCommand = {
site: 'demo',
name: 'structured',
description: 'Structured command',
browser: false,
args: [],
columns: ['name'],
func: vi.fn(),
};
const program = new Command();
const siteCmd = program.command('demo');
registerCommandToProgram(siteCmd, yamlDefaultCmd);

mockExecuteCommand.mockResolvedValueOnce([{ name: 'alice' }]);
await program.parseAsync(['node', 'opencli', 'demo', 'structured']);

expect(mockRenderOutput).toHaveBeenCalledWith(
[{ name: 'alice' }],
expect.objectContaining({ fmt: 'yaml' }),
);
});
});

describe('commanderAdapter error envelope output', () => {
Expand Down
18 changes: 4 additions & 14 deletions src/commanderAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
}
}
subCmd
.option('-f, --format <fmt>', 'Output format: table, plain, json, yaml, md, csv', 'table')
.option('-f, --format <fmt>', 'Output format: yaml, json, plain, md, csv', 'yaml')
.option('-v, --verbose', 'Debug output', false);

subCmd.addHelpText('after', formatRegistryHelpText(cmd));
Expand All @@ -77,7 +77,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
const kwargs = prepareCommandArgs(cmd, rawKwargs);

const verbose = optionsRecord.verbose === true;
let format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'table';
let format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'yaml';
const formatExplicit = subCmd.getOptionValueSource('format') === 'cli';
if (verbose) process.env.OPENCLI_VERBOSE = '1';
if (cmd.deprecated) {
Expand All @@ -90,24 +90,14 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
if (result === null || result === undefined) {
return;
}

const resolved = getRegistry().get(fullName(cmd)) ?? cmd;
if (!formatExplicit && format === 'table' && resolved.defaultFormat) {
if (!formatExplicit && resolved.defaultFormat) {
format = resolved.defaultFormat;
}

if (verbose && (!result || (Array.isArray(result) && result.length === 0))) {
log.warn('Command returned an empty result.');
}
renderOutput(result, {
fmt: format,
fmtExplicit: formatExplicit,
columns: resolved.columns,
title: `${resolved.site}/${resolved.name}`,
elapsed: (Date.now() - startTime) / 1000,
source: fullName(resolved),
footerExtra: resolved.footerExtra?.(kwargs),
});
renderOutput(result, { fmt: format, columns: resolved.columns });
} catch (err) {
renderError(err, fullName(cmd), optionsRecord.verbose === true);
process.exitCode = resolveExitCode(err);
Expand Down
42 changes: 31 additions & 11 deletions src/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,20 @@ describe('output TTY detection', () => {
logSpy.mockRestore();
});

it('outputs YAML in non-TTY when format is default table', () => {
it('defaults to YAML in non-TTY', () => {
Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true });
// commanderAdapter always passes fmt:'table' as default — this must still trigger downgrade
render([{ name: 'alice', score: 10 }], { fmt: 'table', columns: ['name', 'score'] });
render([{ name: 'alice', score: 10 }]);
const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n');
expect(out).toContain('name: alice');
expect(out).toContain('score: 10');
});

it('outputs table in TTY when format is default table', () => {
it('defaults to YAML in TTY too', () => {
Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true });
render([{ name: 'alice', score: 10 }], { fmt: 'table', columns: ['name', 'score'] });
render([{ name: 'alice', score: 10 }]);
const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n');
expect(out).toContain('alice');
expect(out).toContain('name: alice');
expect(out).toContain('score: 10');
});

it('respects explicit -f json even in non-TTY', () => {
Expand All @@ -37,12 +37,32 @@ describe('output TTY detection', () => {
expect(JSON.parse(out)).toEqual([{ name: 'alice' }]);
});

it('explicit -f table overrides non-TTY auto-downgrade', () => {
Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true });
render([{ name: 'alice' }], { fmt: 'table', fmtExplicit: true, columns: ['name'] });
it('renders plain output for chat-style single-field rows', () => {
Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true });
render([{ response: 'hello' }], { fmt: 'plain' });
const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n');
expect(out).toContain('hello');
});

it('renders markdown tables as plain text', () => {
Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true });
render([{ name: 'alice' }], { fmt: 'md' });
const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n');
// Should be table output, not YAML
expect(out).not.toContain('name: alice');
expect(out).toContain('| name |');
});

it('renders csv as plain text', () => {
Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true });
render([{ name: 'alice' }], { fmt: 'csv' });
const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n');
expect(out).toContain('name');
expect(out).toContain('alice');
});

it('falls back to YAML for removed table format', () => {
Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true });
render([{ name: 'alice' }], { fmt: 'table' });
const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n');
expect(out).toContain('name: alice');
});
});
Loading
Loading