diff --git a/package-lock.json b/package-lock.json index 3f8b64e..7256a77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "@insforge/cli", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@insforge/cli", - "version": "0.2.0", + "version": "0.2.1", "license": "Apache-2.0", "dependencies": { "@clack/prompts": "^0.9.1", "@insforge/shared-schemas": "^1.1.58", + "@toon-format/toon": "^4.1.0", "archiver": "^7.0.1", "cli-table3": "^0.6.5", "commander": "^13.1.0", @@ -1477,6 +1478,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@toon-format/toon": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@toon-format/toon/-/toon-4.1.0.tgz", + "integrity": "sha512-dBB3pkEx9QYvHnHR6rtkaBAh+7x4W/oA5ONur4G0fh7Ow69PbPuM7OFxzNRABqyxC0t6SZ3RixiGbCuaFjPDAQ==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", diff --git a/package.json b/package.json index fbafa0d..a0c8111 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "dependencies": { "@clack/prompts": "^0.9.1", "@insforge/shared-schemas": "^1.1.58", + "@toon-format/toon": "^4.1.0", "archiver": "^7.0.1", "cli-table3": "^0.6.5", "commander": "^13.1.0", diff --git a/src/index.ts b/src/index.ts index 65c600c..3bac304 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import { Command } from 'commander'; import * as clack from '@clack/prompts'; import * as prompts from './lib/prompts.js'; import { getCredentials, getProjectConfig } from './lib/config.js'; -import { outputJson } from './lib/output.js'; +import { outputJson, setToonMode } from './lib/output.js'; import { registerLoginCommand } from './commands/login.js'; import { registerLogoutCommand } from './commands/logout.js'; import { registerWhoamiCommand } from './commands/whoami.js'; @@ -116,6 +116,7 @@ program // Global options program .option('--json', 'Output in JSON format') + .option('--toon', 'Output in TOON format (token-optimized notation, see toonformat.dev)') .option('--forger', 'Play the Forger animation (root command only) and return to the interactive menu') .option('--api-url ', 'Override Platform API URL') .option('-y, --yes', 'Skip confirmation prompts') @@ -153,6 +154,12 @@ program.hook('preAction', async (_thisCommand, actionCommand) => { // (Claude Code, Cursor, scripts, CI, humans) automatically. program.hook('preAction', guardHook); +// TOON output format: set module-level flag before any action runs +program.hook('preAction', (thisCommand: Command, actionCommand: Command) => { + const opts = actionCommand.optsWithGlobals() as { toon?: boolean }; + setToonMode(!!opts.toon); +}); + // Top-level commands registerLoginCommand(program); registerLogoutCommand(program); diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 64e8972..fade47a 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -126,7 +126,7 @@ export function getRootOpts(cmd: Command): { json: boolean; apiUrl?: string; yes } const opts = root.opts(); return { - json: opts.json ?? false, + json: opts.json ?? opts.toon ?? false, apiUrl: opts.apiUrl, yes: opts.yes ?? false, }; diff --git a/src/lib/output.test.ts b/src/lib/output.test.ts new file mode 100644 index 0000000..ee79600 --- /dev/null +++ b/src/lib/output.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect } from 'vitest'; +import { setToonMode, outputTable, outputSuccess, outputInfo, outputJson } from './output.js'; + +describe('toonEscapeValue (indirectly via outputTable)', () => { + it('quotes values containing structural chars (comma, colon, bracket, etc.)', () => { + // outputTable with toon mode writes TOON to stdout + // We capture console.log to test the output + const logs: string[] = []; + const origLog = console.log; + console.log = (msg: string) => logs.push(msg); + + try { + setToonMode(true); + outputTable(['Name'], [['Smith, John']]); + expect(logs[0]).toContain('"Smith, John"'); + } finally { + console.log = origLog; + setToonMode(false); + } + }); + + it('outputTable renders colon-containing values quoted', () => { + const logs: string[] = []; + const origLog = console.log; + console.log = (msg: string) => logs.push(msg); + + try { + setToonMode(true); + outputTable(['Key'], [['a:b']]); + expect(logs[0]).toContain('"a:b"'); + } finally { + console.log = origLog; + setToonMode(false); + } + }); + + it('does not quote simple alphanumeric values', () => { + const logs: string[] = []; + const origLog = console.log; + console.log = (msg: string) => logs.push(msg); + + try { + setToonMode(true); + outputTable(['Name'], [['Alice']]); + // Tabular TOON: header on first line, values on second line starting with 2 spaces + expect(logs[0]).toMatch(/\n Alice\n?$/); + } finally { + console.log = origLog; + setToonMode(false); + } + }); + + it('quotes empty string values', () => { + const logs: string[] = []; + const origLog = console.log; + console.log = (msg: string) => logs.push(msg); + + try { + setToonMode(true); + outputTable(['Name'], [['']]); + expect(logs[0]).toContain('""'); + } finally { + console.log = origLog; + setToonMode(false); + } + }); + + it('quotes boolean-looking values', () => { + const logs: string[] = []; + const origLog = console.log; + console.log = (msg: string) => logs.push(msg); + + try { + setToonMode(true); + outputTable(['Val'], [['true'], ['false']]); + expect(logs[0]).toContain('"true"'); + expect(logs[0]).toContain('"false"'); + } finally { + console.log = origLog; + setToonMode(false); + } + }); +}); + +describe('outputJson with TOON mode', () => { + it('single object output is single TOON document', () => { + const logs: string[] = []; + const origLog = console.log; + console.log = (msg: string) => logs.push(msg); + + try { + setToonMode(true); + outputJson({ name: 'Alice', role: 'admin' }); + expect(logs).toHaveLength(1); + expect(logs[0]).toContain('name:'); + expect(logs[0]).toContain('Alice'); + } finally { + console.log = origLog; + setToonMode(false); + } + }); + + it('null values are preserved in TOON output', () => { + const logs: string[] = []; + const origLog = console.log; + console.log = (msg: string) => logs.push(msg); + + try { + setToonMode(true); + outputJson({ name: 'Alice', deletedAt: null }); + expect(logs).toHaveLength(1); + expect(logs[0]).toContain('null'); + } finally { + console.log = origLog; + setToonMode(false); + } + }); + + it('outputs a single doc (not multiple) in TOON mode', () => { + const logs: string[] = []; + const origLog = console.log; + console.log = (msg: string) => logs.push(msg); + + try { + setToonMode(true); + outputJson({ users: [{ id: 1 }, { id: 2 }] }); + expect(logs).toHaveLength(1); + } finally { + console.log = origLog; + setToonMode(false); + } + }); +}); + +describe('outputSuccess/outputInfo in TOON mode', () => { + it('outputSuccess suppresses in TOON mode', () => { + const logs: string[] = []; + const origLog = console.log; + console.log = (msg: string) => logs.push(msg); + + try { + setToonMode(true); + outputSuccess('done'); + expect(logs).toHaveLength(0); + } finally { + console.log = origLog; + setToonMode(false); + } + }); + + it('outputInfo suppresses in TOON mode', () => { + const logs: string[] = []; + const origLog = console.log; + console.log = (msg: string) => logs.push(msg); + + try { + setToonMode(true); + outputInfo('some info'); + expect(logs).toHaveLength(0); + } finally { + console.log = origLog; + setToonMode(false); + } + }); + + it('outputSuccess works normally outside TOON mode', () => { + const logs: string[] = []; + const origLog = console.log; + console.log = (msg: string) => logs.push(msg); + + try { + outputSuccess('done'); + expect(logs).toHaveLength(1); + expect(logs[0]).toContain('done'); + } finally { + console.log = origLog; + } + }); +}); + +describe('outputTable renders correct TOON format', () => { + it('produces tabular TOON with headers and rows', () => { + const logs: string[] = []; + const origLog = console.log; + console.log = (msg: string) => logs.push(msg); + + try { + setToonMode(true); + outputTable(['Slug', 'Status'], [['my-func', 'active'], ['other-func', 'inactive']]); + expect(logs[0]).toMatch(/^\[2\]\{/); + expect(logs[0]).toContain('my-func'); + expect(logs[0]).toContain('active'); + } finally { + console.log = origLog; + setToonMode(false); + } + }); + + it('handles empty table showing header-only line', () => { + const logs: string[] = []; + const origLog = console.log; + console.log = (msg: string) => logs.push(msg); + + try { + setToonMode(true); + outputTable(['Name'], []); + expect(logs).toHaveLength(1); + expect(logs[0]).toBe('[0]{Name}:'); + } finally { + console.log = origLog; + setToonMode(false); + } + }); +}); diff --git a/src/lib/output.ts b/src/lib/output.ts index 0b3d44b..9a3b9ea 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -1,10 +1,28 @@ import Table from 'cli-table3'; +import { encode } from '@toon-format/toon'; + +// Module-level state set by preAction hook +let toonMode = false; + +export function setToonMode(enabled: boolean): void { + toonMode = enabled; +} + +// ── Public output functions ────────────────────────────────────────────── export function outputJson(data: unknown): void { + if (toonMode) { + console.log(encode(data)); + return; + } console.log(JSON.stringify(data, null, 2)); } export function outputTable(headers: string[], rows: string[][]): void { + if (toonMode) { + outputToon(headers, rows); + return; + } const table = new Table({ head: headers, style: { head: ['cyan'] }, @@ -16,9 +34,61 @@ export function outputTable(headers: string[], rows: string[][]): void { } export function outputSuccess(message: string): void { + if (toonMode) return; console.log(`✓ ${message}`); } export function outputInfo(message: string): void { + if (toonMode) return; console.log(message); } + +// ── TOON output implementations ────────────────────────────────────────── + +function toonEscapeKey(k: string): string { + if (/^[A-Za-z_][A-Za-z0-9_.]*$/.test(k)) return k; + return '"' + k + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') + '"'; +} + +function toonEscapeValue(v: unknown): string { + if (v === null || v === undefined || (typeof v === 'number' && !isFinite(v))) return 'null'; + if (typeof v === 'boolean') return v ? 'true' : 'false'; + if (typeof v === 'number') return String(v); + const s = String(v); + const needsQuoting = + s === '' || + s === 'true' || s === 'false' || s === 'null' || + /^[+-]?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?$/.test(s) || + /^[ \t]|[ \t]$/.test(s) || + /^[-#]/.test(s) || + /[\]:,"\\{}[]/.test(s) || + // eslint-disable-next-line no-control-regex + /[\x00-\x1f]/.test(s); + if (!needsQuoting) return s; + return '"' + s + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') + // eslint-disable-next-line no-control-regex + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0')) + '"'; +} + +function outputToon(headers: string[], rows: string[][]): void { + if (headers && rows && rows.length > 0) { + const keyStr = headers.map(toonEscapeKey).join(','); + let out = `[${rows.length}]{${keyStr}}:`; + for (const row of rows) { + out += '\n ' + row.map(toonEscapeValue).join(','); + } + console.log(out); + } else if (headers && headers.length > 0) { + console.log(`[0]{${headers.map(toonEscapeKey).join(',')}}:`); + } +}