feat(cli): padronizar saída emit() — cli-table3/csv-stringify/schema (Fase 1.7)

This commit is contained in:
diegosouzapw
2026-05-14 23:54:49 -03:00
parent 9da0e704a7
commit 0bb1ae7240
6 changed files with 282 additions and 52 deletions

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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" },
];

75
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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"));
});