From 0bb1ae72404aef68d18330bf5fe2b5e8e57cc8c0 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 14 May 2026 23:54:49 -0300 Subject: [PATCH] =?UTF-8?q?feat(cli):=20padronizar=20sa=C3=ADda=20emit()?= =?UTF-8?q?=20=E2=80=94=20cli-table3/csv-stringify/schema=20(Fase=201.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/cli/commands/models.mjs | 32 +++++------ bin/cli/output.mjs | 86 +++++++++++++++++++----------- bin/cli/schemas/output-schemas.mjs | 56 +++++++++++++++++++ package-lock.json | 75 +++++++++++++++++++++++++- package.json | 2 + tests/unit/cli-output.test.ts | 83 ++++++++++++++++++++++++++++ 6 files changed, 282 insertions(+), 52 deletions(-) create mode 100644 bin/cli/schemas/output-schemas.mjs create mode 100644 tests/unit/cli-output.test.ts diff --git a/bin/cli/commands/models.mjs b/bin/cli/commands/models.mjs index 65aa4d0a2f..d8095fea88 100644 --- a/bin/cli/commands/models.mjs +++ b/bin/cli/commands/models.mjs @@ -1,4 +1,6 @@ import { apiFetch, isServerUp } from "../api.mjs"; +import { emit } from "../output.mjs"; +import { modelListSchema } from "../schemas/output-schemas.mjs"; import { t } from "../i18n.mjs"; export function registerModels(program) { @@ -66,35 +68,25 @@ export async function runModelsCommand(provider, opts = {}) { ); } - if (opts.json || opts.output === "json") { - console.log(JSON.stringify(models, null, 2)); - return 0; - } - if (models.length === 0) { console.log(t("models.noModels")); return 0; } - console.log(); - console.log("\x1b[36m" + " Model".padEnd(45) + "Provider".padEnd(20) + "Context\x1b[0m"); - console.log( - "\x1b[2m " + "─".repeat(44) + " " + "─".repeat(19) + " " + "─".repeat(10) + "\x1b[0m" - ); + const normalized = models.map((m) => ({ + id: m.id || m.name || "unknown", + provider: m.provider || "unknown", + contextWindow: String(m.context_length || m.max_tokens || m.contextWindow || "-"), + })); - const displayModels = models.slice(0, 50); - for (const model of displayModels) { - const name = (model.id || model.name || "unknown").slice(0, 43); - const prov = (model.provider || "unknown").slice(0, 18); - const context = model.context_length || model.max_tokens || model.contextWindow || "-"; - console.log(` ${name.padEnd(45)}${prov.padEnd(20)}${String(context).padEnd(10)}`); - } + const display = normalized.slice(0, 50); + emit(display, opts, modelListSchema); - console.log(); if (models.length > 50) { - console.log(`\x1b[2m ... and ${models.length - 50} more. Use --json for full list.\x1b[0m`); + console.log( + `\x1b[2m ... and ${models.length - 50} more. Use --output json for full list.\x1b[0m` + ); } - console.log(` \x1b[32mTotal: ${models.length} models\x1b[0m`); return 0; } diff --git a/bin/cli/output.mjs b/bin/cli/output.mjs index 7be42b747d..5421b33d29 100644 --- a/bin/cli/output.mjs +++ b/bin/cli/output.mjs @@ -1,3 +1,6 @@ +import Table from "cli-table3"; +import { stringify as csvStringify } from "csv-stringify/sync"; + const MASK_RE = /sk-[A-Za-z0-9]{4,}/g; export const EXIT_CODES = Object.freeze({ @@ -17,50 +20,71 @@ export function maskSecret(value) { function toRows(data) { if (Array.isArray(data)) return data; - if (data !== null && typeof data === "object") return [data]; + if (data !== null && typeof data === "object") return data.items ? data.items : [data]; return [{ value: data }]; } -function renderTable(rows) { +function pickFormat(opts) { + if (opts.output) return opts.output; + if (opts.json) return "json"; + if (!process.stdout.isTTY) return "json"; + return "table"; +} + +function inferSchema(sample) { + return Object.keys(sample).map((k) => ({ key: k, header: k })); +} + +function formatCell(v, col) { + if (v == null) return ""; + if (col.formatter) return col.formatter(v); + return String(v); +} + +function renderTable(rows, schema, opts = {}) { if (rows.length === 0) { process.stdout.write("(empty)\n"); return; } - const keys = Array.from( - rows.reduce((acc, row) => { - for (const k of Object.keys(row)) acc.add(k); - return acc; - }, new Set()) - ); - - const widths = keys.map((k) => Math.max(k.length, ...rows.map((r) => String(r[k] ?? "").length))); - - const sep = widths.map((w) => "-".repeat(w)).join("-+-"); - const header = keys.map((k, i) => k.padEnd(widths[i])).join(" | "); - - process.stdout.write(`${header}\n${sep}\n`); + const cols = schema || inferSchema(rows[0]); + const quiet = opts.quiet === true; + const widths = cols.map((c) => c.width || null); + const hasWidths = widths.some((w) => w !== null); + const tableOpts = { + head: quiet ? [] : cols.map((c) => c.header), + style: { head: quiet ? [] : ["cyan"] }, + }; + if (hasWidths) tableOpts.colWidths = widths; + const table = new Table(tableOpts); for (const row of rows) { - const line = keys.map((k, i) => String(row[k] ?? "").padEnd(widths[i])).join(" | "); - process.stdout.write(`${line}\n`); + table.push(cols.map((c) => formatCell(row[c.key], c))); } + process.stdout.write(table.toString() + "\n"); } -function renderCsv(rows) { - if (rows.length === 0) return; - const keys = Object.keys(rows[0]); - process.stdout.write(keys.map(csvEscape).join(",") + "\n"); - for (const row of rows) { - process.stdout.write(keys.map((k) => csvEscape(String(row[k] ?? ""))).join(",") + "\n"); +function renderCsv(rows, schema) { + if (rows.length === 0) { + process.stdout.write("\n"); + return; } + const cols = schema || inferSchema(rows[0]); + const headers = cols.map((c) => c.header); + const records = rows.map((r) => cols.map((c) => formatCell(r[c.key], c))); + process.stdout.write(csvStringify([headers, ...records])); } -function csvEscape(value) { - if (/[",\r\n]/.test(value)) return `"${value.replace(/"/g, '""')}"`; - return value; +function renderJsonl(rows) { + for (const row of rows) process.stdout.write(JSON.stringify(row) + "\n"); } -export function emit(data, opts = {}) { - const format = opts.output || "table"; +/** + * Emit structured data to stdout in the requested format. + * @param {unknown} data - Array of objects or single object + * @param {object} opts - Options: { output, json, quiet } + * @param {Array|null} schema - Column definitions: [{ key, header, width?, formatter? }] + */ +export function emit(data, opts = {}, schema = null) { + const format = pickFormat(opts); const rows = toRows(data); switch (format) { @@ -68,13 +92,13 @@ export function emit(data, opts = {}) { process.stdout.write(JSON.stringify(data, null, 2) + "\n"); break; case "jsonl": - for (const row of rows) process.stdout.write(JSON.stringify(row) + "\n"); + renderJsonl(rows); break; case "csv": - renderCsv(rows); + renderCsv(rows, schema); break; default: - renderTable(rows); + renderTable(rows, schema, opts); } } diff --git a/bin/cli/schemas/output-schemas.mjs b/bin/cli/schemas/output-schemas.mjs new file mode 100644 index 0000000000..e191895c36 --- /dev/null +++ b/bin/cli/schemas/output-schemas.mjs @@ -0,0 +1,56 @@ +const checkmark = (v) => (v ? "\x1b[32m✓\x1b[0m" : "\x1b[31m✗\x1b[0m"); +const bullet = (v) => (v ? "\x1b[32m●\x1b[0m" : ""); +const statusColor = (v) => { + if (!v) return ""; + const s = String(v).toLowerCase(); + if (s === "ok" || s === "active" || s === "success") return `\x1b[32m${v}\x1b[0m`; + if (s === "warn" || s === "degraded") return `\x1b[33m${v}\x1b[0m`; + return `\x1b[31m${v}\x1b[0m`; +}; + +export const providerListSchema = [ + { key: "provider", header: "Provider", width: 20 }, + { key: "name", header: "Name", width: 30 }, + { key: "isActive", header: "Active", width: 8, formatter: checkmark }, + { key: "testStatus", header: "Status", width: 12, formatter: statusColor }, + { key: "lastTested", header: "Last Test", width: 22 }, +]; + +export const comboListSchema = [ + { key: "name", header: "Name", width: 26 }, + { key: "strategy", header: "Strategy", width: 18 }, + { key: "enabled", header: "Enabled", width: 9, formatter: checkmark }, + { key: "active", header: "Active", width: 8, formatter: bullet }, +]; + +export const modelListSchema = [ + { key: "id", header: "Model ID", width: 46 }, + { key: "provider", header: "Provider", width: 20 }, + { key: "contextWindow", header: "Context", width: 10 }, +]; + +export const healthSchema = [ + { key: "component", header: "Component", width: 26 }, + { key: "status", header: "Status", width: 10, formatter: statusColor }, + { key: "message", header: "Message" }, +]; + +export const quotaSchema = [ + { key: "provider", header: "Provider", width: 20 }, + { key: "used", header: "Used", width: 12 }, + { key: "limit", header: "Limit", width: 12 }, + { key: "remaining", header: "Remaining", width: 14 }, + { key: "resetAt", header: "Resets At", width: 22 }, +]; + +export const keysListSchema = [ + { key: "provider", header: "Provider", width: 20 }, + { key: "name", header: "Name", width: 30 }, + { key: "isActive", header: "Active", width: 8, formatter: checkmark }, + { key: "testStatus", header: "Status", width: 12, formatter: statusColor }, +]; + +export const cacheStatusSchema = [ + { key: "key", header: "Metric", width: 28 }, + { key: "value", header: "Value" }, +]; diff --git a/package-lock.json b/package-lock.json index c8a5b8d212..b133ebafd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,9 @@ "bcryptjs": "^3.0.3", "better-sqlite3": "^12.9.0", "bottleneck": "^2.19.5", + "cli-table3": "^0.6.5", "commander": "^14.0.3", + "csv-stringify": "^6.7.0", "express": "^5.2.1", "fetch-socks": "^1.3.3", "fuse.js": "^7.3.0", @@ -483,6 +485,16 @@ "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", "license": "Apache-2.0" }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", @@ -5326,7 +5338,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6160,6 +6171,62 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-table3/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cli-table3/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-table3/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-table3/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/cli-truncate": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", @@ -6529,6 +6596,12 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/csv-stringify": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.7.0.tgz", + "integrity": "sha512-UdtziYp5HuTz7e5j8Nvq+a/3HQo+2/aJZ9xntNTpmRRIg/3YYqDVgiS9fvAhtNbnyfbv2ZBe0bqCHqzhE7FqWQ==", + "license": "MIT" + }, "node_modules/cytoscape": { "version": "3.33.3", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.3.tgz", diff --git a/package.json b/package.json index 66eee61491..d4d83a9b7b 100644 --- a/package.json +++ b/package.json @@ -135,7 +135,9 @@ "bcryptjs": "^3.0.3", "better-sqlite3": "^12.9.0", "bottleneck": "^2.19.5", + "cli-table3": "^0.6.5", "commander": "^14.0.3", + "csv-stringify": "^6.7.0", "express": "^5.2.1", "fetch-socks": "^1.3.3", "fuse.js": "^7.3.0", diff --git a/tests/unit/cli-output.test.ts b/tests/unit/cli-output.test.ts new file mode 100644 index 0000000000..a9e8c1324b --- /dev/null +++ b/tests/unit/cli-output.test.ts @@ -0,0 +1,83 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +function captureStdout(fn: () => void): string { + const chunks: string[] = []; + const originalWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = (chunk: string | Uint8Array) => { + chunks.push(typeof chunk === "string" ? chunk : chunk.toString()); + return true; + }; + try { + fn(); + } finally { + process.stdout.write = originalWrite; + } + return chunks.join(""); +} + +test("emit json format outputs valid JSON", async () => { + const { emit } = await import("../../bin/cli/output.mjs"); + const data = [{ id: "m1", provider: "openai" }]; + const out = captureStdout(() => emit(data, { output: "json" })); + const parsed = JSON.parse(out); + assert.deepEqual(parsed, data); +}); + +test("emit jsonl format outputs one JSON per line", async () => { + const { emit } = await import("../../bin/cli/output.mjs"); + const data = [{ id: "m1" }, { id: "m2" }]; + const out = captureStdout(() => emit(data, { output: "jsonl" })); + const lines = out.trim().split("\n").filter(Boolean); + assert.equal(lines.length, 2); + assert.equal(JSON.parse(lines[0]).id, "m1"); + assert.equal(JSON.parse(lines[1]).id, "m2"); +}); + +test("emit csv format outputs header + rows", async () => { + const { emit } = await import("../../bin/cli/output.mjs"); + const schema = [ + { key: "id", header: "Model" }, + { key: "provider", header: "Provider" }, + ]; + const data = [{ id: "gpt-4", provider: "openai" }]; + const out = captureStdout(() => emit(data, { output: "csv" }, schema)); + const lines = out.trim().split("\n"); + assert.equal(lines[0], "Model,Provider"); + assert.equal(lines[1], "gpt-4,openai"); +}); + +test("emit table format outputs non-empty content", async () => { + const { emit } = await import("../../bin/cli/output.mjs"); + const data = [{ id: "m1", provider: "anthropic" }]; + const out = captureStdout(() => emit(data, { output: "table" })); + assert.ok(out.length > 0); + assert.ok(out.includes("m1")); + assert.ok(out.includes("anthropic")); +}); + +test("emit auto-detects json when stdout is not TTY", async () => { + const { emit } = await import("../../bin/cli/output.mjs"); + const data = [{ id: "x" }]; + const origIsTTY = process.stdout.isTTY; + // @ts-ignore + process.stdout.isTTY = false; + const out = captureStdout(() => emit(data, {})); + // @ts-ignore + process.stdout.isTTY = origIsTTY; + const parsed = JSON.parse(out); + assert.ok(Array.isArray(parsed)); +}); + +test("emit empty array outputs (empty) for table", async () => { + const { emit } = await import("../../bin/cli/output.mjs"); + const out = captureStdout(() => emit([], { output: "table" })); + assert.ok(out.includes("empty")); +}); + +test("maskSecret redacts sk- keys", async () => { + const { maskSecret } = await import("../../bin/cli/output.mjs"); + const masked = maskSecret("prefix sk-abcdefgh1234 suffix"); + assert.ok(!masked.includes("sk-abcdefgh1234")); + assert.ok(masked.includes("sk-ab***1234")); +});