Compare commits

...

9 Commits

Author SHA1 Message Date
jackwener 94b03f8654 fix(output): restore text format paths 2026-04-19 22:59:02 +08:00
jackwener 5816d7d672 fix(output): keep generate summary path 2026-04-19 22:54:01 +08:00
jackwener 10a909fd2d fix(output): align generate and e2e formats 2026-04-19 22:51:19 +08:00
jackwener 5378863a22 refactor(output): remove rich terminal render path 2026-04-19 22:41:33 +08:00
jackwener cd00a46688 fix(output): preserve command default formats 2026-04-19 22:32:21 +08:00
jackwener 7f82b6f800 docs: align yaml-default scope 2026-04-19 22:28:01 +08:00
jackwener 1490579e7f feat(output): default to yaml output 2026-04-19 22:23:47 +08:00
jackwener 454b94d340 Revert "feat(output): make detail layout explicit"
This reverts commit 5fc77cca08.
2026-04-19 22:19:53 +08:00
jackwener 5fc77cca08 feat(output): make detail layout explicit 2026-04-19 22:11:07 +08:00
9 changed files with 130 additions and 228 deletions
+2 -1
View File
@@ -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
```
+32
View File
@@ -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('', '');
+7 -118
View File
@@ -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';
@@ -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 ───────────────────────────────────────────
@@ -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
+25 -2
View File
@@ -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);
@@ -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);
@@ -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', () => {
+4 -14
View File
@@ -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));
@@ -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) {
@@ -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);
+31 -11
View File
@@ -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', () => {
@@ -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');
// Should be table output, not YAML
expect(out).not.toContain('name: alice');
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');
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');
});
});
+26 -67
View File
@@ -1,20 +1,12 @@
/**
* Output formatting: table, JSON, Markdown, CSV, YAML.
* Output formatting with structured defaults and text-only opt-in formats.
*/
import { styleText } from 'node:util';
import Table from 'cli-table3';
import yaml from 'js-yaml';
export interface RenderOptions {
fmt?: string;
/** True when the user explicitly passed -f on the command line */
fmtExplicit?: boolean;
columns?: string[];
title?: string;
elapsed?: number;
source?: string;
footerExtra?: string;
}
function normalizeRows(data: unknown): Record<string, unknown>[] {
@@ -28,64 +20,34 @@ function resolveColumns(rows: Record<string, unknown>[], opts: RenderOptions): s
}
export function render(data: unknown, opts: RenderOptions = {}): void {
let fmt = opts.fmt ?? 'table';
// Non-TTY auto-downgrade only when format was NOT explicitly passed by user.
if (!opts.fmtExplicit) {
if (fmt === 'table' && !process.stdout.isTTY) fmt = 'yaml';
}
const fmt = opts.fmt ?? 'yaml';
if (data === null || data === undefined) {
console.log(data);
return;
}
switch (fmt) {
case 'json': renderJson(data); break;
case 'plain': renderPlain(data, opts); break;
case 'md': case 'markdown': renderMarkdown(data, opts); break;
case 'plain': renderPlain(data); break;
case 'md':
case 'markdown': renderMarkdown(data, opts); break;
case 'csv': renderCsv(data, opts); break;
case 'yaml': case 'yml': renderYaml(data); break;
default: renderTable(data, opts); break;
case 'yaml':
case 'yml':
case 'table':
default:
renderYaml(data);
break;
}
}
function renderTable(data: unknown, opts: RenderOptions): void {
const rows = normalizeRows(data);
if (!rows.length) { console.log(styleText('dim', '(no data)')); return; }
const columns = resolveColumns(rows, opts);
const header = columns.map(c => capitalize(c));
const table = new Table({
head: header.map(h => styleText('bold', h)),
style: { head: [], border: [] },
wordWrap: true,
wrapOnWordBoundary: true,
});
for (const row of rows) {
table.push(columns.map(c => {
const v = (row as Record<string, unknown>)[c];
return v === null || v === undefined ? '' : String(v);
}));
}
console.log();
if (opts.title) console.log(styleText('dim', ` ${opts.title}`));
console.log(table.toString());
const footer: string[] = [];
footer.push(`${rows.length} items`);
if (opts.elapsed) footer.push(`${opts.elapsed.toFixed(1)}s`);
if (opts.source) footer.push(opts.source);
if (opts.footerExtra) footer.push(opts.footerExtra);
console.log(styleText('dim', footer.join(' · ')));
}
function renderJson(data: unknown): void {
console.log(JSON.stringify(data, null, 2));
}
function renderPlain(data: unknown, opts: RenderOptions): void {
function renderPlain(data: unknown): void {
const rows = normalizeRows(data);
if (!rows.length) return;
// Single-row single-field shortcuts for chat-style commands.
if (rows.length === 1) {
const row = rows[0];
const entries = Object.entries(row);
@@ -99,23 +61,23 @@ function renderPlain(data: unknown, opts: RenderOptions): void {
}
rows.forEach((row, index) => {
const entries = Object.entries(row).filter(([, value]) => value !== undefined && value !== null && String(value) !== '');
entries.forEach(([key, value]) => {
console.log(`${key}: ${value}`);
});
Object.entries(row)
.filter(([, value]) => value !== undefined && value !== null && String(value) !== '')
.forEach(([key, value]) => {
console.log(`${key}: ${value}`);
});
if (index < rows.length - 1) console.log('');
});
}
function renderMarkdown(data: unknown, opts: RenderOptions): void {
const rows = normalizeRows(data);
if (!rows.length) return;
const columns = resolveColumns(rows, opts);
console.log('| ' + columns.join(' | ') + ' |');
console.log('| ' + columns.map(() => '---').join(' | ') + ' |');
console.log(`| ${columns.join(' | ')} |`);
console.log(`| ${columns.map(() => '---').join(' | ')} |`);
for (const row of rows) {
console.log('| ' + columns.map(c => String((row as Record<string, unknown>)[c] ?? '')).join(' | ') + ' |');
console.log(`| ${columns.map((column) => String(row[column] ?? '')).join(' | ')} |`);
}
}
@@ -125,10 +87,11 @@ function renderCsv(data: unknown, opts: RenderOptions): void {
const columns = resolveColumns(rows, opts);
console.log(columns.join(','));
for (const row of rows) {
console.log(columns.map(c => {
const v = String((row as Record<string, unknown>)[c] ?? '');
return v.includes(',') || v.includes('"') || v.includes('\n') || v.includes('\r')
? `"${v.replace(/"/g, '""')}"` : v;
console.log(columns.map((column) => {
const value = String(row[column] ?? '');
return value.includes(',') || value.includes('"') || value.includes('\n') || value.includes('\r')
? `"${value.replace(/"/g, '""')}"`
: value;
}).join(','));
}
}
@@ -136,7 +99,3 @@ function renderCsv(data: unknown, opts: RenderOptions): void {
function renderYaml(data: unknown): void {
console.log(yaml.dump(data, { sortKeys: false, lineWidth: 120, noRefs: true }));
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
+3 -13
View File
@@ -24,21 +24,12 @@ describe('management commands E2E', () => {
expect(data[0]).toHaveProperty('browser');
});
it('list default table format renders sites', async () => {
it('list defaults to yaml', async () => {
const { stdout, code } = await runCli(['list']);
expect(code).toBe(0);
// Should contain site names
expect(stdout).toContain('hackernews');
expect(stdout).toContain('bilibili');
expect(stdout).toContain('twitter');
expect(stdout).toContain('commands across');
});
it('list -f yaml produces valid yaml', async () => {
const { stdout, code } = await runCli(['list', '-f', 'yaml']);
expect(code).toBe(0);
expect(stdout).toContain('command:');
expect(stdout).toContain('site:');
expect(stdout).toContain('hackernews');
});
it('list -f csv produces valid csv', async () => {
@@ -51,8 +42,7 @@ describe('management commands E2E', () => {
it('list -f md produces markdown table', async () => {
const { stdout, code } = await runCli(['list', '-f', 'md']);
expect(code).toBe(0);
expect(stdout).toContain('|');
expect(stdout).toContain('command');
expect(stdout).toContain('| command |');
});
// ── validate ──
-2
View File
@@ -30,13 +30,11 @@ describe('output formats E2E', () => {
}
if (fmt === 'csv') {
// CSV should have a header row + data rows
const lines = stdout.trim().split('\n');
expect(lines.length).toBeGreaterThanOrEqual(2);
}
if (fmt === 'md') {
// Markdown table should have pipe characters
expect(stdout).toContain('| command |');
}
}, 30_000);