From 5fc77cca08e12f3595079ba31ee7114cf7d02039 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 19 Apr 2026 22:11:07 +0800 Subject: [PATCH 1/9] feat(output): make detail layout explicit --- clis/bilibili/me.js | 1 + clis/bluesky/profile.js | 1 + clis/twitter/profile.js | 1 + clis/v2ex/member.js | 1 + src/commanderAdapter.test.ts | 3 ++- src/commanderAdapter.ts | 1 + src/output.test.ts | 24 ++++++++++++++++++ src/output.ts | 49 +++++++++++++++++++++++++++++++----- src/registry.test.ts | 12 +++++++++ src/registry.ts | 11 ++++++++ src/serialization.ts | 2 ++ 11 files changed, 99 insertions(+), 7 deletions(-) diff --git a/clis/bilibili/me.js b/clis/bilibili/me.js index e0131227c..012fc183a 100644 --- a/clis/bilibili/me.js +++ b/clis/bilibili/me.js @@ -4,6 +4,7 @@ cli({ site: 'bilibili', name: 'me', description: 'My Bilibili profile info', domain: 'www.bilibili.com', strategy: Strategy.COOKIE, args: [], columns: ['name', 'uid', 'level', 'coins', 'followers', 'following'], + presentation: 'detail', func: async (page) => { const uid = await getSelfUid(page); const payload = await apiGet(page, '/x/space/wbi/acc/info', { params: { mid: uid }, signed: true }); diff --git a/clis/bluesky/profile.js b/clis/bluesky/profile.js index a50ae6302..e06f688ee 100644 --- a/clis/bluesky/profile.js +++ b/clis/bluesky/profile.js @@ -15,6 +15,7 @@ cli({ }, ], columns: ['handle', 'name', 'followers', 'following', 'posts', 'description'], + presentation: 'detail', pipeline: [ { fetch: { url: 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${{ args.handle }}' } }, { map: { diff --git a/clis/twitter/profile.js b/clis/twitter/profile.js index c32e5b56a..ab727f749 100644 --- a/clis/twitter/profile.js +++ b/clis/twitter/profile.js @@ -13,6 +13,7 @@ cli({ { name: 'username', type: 'string', positional: true, help: 'Twitter screen name (without @). Defaults to logged-in user.' }, ], columns: ['screen_name', 'name', 'bio', 'location', 'url', 'followers', 'following', 'tweets', 'likes', 'verified', 'created_at'], + presentation: 'detail', func: async (page, kwargs) => { let username = (kwargs.username || '').replace(/^@/, ''); // If no username, detect the logged-in user diff --git a/clis/v2ex/member.js b/clis/v2ex/member.js index df4be79dd..c2706d5da 100644 --- a/clis/v2ex/member.js +++ b/clis/v2ex/member.js @@ -10,6 +10,7 @@ cli({ { name: 'username', required: true, positional: true, help: 'Username' }, ], columns: ['username', 'tagline', 'website', 'github', 'twitter', 'location'], + presentation: 'detail', pipeline: [ { fetch: { url: 'https://www.v2ex.com/api/members/show.json', diff --git a/src/commanderAdapter.test.ts b/src/commanderAdapter.test.ts index ee04a6d76..d30381c72 100644 --- a/src/commanderAdapter.test.ts +++ b/src/commanderAdapter.test.ts @@ -255,6 +255,7 @@ describe('commanderAdapter default formats', () => { args: [], columns: ['response'], defaultFormat: 'plain', + presentation: 'detail', func: vi.fn(), }; @@ -275,7 +276,7 @@ describe('commanderAdapter default formats', () => { expect(mockRenderOutput).toHaveBeenCalledWith( [{ response: 'hello' }], - expect.objectContaining({ fmt: 'plain' }), + expect.objectContaining({ fmt: 'plain', presentation: 'detail' }), ); }); diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index 6cf7484b6..73a7660bd 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -103,6 +103,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi fmt: format, fmtExplicit: formatExplicit, columns: resolved.columns, + presentation: resolved.presentation, title: `${resolved.site}/${resolved.name}`, elapsed: (Date.now() - startTime) / 1000, source: fullName(resolved), diff --git a/src/output.test.ts b/src/output.test.ts index 3f4be0c35..c79051a5a 100644 --- a/src/output.test.ts +++ b/src/output.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { render } from './output.js'; +function stripAnsi(text: string): string { + return text.replace(/\u001B\[[0-9;]*m/g, ''); +} + describe('output TTY detection', () => { const originalIsTTY = process.stdout.isTTY; let logSpy: ReturnType; @@ -45,4 +49,24 @@ describe('output TTY detection', () => { expect(out).not.toContain('name: alice'); expect(out).toContain('alice'); }); + + it('keeps single-row table output as a table by default', () => { + Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); + render([{ name: 'alice', score: 10 }], { fmt: 'table', columns: ['name', 'score'] }); + const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); + expect(out).toContain('Name'); + expect(out).toContain('Score'); + expect(out).toContain('1 item'); + }); + + it('renders detail presentation as key/value output when explicitly requested', () => { + Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); + render({ name: 'alice', score: 10 }, { fmt: 'table', columns: ['name', 'score'], presentation: 'detail' }); + const out = stripAnsi(logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n')); + expect(out).toContain(' Name'); + expect(out).toContain('alice'); + expect(out).toContain(' Score'); + expect(out).toContain('10'); + expect(out).toContain('1 item'); + }); }); diff --git a/src/output.ts b/src/output.ts index 746f87960..7e148ee1f 100644 --- a/src/output.ts +++ b/src/output.ts @@ -11,6 +11,7 @@ export interface RenderOptions { /** True when the user explicitly passed -f on the command line */ fmtExplicit?: boolean; columns?: string[]; + presentation?: 'list' | 'detail'; title?: string; elapsed?: number; source?: string; @@ -51,6 +52,10 @@ 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); + if (opts.presentation === 'detail' && rows.length === 1) { + renderDetail(rows[0], columns, opts); + return; + } const header = columns.map(c => capitalize(c)); const table = new Table({ @@ -70,12 +75,7 @@ function renderTable(data: unknown, opts: RenderOptions): void { 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(' · '))); + console.log(styleText('dim', formatFooter(rows.length, opts))); } function renderJson(data: unknown): void { @@ -137,6 +137,43 @@ function renderYaml(data: unknown): void { console.log(yaml.dump(data, { sortKeys: false, lineWidth: 120, noRefs: true })); } +function renderDetail(row: Record, columns: string[], opts: RenderOptions): void { + const entries = columns + .map((column) => [column, row[column]] as const) + .filter(([, value]) => value !== undefined && value !== null && String(value) !== ''); + + if (!entries.length) { + console.log(styleText('dim', '(no data)')); + return; + } + + const labels = entries.map(([column]) => capitalize(column)); + const keyWidth = Math.max(...labels.map((label) => label.length)); + + console.log(); + if (opts.title) console.log(styleText('dim', ` ${opts.title}`)); + entries.forEach(([column, value], index) => { + const label = capitalize(column).padEnd(keyWidth, ' '); + const rendered = String(value); + const lines = rendered.split('\n'); + console.log(` ${styleText('bold', label)} ${lines[0]}`); + for (let i = 1; i < lines.length; i++) { + console.log(` ${' '.repeat(keyWidth)} ${lines[i]}`); + } + if (index < entries.length - 1 && lines.length > 1) console.log(); + }); + console.log(styleText('dim', formatFooter(1, opts))); +} + +function formatFooter(count: number, opts: RenderOptions): string { + const footer: string[] = []; + footer.push(count === 1 ? '1 item' : `${count} 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); + return footer.join(' · '); +} + function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } diff --git a/src/registry.test.ts b/src/registry.test.ts index c7eb29f50..3dcb13fe8 100644 --- a/src/registry.test.ts +++ b/src/registry.test.ts @@ -100,6 +100,18 @@ describe('cli() registration', () => { expect(cmd.defaultFormat).toBe('plain'); expect(getRegistry().get('test-registry/plain-default')?.defaultFormat).toBe('plain'); }); + + it('preserves explicit presentation metadata on the registered command', () => { + const cmd = cli({ + site: 'test-registry', + name: 'detail-view', + description: 'prefers key/value detail output', + presentation: 'detail', + }); + + expect(cmd.presentation).toBe('detail'); + expect(getRegistry().get('test-registry/detail-view')?.presentation).toBe('detail'); + }); }); describe('fullName', () => { diff --git a/src/registry.ts b/src/registry.ts index 5136af29c..05edfa9d8 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -71,6 +71,16 @@ export interface CliCommand { navigateBefore?: boolean | string; /** Override the default CLI output format when the user does not pass -f/--format. */ defaultFormat?: 'table' | 'plain' | 'json' | 'yaml' | 'yml' | 'md' | 'markdown' | 'csv'; + /** + * Presentation policy for default human-readable output. + * + * - `list`: row-oriented data rendered as a table + * - `detail`: a single entity rendered as key/value pairs + * + * This is intentionally explicit command metadata. The renderer should not + * guess "detail" from heuristics like `rows.length === 1`. + */ + presentation?: 'list' | 'detail'; } /** Internal extension for lazy-loaded TS modules (not exposed in public API) */ @@ -112,6 +122,7 @@ export function cli(opts: CliOptions): CliCommand { replacedBy: opts.replacedBy, navigateBefore: opts.navigateBefore, defaultFormat: opts.defaultFormat, + presentation: opts.presentation, }; registerCommand(cmd); diff --git a/src/serialization.ts b/src/serialization.ts index 27c13183f..09b3c930a 100644 --- a/src/serialization.ts +++ b/src/serialization.ts @@ -47,6 +47,7 @@ export function serializeCommand(cmd: CliCommand) { browser: !!cmd.browser, args: cmd.args.map(serializeArg), columns: cmd.columns ?? [], + presentation: cmd.presentation ?? 'list', domain: cmd.domain ?? null, deprecated: cmd.deprecated ?? null, replacedBy: cmd.replacedBy ?? null, @@ -82,6 +83,7 @@ export function formatRegistryHelpText(cmd: CliCommand): string { const meta: string[] = []; meta.push(`Strategy: ${strategyLabel(cmd)}`); meta.push(`Browser: ${cmd.browser ? 'yes' : 'no'}`); + if (cmd.presentation) meta.push(`Presentation: ${cmd.presentation}`); if (cmd.domain) meta.push(`Domain: ${cmd.domain}`); if (cmd.deprecated) meta.push(`Deprecated: ${typeof cmd.deprecated === 'string' ? cmd.deprecated : 'yes'}`); if (cmd.replacedBy) meta.push(`Use instead: ${cmd.replacedBy}`); From 454b94d340a413556d4e76a5b8fd0d3d9ac12cdc Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 19 Apr 2026 22:19:53 +0800 Subject: [PATCH 2/9] Revert "feat(output): make detail layout explicit" This reverts commit 5fc77cca08e12f3595079ba31ee7114cf7d02039. --- clis/bilibili/me.js | 1 - clis/bluesky/profile.js | 1 - clis/twitter/profile.js | 1 - clis/v2ex/member.js | 1 - src/commanderAdapter.test.ts | 3 +-- src/commanderAdapter.ts | 1 - src/output.test.ts | 24 ------------------ src/output.ts | 49 +++++------------------------------- src/registry.test.ts | 12 --------- src/registry.ts | 11 -------- src/serialization.ts | 2 -- 11 files changed, 7 insertions(+), 99 deletions(-) diff --git a/clis/bilibili/me.js b/clis/bilibili/me.js index 012fc183a..e0131227c 100644 --- a/clis/bilibili/me.js +++ b/clis/bilibili/me.js @@ -4,7 +4,6 @@ cli({ site: 'bilibili', name: 'me', description: 'My Bilibili profile info', domain: 'www.bilibili.com', strategy: Strategy.COOKIE, args: [], columns: ['name', 'uid', 'level', 'coins', 'followers', 'following'], - presentation: 'detail', func: async (page) => { const uid = await getSelfUid(page); const payload = await apiGet(page, '/x/space/wbi/acc/info', { params: { mid: uid }, signed: true }); diff --git a/clis/bluesky/profile.js b/clis/bluesky/profile.js index e06f688ee..a50ae6302 100644 --- a/clis/bluesky/profile.js +++ b/clis/bluesky/profile.js @@ -15,7 +15,6 @@ cli({ }, ], columns: ['handle', 'name', 'followers', 'following', 'posts', 'description'], - presentation: 'detail', pipeline: [ { fetch: { url: 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${{ args.handle }}' } }, { map: { diff --git a/clis/twitter/profile.js b/clis/twitter/profile.js index ab727f749..c32e5b56a 100644 --- a/clis/twitter/profile.js +++ b/clis/twitter/profile.js @@ -13,7 +13,6 @@ cli({ { name: 'username', type: 'string', positional: true, help: 'Twitter screen name (without @). Defaults to logged-in user.' }, ], columns: ['screen_name', 'name', 'bio', 'location', 'url', 'followers', 'following', 'tweets', 'likes', 'verified', 'created_at'], - presentation: 'detail', func: async (page, kwargs) => { let username = (kwargs.username || '').replace(/^@/, ''); // If no username, detect the logged-in user diff --git a/clis/v2ex/member.js b/clis/v2ex/member.js index c2706d5da..df4be79dd 100644 --- a/clis/v2ex/member.js +++ b/clis/v2ex/member.js @@ -10,7 +10,6 @@ cli({ { name: 'username', required: true, positional: true, help: 'Username' }, ], columns: ['username', 'tagline', 'website', 'github', 'twitter', 'location'], - presentation: 'detail', pipeline: [ { fetch: { url: 'https://www.v2ex.com/api/members/show.json', diff --git a/src/commanderAdapter.test.ts b/src/commanderAdapter.test.ts index d30381c72..ee04a6d76 100644 --- a/src/commanderAdapter.test.ts +++ b/src/commanderAdapter.test.ts @@ -255,7 +255,6 @@ describe('commanderAdapter default formats', () => { args: [], columns: ['response'], defaultFormat: 'plain', - presentation: 'detail', func: vi.fn(), }; @@ -276,7 +275,7 @@ describe('commanderAdapter default formats', () => { expect(mockRenderOutput).toHaveBeenCalledWith( [{ response: 'hello' }], - expect.objectContaining({ fmt: 'plain', presentation: 'detail' }), + expect.objectContaining({ fmt: 'plain' }), ); }); diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index 73a7660bd..6cf7484b6 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -103,7 +103,6 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi fmt: format, fmtExplicit: formatExplicit, columns: resolved.columns, - presentation: resolved.presentation, title: `${resolved.site}/${resolved.name}`, elapsed: (Date.now() - startTime) / 1000, source: fullName(resolved), diff --git a/src/output.test.ts b/src/output.test.ts index c79051a5a..3f4be0c35 100644 --- a/src/output.test.ts +++ b/src/output.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { render } from './output.js'; -function stripAnsi(text: string): string { - return text.replace(/\u001B\[[0-9;]*m/g, ''); -} - describe('output TTY detection', () => { const originalIsTTY = process.stdout.isTTY; let logSpy: ReturnType; @@ -49,24 +45,4 @@ describe('output TTY detection', () => { expect(out).not.toContain('name: alice'); expect(out).toContain('alice'); }); - - it('keeps single-row table output as a table by default', () => { - Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); - render([{ name: 'alice', score: 10 }], { fmt: 'table', columns: ['name', 'score'] }); - const out = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n'); - expect(out).toContain('Name'); - expect(out).toContain('Score'); - expect(out).toContain('1 item'); - }); - - it('renders detail presentation as key/value output when explicitly requested', () => { - Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); - render({ name: 'alice', score: 10 }, { fmt: 'table', columns: ['name', 'score'], presentation: 'detail' }); - const out = stripAnsi(logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n')); - expect(out).toContain(' Name'); - expect(out).toContain('alice'); - expect(out).toContain(' Score'); - expect(out).toContain('10'); - expect(out).toContain('1 item'); - }); }); diff --git a/src/output.ts b/src/output.ts index 7e148ee1f..746f87960 100644 --- a/src/output.ts +++ b/src/output.ts @@ -11,7 +11,6 @@ export interface RenderOptions { /** True when the user explicitly passed -f on the command line */ fmtExplicit?: boolean; columns?: string[]; - presentation?: 'list' | 'detail'; title?: string; elapsed?: number; source?: string; @@ -52,10 +51,6 @@ 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); - if (opts.presentation === 'detail' && rows.length === 1) { - renderDetail(rows[0], columns, opts); - return; - } const header = columns.map(c => capitalize(c)); const table = new Table({ @@ -75,7 +70,12 @@ function renderTable(data: unknown, opts: RenderOptions): void { console.log(); if (opts.title) console.log(styleText('dim', ` ${opts.title}`)); console.log(table.toString()); - console.log(styleText('dim', formatFooter(rows.length, opts))); + 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 { @@ -137,43 +137,6 @@ function renderYaml(data: unknown): void { console.log(yaml.dump(data, { sortKeys: false, lineWidth: 120, noRefs: true })); } -function renderDetail(row: Record, columns: string[], opts: RenderOptions): void { - const entries = columns - .map((column) => [column, row[column]] as const) - .filter(([, value]) => value !== undefined && value !== null && String(value) !== ''); - - if (!entries.length) { - console.log(styleText('dim', '(no data)')); - return; - } - - const labels = entries.map(([column]) => capitalize(column)); - const keyWidth = Math.max(...labels.map((label) => label.length)); - - console.log(); - if (opts.title) console.log(styleText('dim', ` ${opts.title}`)); - entries.forEach(([column, value], index) => { - const label = capitalize(column).padEnd(keyWidth, ' '); - const rendered = String(value); - const lines = rendered.split('\n'); - console.log(` ${styleText('bold', label)} ${lines[0]}`); - for (let i = 1; i < lines.length; i++) { - console.log(` ${' '.repeat(keyWidth)} ${lines[i]}`); - } - if (index < entries.length - 1 && lines.length > 1) console.log(); - }); - console.log(styleText('dim', formatFooter(1, opts))); -} - -function formatFooter(count: number, opts: RenderOptions): string { - const footer: string[] = []; - footer.push(count === 1 ? '1 item' : `${count} 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); - return footer.join(' · '); -} - function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } diff --git a/src/registry.test.ts b/src/registry.test.ts index 3dcb13fe8..c7eb29f50 100644 --- a/src/registry.test.ts +++ b/src/registry.test.ts @@ -100,18 +100,6 @@ describe('cli() registration', () => { expect(cmd.defaultFormat).toBe('plain'); expect(getRegistry().get('test-registry/plain-default')?.defaultFormat).toBe('plain'); }); - - it('preserves explicit presentation metadata on the registered command', () => { - const cmd = cli({ - site: 'test-registry', - name: 'detail-view', - description: 'prefers key/value detail output', - presentation: 'detail', - }); - - expect(cmd.presentation).toBe('detail'); - expect(getRegistry().get('test-registry/detail-view')?.presentation).toBe('detail'); - }); }); describe('fullName', () => { diff --git a/src/registry.ts b/src/registry.ts index 05edfa9d8..5136af29c 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -71,16 +71,6 @@ export interface CliCommand { navigateBefore?: boolean | string; /** Override the default CLI output format when the user does not pass -f/--format. */ defaultFormat?: 'table' | 'plain' | 'json' | 'yaml' | 'yml' | 'md' | 'markdown' | 'csv'; - /** - * Presentation policy for default human-readable output. - * - * - `list`: row-oriented data rendered as a table - * - `detail`: a single entity rendered as key/value pairs - * - * This is intentionally explicit command metadata. The renderer should not - * guess "detail" from heuristics like `rows.length === 1`. - */ - presentation?: 'list' | 'detail'; } /** Internal extension for lazy-loaded TS modules (not exposed in public API) */ @@ -122,7 +112,6 @@ export function cli(opts: CliOptions): CliCommand { replacedBy: opts.replacedBy, navigateBefore: opts.navigateBefore, defaultFormat: opts.defaultFormat, - presentation: opts.presentation, }; registerCommand(cmd); diff --git a/src/serialization.ts b/src/serialization.ts index 09b3c930a..27c13183f 100644 --- a/src/serialization.ts +++ b/src/serialization.ts @@ -47,7 +47,6 @@ export function serializeCommand(cmd: CliCommand) { browser: !!cmd.browser, args: cmd.args.map(serializeArg), columns: cmd.columns ?? [], - presentation: cmd.presentation ?? 'list', domain: cmd.domain ?? null, deprecated: cmd.deprecated ?? null, replacedBy: cmd.replacedBy ?? null, @@ -83,7 +82,6 @@ export function formatRegistryHelpText(cmd: CliCommand): string { const meta: string[] = []; meta.push(`Strategy: ${strategyLabel(cmd)}`); meta.push(`Browser: ${cmd.browser ? 'yes' : 'no'}`); - if (cmd.presentation) meta.push(`Presentation: ${cmd.presentation}`); if (cmd.domain) meta.push(`Domain: ${cmd.domain}`); if (cmd.deprecated) meta.push(`Deprecated: ${typeof cmd.deprecated === 'string' ? cmd.deprecated : 'yes'}`); if (cmd.replacedBy) meta.push(`Use instead: ${cmd.replacedBy}`); From 1490579e7fb3a114429fb1c1abcee0437620b9f8 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 19 Apr 2026 22:23:47 +0800 Subject: [PATCH 3/9] feat(output): default to yaml output --- README.md | 2 +- src/cli.ts | 10 +++++----- src/commanderAdapter.test.ts | 6 +++--- src/commanderAdapter.ts | 8 ++------ src/output.test.ts | 15 +++++++-------- src/output.ts | 6 +----- 6 files changed, 19 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 1e01d91dd..4220ef23c 100644 --- a/README.md +++ b/README.md @@ -307,7 +307,7 @@ opencli xiaoyuzhou transcript 69dd0c98e2c8be31551f6a33 --output ./xiaoyuzhou-tra ## Output Formats -All built-in commands support `--format` / `-f` with `table` (default), `json`, `yaml`, `md`, and `csv`. +All built-in commands support `--format` / `-f` with `yaml` (default), `json`, `table`, `md`, and `csv`. ```bash opencli bilibili hot -f json # Pipe to jq or LLMs diff --git a/src/cli.ts b/src/cli.ts index b1dbc78e8..470645cd0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -199,12 +199,12 @@ 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, table, 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 fmt = opts.json ? 'json' : opts.format; const isStructured = fmt === 'json' || fmt === 'yaml'; if (fmt !== 'table') { @@ -1094,7 +1094,7 @@ cli({ pluginCmd .command('list') .description('List installed plugins') - .option('-f, --format ', 'Output format: table, json', 'table') + .option('-f, --format ', 'Output format: yaml, json, table', 'yaml') .action(async (opts) => { const { listPlugins } = await import('./plugin.js'); const plugins = listPlugins(); @@ -1103,9 +1103,9 @@ cli({ console.log(styleText('dim', ' Install one with: opencli plugin install github:user/repo')); return; } - if (opts.format === 'json') { + if (opts.format !== 'table') { renderOutput(plugins, { - fmt: 'json', + fmt: opts.format, columns: ['name', 'commands', 'source'], title: 'opencli/plugins', source: 'opencli plugin list', diff --git a/src/commanderAdapter.test.ts b/src/commanderAdapter.test.ts index ee04a6d76..54dec4ecc 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('defaults to yaml even if the command carries a legacy defaultFormat', async () => { const program = new Command(); const siteCmd = program.command('gemini'); registerCommandToProgram(siteCmd, cmd); @@ -275,11 +275,11 @@ describe('commanderAdapter default formats', () => { expect(mockRenderOutput).toHaveBeenCalledWith( [{ response: 'hello' }], - expect.objectContaining({ fmt: 'plain' }), + expect.objectContaining({ fmt: 'yaml' }), ); }); - 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); diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index 6cf7484b6..bce0f714c 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, table, 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'; + const 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,11 +90,7 @@ 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) { - format = resolved.defaultFormat; - } if (verbose && (!result || (Array.isArray(result) && result.length === 0))) { log.warn('Command returned an empty result.'); diff --git a/src/output.test.ts b/src/output.test.ts index 3f4be0c35..ff0aa50b1 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 }], { columns: ['name', 'score'] }); 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 }], { columns: ['name', 'score'] }); 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,11 +37,10 @@ describe('output TTY detection', () => { expect(JSON.parse(out)).toEqual([{ name: 'alice' }]); }); - it('explicit -f table overrides non-TTY auto-downgrade', () => { + it('explicit -f table still renders a table in non-TTY', () => { Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true }); render([{ name: 'alice' }], { fmt: 'table', fmtExplicit: true, columns: ['name'] }); 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('alice'); }); diff --git a/src/output.ts b/src/output.ts index 746f87960..05033962d 100644 --- a/src/output.ts +++ b/src/output.ts @@ -28,11 +28,7 @@ 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; From 7f82b6f80074051890a75e5f4394b5763faf0e2f Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 19 Apr 2026 22:28:01 +0800 Subject: [PATCH 4/9] docs: align yaml-default scope --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4220ef23c..565c21fca 100644 --- a/README.md +++ b/README.md @@ -307,7 +307,7 @@ opencli xiaoyuzhou transcript 69dd0c98e2c8be31551f6a33 --output ./xiaoyuzhou-tra ## Output Formats -All built-in commands support `--format` / `-f` with `yaml` (default), `json`, `table`, `md`, and `csv`. +Registry-backed commands, `opencli list`, and `opencli plugin list` support `--format` / `-f` with `yaml` as the default path. Rich-text formats such as `table`, `md`, and `csv` remain explicit opt-ins. ```bash opencli bilibili hot -f json # Pipe to jq or LLMs From cd00a466887a5340be813e1ebc5fab82e4a3b8de Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 19 Apr 2026 22:32:21 +0800 Subject: [PATCH 5/9] fix(output): preserve command default formats --- README.md | 2 +- src/commanderAdapter.test.ts | 27 +++++++++++++++++++++++++-- src/commanderAdapter.ts | 5 ++++- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 565c21fca..86421aa2c 100644 --- a/README.md +++ b/README.md @@ -307,7 +307,7 @@ opencli xiaoyuzhou transcript 69dd0c98e2c8be31551f6a33 --output ./xiaoyuzhou-tra ## Output Formats -Registry-backed commands, `opencli list`, and `opencli plugin list` support `--format` / `-f` with `yaml` as the default path. Rich-text formats such as `table`, `md`, and `csv` remain explicit opt-ins. +Registry-backed commands now default to `yaml` unless a command explicitly keeps another default format such as `plain` or `json`. `opencli list` and `opencli plugin list` also default to `yaml`, while rich-text formats such as `table`, `md`, and `csv` remain explicit opt-ins. ```bash opencli bilibili hot -f json # Pipe to jq or LLMs diff --git a/src/commanderAdapter.test.ts b/src/commanderAdapter.test.ts index 54dec4ecc..4dc44b70d 100644 --- a/src/commanderAdapter.test.ts +++ b/src/commanderAdapter.test.ts @@ -266,7 +266,7 @@ describe('commanderAdapter default formats', () => { process.exitCode = undefined; }); - it('defaults to yaml even if the command carries a legacy defaultFormat', 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); @@ -275,7 +275,7 @@ describe('commanderAdapter default formats', () => { expect(mockRenderOutput).toHaveBeenCalledWith( [{ response: 'hello' }], - expect.objectContaining({ fmt: 'yaml' }), + expect.objectContaining({ fmt: 'plain' }), ); }); @@ -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 bce0f714c..fb7457248 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -77,7 +77,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi const kwargs = prepareCommandArgs(cmd, rawKwargs); const verbose = optionsRecord.verbose === true; - const format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'yaml'; + 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) { @@ -91,6 +91,9 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi return; } const resolved = getRegistry().get(fullName(cmd)) ?? cmd; + if (!formatExplicit && resolved.defaultFormat) { + format = resolved.defaultFormat; + } if (verbose && (!result || (Array.isArray(result) && result.length === 0))) { log.warn('Command returned an empty result.'); From 5378863a22d775ecebb624c026fe2ff175d5aafe Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 19 Apr 2026 22:37:55 +0800 Subject: [PATCH 6/9] refactor(output): remove rich terminal render path --- README.md | 3 +- src/cli.ts | 123 ++--------------------------------- src/commanderAdapter.test.ts | 4 +- src/commanderAdapter.ts | 20 +----- src/output.test.ts | 13 ++-- src/output.ts | 122 ++-------------------------------- 6 files changed, 23 insertions(+), 262 deletions(-) diff --git a/README.md b/README.md index 86421aa2c..865f5de80 100644 --- a/README.md +++ b/README.md @@ -307,11 +307,10 @@ opencli xiaoyuzhou transcript 69dd0c98e2c8be31551f6a33 --output ./xiaoyuzhou-tra ## Output Formats -Registry-backed commands now default to `yaml` unless a command explicitly keeps another default format such as `plain` or `json`. `opencli list` and `opencli plugin list` also default to `yaml`, while rich-text formats such as `table`, `md`, and `csv` remain explicit opt-ins. +Registry-backed commands, `opencli list`, and `opencli plugin list` support `--format` / `-f` with `yaml` (default) and `json`. ```bash opencli bilibili hot -f json # Pipe to jq or LLMs -opencli bilibili hot -f csv # Spreadsheet-friendly opencli bilibili hot -v # Verbose: show pipeline debug steps ``` diff --git a/src/cli.ts b/src/cli.ts index 470645cd0..64d03705f 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: yaml, json, table, md, csv', 'yaml') + .option('-f, --format ', 'Output format: yaml, json', '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 ? '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(); + 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: yaml, json, table', 'yaml') + .option('-f, --format ', 'Output format: yaml, json', '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 !== 'table') { - renderOutput(plugins, { - fmt: opts.format, - 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 4dc44b70d..36c1883db 100644 --- a/src/commanderAdapter.test.ts +++ b/src/commanderAdapter.test.ts @@ -266,7 +266,7 @@ describe('commanderAdapter default formats', () => { process.exitCode = undefined; }); - it('preserves a command defaultFormat when the user does not override it', async () => { + it('defaults to yaml even if the command carries a legacy defaultFormat', async () => { const program = new Command(); const siteCmd = program.command('gemini'); registerCommandToProgram(siteCmd, cmd); @@ -275,7 +275,7 @@ describe('commanderAdapter default formats', () => { expect(mockRenderOutput).toHaveBeenCalledWith( [{ response: 'hello' }], - expect.objectContaining({ fmt: 'plain' }), + expect.objectContaining({ fmt: 'yaml' }), ); }); diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index fb7457248..4e831edc8 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: yaml, json, table, plain, md, csv', 'yaml') + .option('-f, --format ', 'Output format: yaml, json', 'yaml') .option('-v, --verbose', 'Debug output', false); subCmd.addHelpText('after', formatRegistryHelpText(cmd)); @@ -77,8 +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 : 'yaml'; - const formatExplicit = subCmd.getOptionValueSource('format') === 'cli'; + const format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'yaml'; if (verbose) process.env.OPENCLI_VERBOSE = '1'; if (cmd.deprecated) { const message = typeof cmd.deprecated === 'string' ? cmd.deprecated : `${fullName(cmd)} is deprecated.`; @@ -90,23 +89,10 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi if (result === null || result === undefined) { return; } - const resolved = getRegistry().get(fullName(cmd)) ?? cmd; - 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 }); } 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 ff0aa50b1..e2fc0925b 100644 --- a/src/output.test.ts +++ b/src/output.test.ts @@ -16,7 +16,7 @@ describe('output TTY detection', () => { it('defaults to YAML in non-TTY', () => { Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true }); - render([{ name: 'alice', score: 10 }], { 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'); @@ -24,7 +24,7 @@ describe('output TTY detection', () => { it('defaults to YAML in TTY too', () => { Object.defineProperty(process.stdout, 'isTTY', { value: true, writable: true }); - render([{ name: 'alice', score: 10 }], { 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'); @@ -37,11 +37,10 @@ describe('output TTY detection', () => { expect(JSON.parse(out)).toEqual([{ name: 'alice' }]); }); - it('explicit -f table still renders a table in non-TTY', () => { - Object.defineProperty(process.stdout, 'isTTY', { value: false, writable: true }); - render([{ name: 'alice' }], { fmt: 'table', fmtExplicit: true, columns: ['name'] }); + it('falls back to YAML for unsupported legacy rich formats', () => { + 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).not.toContain('name: alice'); - expect(out).toContain('alice'); + expect(out).toContain('name: alice'); }); }); diff --git a/src/output.ts b/src/output.ts index 05033962d..4da7bd3ce 100644 --- a/src/output.ts +++ b/src/output.ts @@ -1,30 +1,11 @@ /** - * Output formatting: table, JSON, Markdown, CSV, YAML. + * Structured output formatting: YAML by default, JSON when explicitly requested. */ -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[] { - if (Array.isArray(data)) return data; - if (data && typeof data === 'object') return [data as Record]; - return [{ value: data }]; -} - -function resolveColumns(rows: Record[], opts: RenderOptions): string[] { - return opts.columns ?? Object.keys(rows[0] ?? {}); } export function render(data: unknown, opts: RenderOptions = {}): void { @@ -33,106 +14,13 @@ export function render(data: unknown, opts: RenderOptions = {}): void { 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 'csv': renderCsv(data, opts); break; - case 'yaml': case 'yml': renderYaml(data); break; - default: renderTable(data, opts); 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 { - 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); - if (entries.length === 1) { - const [key, value] = entries[0]; - if (key === 'response' || key === 'content' || key === 'text' || key === 'value') { - console.log(String(value ?? '')); - return; - } - } - } - - 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}`); - }); - 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(' | ') + ' |'); - for (const row of rows) { - console.log('| ' + columns.map(c => String((row as Record)[c] ?? '')).join(' | ') + ' |'); - } -} - -function renderCsv(data: unknown, opts: RenderOptions): void { - const rows = normalizeRows(data); - if (!rows.length) return; - 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; - }).join(',')); + if (fmt === 'json') { + console.log(JSON.stringify(data, null, 2)); + return; } + renderYaml(data); } 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); -} From 10a909fd2d20ff6eea739a0d226c2250a3363ffe Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 19 Apr 2026 22:51:19 +0800 Subject: [PATCH 7/9] fix(output): align generate and e2e formats --- src/cli.test.ts | 32 ++++++++++++++++++++++++++++++++ src/cli.ts | 7 +++---- tests/e2e/management.test.ts | 29 +++-------------------------- tests/e2e/output-formats.test.ts | 13 +------------ 4 files changed, 39 insertions(+), 42 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index e6086f06c..d881fe061 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 yaml 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(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('status: success')); + expect(mockRenderGenerateVerifiedSummary).not.toHaveBeenCalled(); + }); + + 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 64d03705f..d35d21281 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -288,7 +288,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command .argument('') .option('--goal ') .option('--site ') - .option('--format ', 'Output format: table, json', 'table') + .option('--format ', 'Output format: yaml, json', 'yaml') .option('--no-register', 'Verify the generated adapter without registering it') .option('-v, --verbose', 'Debug output') .action(async (url: string, opts: { @@ -299,7 +299,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command verbose?: boolean; }) => { applyVerbose(opts); - const { generateVerifiedFromUrl, renderGenerateVerifiedSummary } = await import('./generate-verified.js'); + const { generateVerifiedFromUrl } = await import('./generate-verified.js'); const workspace = `generate:${inferHost(url, opts.site)}`; const r = await generateVerifiedFromUrl({ url, @@ -309,8 +309,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command workspace, noRegister: opts.register === false, }); - if (opts.format === 'json') console.log(JSON.stringify(r, null, 2)); - else console.log(renderGenerateVerifiedSummary(r)); + renderOutput(r, { fmt: opts.format }); process.exitCode = r.status === 'success' ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR; }); diff --git a/tests/e2e/management.test.ts b/tests/e2e/management.test.ts index 8a6333409..1b5421750 100644 --- a/tests/e2e/management.test.ts +++ b/tests/e2e/management.test.ts @@ -24,36 +24,13 @@ 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:'); - }); - - it('list -f csv produces valid csv', async () => { - const { stdout, code } = await runCli(['list', '-f', 'csv']); - expect(code).toBe(0); - const lines = stdout.trim().split('\n'); - expect(lines.length).toBeGreaterThan(50); - }); - - 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('hackernews'); + }); // ── validate ── it('validate passes for all built-in adapters', async () => { diff --git a/tests/e2e/output-formats.test.ts b/tests/e2e/output-formats.test.ts index 638863dd9..77dd51ad5 100644 --- a/tests/e2e/output-formats.test.ts +++ b/tests/e2e/output-formats.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect } from 'vitest'; import { runCli, parseJsonOutput } from './helpers.js'; -const FORMATS = ['json', 'yaml', 'csv', 'md'] as const; +const FORMATS = ['json', 'yaml'] as const; describe('output formats E2E', () => { for (const fmt of FORMATS) { @@ -28,17 +28,6 @@ describe('output formats E2E', () => { expect(stdout).toContain('command:'); expect(stdout).toContain('site:'); } - - 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); } }); From 5816d7d672e2a51ac6a04b7ce6dda16fb0a96164 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 19 Apr 2026 22:54:01 +0800 Subject: [PATCH 8/9] fix(output): keep generate summary path --- src/cli.test.ts | 6 +++--- src/cli.ts | 7 ++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index d881fe061..7fea4ccb2 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -136,7 +136,7 @@ describe('built-in browser commands verbose wiring', () => { ); }); - it('renders generate output as yaml by default', async () => { + it('renders generate output as summary by default', async () => { const program = createProgram('', ''); mockGenerateVerifiedFromUrl.mockResolvedValueOnce({ @@ -147,8 +147,8 @@ describe('built-in browser commands verbose wiring', () => { await program.parseAsync(['node', 'opencli', 'generate', 'https://example.com']); - expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('status: success')); - expect(mockRenderGenerateVerifiedSummary).not.toHaveBeenCalled(); + expect(mockRenderGenerateVerifiedSummary).toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith('generate-summary'); }); it('renders generate output as json when requested', async () => { diff --git a/src/cli.ts b/src/cli.ts index d35d21281..64d03705f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -288,7 +288,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command .argument('') .option('--goal ') .option('--site ') - .option('--format ', 'Output format: yaml, json', 'yaml') + .option('--format ', 'Output format: table, json', 'table') .option('--no-register', 'Verify the generated adapter without registering it') .option('-v, --verbose', 'Debug output') .action(async (url: string, opts: { @@ -299,7 +299,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command verbose?: boolean; }) => { applyVerbose(opts); - const { generateVerifiedFromUrl } = await import('./generate-verified.js'); + const { generateVerifiedFromUrl, renderGenerateVerifiedSummary } = await import('./generate-verified.js'); const workspace = `generate:${inferHost(url, opts.site)}`; const r = await generateVerifiedFromUrl({ url, @@ -309,7 +309,8 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command workspace, noRegister: opts.register === false, }); - renderOutput(r, { fmt: opts.format }); + if (opts.format === 'json') console.log(JSON.stringify(r, null, 2)); + else console.log(renderGenerateVerifiedSummary(r)); process.exitCode = r.status === 'success' ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR; }); From 94b03f86540a8e6a6a141fd1391192b427142a29 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 19 Apr 2026 22:59:02 +0800 Subject: [PATCH 9/9] fix(output): restore text format paths --- README.md | 4 +- src/cli.ts | 4 +- src/commanderAdapter.test.ts | 4 +- src/commanderAdapter.ts | 11 +++-- src/output.test.ts | 24 ++++++++- src/output.ts | 85 ++++++++++++++++++++++++++++++-- tests/e2e/management.test.ts | 15 +++++- tests/e2e/output-formats.test.ts | 11 ++++- 8 files changed, 142 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 865f5de80..270e2eb33 100644 --- a/README.md +++ b/README.md @@ -307,10 +307,12 @@ opencli xiaoyuzhou transcript 69dd0c98e2c8be31551f6a33 --output ./xiaoyuzhou-tra ## Output Formats -Registry-backed commands, `opencli list`, and `opencli plugin list` support `--format` / `-f` with `yaml` (default) and `json`. +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.ts b/src/cli.ts index 64d03705f..454e1d630 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -199,7 +199,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string): Command program .command('list') .description('List all available CLI commands') - .option('-f, --format ', 'Output format: yaml, json', 'yaml') + .option('-f, --format ', 'Output format: yaml, json, plain, md, csv', 'yaml') .option('--json', 'JSON output (deprecated)') .action((opts) => { const registry = getRegistry(); @@ -1033,7 +1033,7 @@ cli({ pluginCmd .command('list') .description('List installed plugins') - .option('-f, --format ', 'Output format: yaml, json', 'yaml') + .option('-f, --format ', 'Output format: yaml, json, plain, md, csv', 'yaml') .action(async (opts) => { const { listPlugins } = await import('./plugin.js'); renderOutput(listPlugins(), { fmt: opts.format }); diff --git a/src/commanderAdapter.test.ts b/src/commanderAdapter.test.ts index 36c1883db..4dc44b70d 100644 --- a/src/commanderAdapter.test.ts +++ b/src/commanderAdapter.test.ts @@ -266,7 +266,7 @@ describe('commanderAdapter default formats', () => { process.exitCode = undefined; }); - it('defaults to yaml even if the command carries a legacy defaultFormat', 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); @@ -275,7 +275,7 @@ describe('commanderAdapter default formats', () => { expect(mockRenderOutput).toHaveBeenCalledWith( [{ response: 'hello' }], - expect.objectContaining({ fmt: 'yaml' }), + expect.objectContaining({ fmt: 'plain' }), ); }); diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index 4e831edc8..340057479 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: yaml, json', 'yaml') + .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,8 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi const kwargs = prepareCommandArgs(cmd, rawKwargs); const verbose = optionsRecord.verbose === true; - const format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'yaml'; + 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) { const message = typeof cmd.deprecated === 'string' ? cmd.deprecated : `${fullName(cmd)} is deprecated.`; @@ -89,10 +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 && 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 }); + 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 e2fc0925b..063fe4dd3 100644 --- a/src/output.test.ts +++ b/src/output.test.ts @@ -37,7 +37,29 @@ describe('output TTY detection', () => { expect(JSON.parse(out)).toEqual([{ name: 'alice' }]); }); - it('falls back to YAML for unsupported legacy rich formats', () => { + 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'); + 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'); diff --git a/src/output.ts b/src/output.ts index 4da7bd3ce..4e2d54ad8 100644 --- a/src/output.ts +++ b/src/output.ts @@ -1,11 +1,22 @@ /** - * Structured output formatting: YAML by default, JSON when explicitly requested. + * Output formatting with structured defaults and text-only opt-in formats. */ import yaml from 'js-yaml'; export interface RenderOptions { fmt?: string; + columns?: string[]; +} + +function normalizeRows(data: unknown): Record[] { + if (Array.isArray(data)) return data; + if (data && typeof data === 'object') return [data as Record]; + return [{ value: data }]; +} + +function resolveColumns(rows: Record[], opts: RenderOptions): string[] { + return opts.columns ?? Object.keys(rows[0] ?? {}); } export function render(data: unknown, opts: RenderOptions = {}): void { @@ -14,11 +25,75 @@ export function render(data: unknown, opts: RenderOptions = {}): void { console.log(data); return; } - if (fmt === 'json') { - console.log(JSON.stringify(data, null, 2)); - return; + switch (fmt) { + case 'json': renderJson(data); break; + case 'plain': renderPlain(data); break; + case 'md': + case 'markdown': renderMarkdown(data, opts); break; + case 'csv': renderCsv(data, opts); break; + case 'yaml': + case 'yml': + case 'table': + default: + renderYaml(data); + break; + } +} + +function renderJson(data: unknown): void { + console.log(JSON.stringify(data, null, 2)); +} + +function renderPlain(data: unknown): void { + const rows = normalizeRows(data); + if (!rows.length) return; + + if (rows.length === 1) { + const row = rows[0]; + const entries = Object.entries(row); + if (entries.length === 1) { + const [key, value] = entries[0]; + if (key === 'response' || key === 'content' || key === 'text' || key === 'value') { + console.log(String(value ?? '')); + return; + } + } + } + + rows.forEach((row, index) => { + 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(' | ')} |`); + for (const row of rows) { + console.log(`| ${columns.map((column) => String(row[column] ?? '')).join(' | ')} |`); + } +} + +function renderCsv(data: unknown, opts: RenderOptions): void { + const rows = normalizeRows(data); + if (!rows.length) return; + const columns = resolveColumns(rows, opts); + console.log(columns.join(',')); + for (const row of rows) { + 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(',')); } - renderYaml(data); } function renderYaml(data: unknown): void { diff --git a/tests/e2e/management.test.ts b/tests/e2e/management.test.ts index 1b5421750..8ceec3554 100644 --- a/tests/e2e/management.test.ts +++ b/tests/e2e/management.test.ts @@ -30,7 +30,20 @@ describe('management commands E2E', () => { expect(stdout).toContain('command:'); expect(stdout).toContain('site:'); expect(stdout).toContain('hackernews'); - }); + }); + + it('list -f csv produces valid csv', async () => { + const { stdout, code } = await runCli(['list', '-f', 'csv']); + expect(code).toBe(0); + const lines = stdout.trim().split('\n'); + expect(lines.length).toBeGreaterThan(50); + }); + + it('list -f md produces markdown table', async () => { + const { stdout, code } = await runCli(['list', '-f', 'md']); + expect(code).toBe(0); + expect(stdout).toContain('| command |'); + }); // ── validate ── it('validate passes for all built-in adapters', async () => { diff --git a/tests/e2e/output-formats.test.ts b/tests/e2e/output-formats.test.ts index 77dd51ad5..1c6ac6727 100644 --- a/tests/e2e/output-formats.test.ts +++ b/tests/e2e/output-formats.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect } from 'vitest'; import { runCli, parseJsonOutput } from './helpers.js'; -const FORMATS = ['json', 'yaml'] as const; +const FORMATS = ['json', 'yaml', 'csv', 'md'] as const; describe('output formats E2E', () => { for (const fmt of FORMATS) { @@ -28,6 +28,15 @@ describe('output formats E2E', () => { expect(stdout).toContain('command:'); expect(stdout).toContain('site:'); } + + if (fmt === 'csv') { + const lines = stdout.trim().split('\n'); + expect(lines.length).toBeGreaterThanOrEqual(2); + } + + if (fmt === 'md') { + expect(stdout).toContain('| command |'); + } }, 30_000); } });