diff --git a/README.md b/README.md index 1e01d91d..270e2eb3 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/src/cli.test.ts b/src/cli.test.ts index e6086f06..7fea4ccb 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -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('', ''); diff --git a/src/cli.ts b/src/cli.ts index b1dbc78e..454e1d63 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 ', 'Output format: table, json, yaml, md, csv', 'table') + .option('-f, --format ', '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(); - 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 ', 'Output format: table, json', 'table') + .option('-f, --format ', '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(); - 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 diff --git a/src/commanderAdapter.test.ts b/src/commanderAdapter.test.ts index ee04a6d7..4dc44b70 100644 --- a/src/commanderAdapter.test.ts +++ b/src/commanderAdapter.test.ts @@ -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', () => { diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index 6cf7484b..34005747 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -50,7 +50,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi } } subCmd - .option('-f, --format ', 'Output format: table, plain, json, yaml, md, csv', 'table') + .option('-f, --format ', '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); diff --git a/src/output.test.ts b/src/output.test.ts index 3f4be0c3..063fe4dd 100644 --- a/src/output.test.ts +++ b/src/output.test.ts @@ -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'); + 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'); + }); }); diff --git a/src/output.ts b/src/output.ts index 746f8796..4e2d54ad 100644 --- a/src/output.ts +++ b/src/output.ts @@ -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[] { @@ -28,64 +20,34 @@ function resolveColumns(rows: Record[], 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)[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)[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)[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); -} diff --git a/tests/e2e/management.test.ts b/tests/e2e/management.test.ts index 8a633340..8ceec355 100644 --- a/tests/e2e/management.test.ts +++ b/tests/e2e/management.test.ts @@ -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 ── diff --git a/tests/e2e/output-formats.test.ts b/tests/e2e/output-formats.test.ts index 638863dd..1c6ac672 100644 --- a/tests/e2e/output-formats.test.ts +++ b/tests/e2e/output-formats.test.ts @@ -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);