diff --git a/package.json b/package.json index d17dbd471f..46e02c26c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fission-ai/openspec", - "version": "1.7.0", + "version": "1.8.0", "description": "AI-native system for spec-driven development", "keywords": [ "openspec", @@ -76,6 +76,7 @@ "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/prompts": "^7.10.1", + "@toon-format/toon": "^4.1.0", "chalk": "^5.6.2", "commander": "^14.0.0", "cross-spawn": "7.0.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49f86e2c3f..5c180b2656 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@inquirer/prompts': specifier: ^7.10.1 version: 7.10.1(@types/node@20.19.43) + '@toon-format/toon': + specifier: ^4.1.0 + version: 4.1.0 chalk: specifier: ^5.6.2 version: 5.6.2 @@ -625,6 +628,9 @@ packages: cpu: [x64] os: [win32] + '@toon-format/toon@4.1.0': + resolution: {integrity: sha512-dBB3pkEx9QYvHnHR6rtkaBAh+7x4W/oA5ONur4G0fh7Ow69PbPuM7OFxzNRABqyxC0t6SZ3RixiGbCuaFjPDAQ==} + '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} @@ -2122,6 +2128,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@toon-format/toon@4.1.0': {} + '@types/chai@5.2.2': dependencies: '@types/deep-eql': 4.0.2 diff --git a/src/cli/index.ts b/src/cli/index.ts index 902c46d0a1..c40ca95c90 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,4 +1,5 @@ import { asStatus } from '../commands/shared-output.js'; +import { formatAgentOutput, resolveOutputFormat, normalizeOptions, type OutputFormat } from '../core/format-output.js'; import { Command, Option } from 'commander'; import { createRequire } from 'module'; import ora from 'ora'; @@ -65,18 +66,19 @@ function hiddenStorePathOption(): Option { ).hideHelp(); } + function failWithError( error: unknown, - json?: { enabled: boolean | undefined; payload?: Record; fallbackCode?: string } + formatOptions?: { format?: import('../core/format-output.js').OutputFormat | boolean | undefined; payload?: Record; fallbackCode?: string } ): void { // The agent contract: every --json failure leaves exactly one JSON // document on stdout (the command's null-shape plus a status array). - if (json?.enabled) { + if (formatOptions?.format) { + const format = typeof formatOptions.format === 'string' ? formatOptions.format : 'json-pretty'; console.log( - JSON.stringify( - { ...(json.payload ?? {}), status: [asStatus(error, json.fallbackCode ?? 'command_error')] }, - null, - 2 + formatAgentOutput( + { ...(formatOptions.payload ?? {}), status: [asStatus(error, formatOptions.fallbackCode ?? 'command_error')] }, + format ) ); process.exitCode = 1; @@ -180,12 +182,26 @@ program } } + let agentOutputFormat: 'json' | 'toon' | undefined = undefined; + const { isInteractive } = await import('../utils/interactive.js'); + if (options?.tools === undefined && isInteractive({ interactive: (options as any)?.interactive })) { + const { select } = await import('@inquirer/prompts'); + agentOutputFormat = await select({ + message: 'Default AI output format:', + choices: [ + { name: 'JSON (minified for token efficiency)', value: 'json' }, + { name: 'TOON (extreme token optimization, requires TOON-aware agent)', value: 'toon' }, + ], + }); + } + const { InitCommand } = await import('../core/init.js'); const initCommand = new InitCommand({ tools: options?.tools, force: options?.force, profile: options?.profile, animation: options?.animation, + agentOutputFormat, }); await initCommand.execute(targetPath); } catch (error) { @@ -287,12 +303,15 @@ program .option('--changes', 'List changes explicitly (default)') .option('--sort ', 'Sort order: "recent" (default) or "name"', 'recent') .option('--json', 'Output as JSON (for programmatic use)') + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') .option('--store ', STORE_OPTION_DESCRIPTION) .addOption(hiddenStorePathOption()) - .action(async (options?: { specs?: boolean; changes?: boolean; sort?: string; json?: boolean; store?: string; storePath?: string }) => { + .action(async (options?: { specs?: boolean; changes?: boolean; sort?: string; json?: boolean; store?: string; storePath?: string; jsonPretty?: boolean; toon?: boolean }) => { try { + const format = resolveOutputFormat(options); const root = await resolveRootForCommand(options ?? {}, { - json: options?.json, + json: format !== undefined, failurePayload: options?.specs ? { specs: [], root: null } : { changes: [], root: null }, }); if (!root) { @@ -303,12 +322,13 @@ program const sort = options?.sort === 'name' ? 'name' : 'recent'; await listCommand.execute(root.path, mode, { sort, + format, json: options?.json, - ...(options?.json ? { root: toRootOutput(root) } : {}), + ...(format !== undefined ? { root: toRootOutput(root) } : {}), }); } catch (error) { failWithError(error, { - enabled: options?.json, + format: resolveOutputFormat(options) ?? options?.json, payload: options?.specs ? { specs: [], root: null } : { changes: [], root: null }, fallbackCode: 'list_error', }); @@ -352,13 +372,15 @@ changeCmd .command('show [change-name]') .description('Show a change proposal in JSON or markdown format') .option('--json', 'Output as JSON') + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') .option('--deltas-only', 'Show only deltas (JSON only)') .option('--requirements-only', 'Alias for --deltas-only (deprecated)') .option('--no-interactive', 'Disable interactive prompts') - .action(async (changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean; noInteractive?: boolean }) => { + .action(async (changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean; noInteractive?: boolean; jsonPretty?: boolean; toon?: boolean }) => { try { const changeCommand = new ChangeCommand(); - await changeCommand.show(changeName, options); + await changeCommand.show(changeName, normalizeOptions(options)); } catch (error) { console.error(`Error: ${(error as Error).message}`); process.exitCode = 1; @@ -369,12 +391,14 @@ changeCmd .command('list') .description('List all active changes (DEPRECATED: use "openspec list" instead)') .option('--json', 'Output as JSON') + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') .option('--long', 'Show id and title with counts') - .action(async (options?: { json?: boolean; long?: boolean }) => { + .action(async (options?: { json?: boolean; long?: boolean; jsonPretty?: boolean; toon?: boolean }) => { try { console.error('Warning: "openspec change list" is deprecated. Use "openspec list".'); const changeCommand = new ChangeCommand(); - await changeCommand.list(options); + await changeCommand.list(normalizeOptions(options)); } catch (error) { console.error(`Error: ${(error as Error).message}`); process.exitCode = 1; @@ -386,11 +410,13 @@ changeCmd .description('Validate a change proposal') .option('--strict', 'Enable strict validation mode') .option('--json', 'Output validation report as JSON') + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') .option('--no-interactive', 'Disable interactive prompts') - .action(async (changeName?: string, options?: { strict?: boolean; json?: boolean; noInteractive?: boolean }) => { + .action(async (changeName?: string, options?: { strict?: boolean; json?: boolean; noInteractive?: boolean; jsonPretty?: boolean; toon?: boolean }) => { try { const changeCommand = new ChangeCommand(); - await changeCommand.validate(changeName, options); + await changeCommand.validate(changeName, normalizeOptions(options)); if (typeof process.exitCode === 'number' && process.exitCode !== 0) { process.exit(process.exitCode); } @@ -407,12 +433,14 @@ program .option('--skip-specs', 'Skip spec update operations (useful for infrastructure, tooling, or doc-only changes)') .option('--no-validate', 'Skip validation (not recommended, requires confirmation)') .option('--json', 'Output as JSON (non-interactive)') + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') .option('--store ', STORE_OPTION_DESCRIPTION) .addOption(hiddenStorePathOption()) - .action(async (changeName?: string, options?: ArchiveOptions) => { + .action(async (changeName?: string, options?: ArchiveOptions & { jsonPretty?: boolean; toon?: boolean }) => { try { const archiveCommand = new ArchiveCommand(); - await archiveCommand.execute(changeName, options); + await archiveCommand.execute(changeName, normalizeOptions(options)); } catch (error) { failWithError(error); process.exit(1); @@ -437,16 +465,18 @@ program .option('--type ', 'Specify item type when ambiguous: change|spec') .option('--strict', 'Enable strict validation mode') .option('--json', 'Output validation results as JSON') + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') .option('--concurrency ', 'Max concurrent validations (defaults to env OPENSPEC_CONCURRENCY or 6)') .option('--no-interactive', 'Disable interactive prompts') .option('--store ', STORE_OPTION_DESCRIPTION) .addOption(hiddenStorePathOption()) - .action(async (itemName?: string, options?: { all?: boolean; changes?: boolean; specs?: boolean; type?: string; strict?: boolean; json?: boolean; noInteractive?: boolean; concurrency?: string; store?: string; storePath?: string }) => { + .action(async (itemName?: string, options?: { all?: boolean; changes?: boolean; specs?: boolean; type?: string; strict?: boolean; json?: boolean; noInteractive?: boolean; concurrency?: string; store?: string; storePath?: string; jsonPretty?: boolean; toon?: boolean }) => { try { const validateCommand = new ValidateCommand(); - await validateCommand.execute(itemName, options); + await validateCommand.execute(itemName, normalizeOptions(options)); } catch (error) { - failWithError(error, { enabled: options?.json, fallbackCode: 'validate_error' }); + failWithError(error, { format: resolveOutputFormat(options) ?? options?.json, fallbackCode: 'validate_error' }); process.exit(1); } }); @@ -456,6 +486,8 @@ program .command('show [item-name]') .description('Show a change or spec') .option('--json', 'Output as JSON') + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') .option('--type ', 'Specify item type when ambiguous: change|spec') .option('--no-interactive', 'Disable interactive prompts') // change-only flags @@ -471,12 +503,12 @@ program .addOption(hiddenStorePathOption()) // allow unknown options to pass-through to underlying command implementation .allowUnknownOption(true) - .action(async (itemName?: string, options?: { json?: boolean; type?: string; noInteractive?: boolean; [k: string]: any }) => { + .action(async (itemName?: string, options?: { json?: boolean; type?: string; noInteractive?: boolean; jsonPretty?: boolean; toon?: boolean; [k: string]: any }) => { try { const showCommand = new ShowCommand(); - await showCommand.execute(itemName, options ?? {}); + await showCommand.execute(itemName, normalizeOptions(options)); } catch (error) { - failWithError(error, { enabled: options?.json, fallbackCode: 'show_error' }); + failWithError(error, { format: resolveOutputFormat(options) ?? options?.json, fallbackCode: 'show_error' }); process.exit(1); } }); @@ -567,13 +599,15 @@ program .option('--change ', 'Change name to show status for') .option('--schema ', 'Schema override (auto-detected from config.yaml)') .option('--json', 'Output as JSON') + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') .option('--store ', STORE_OPTION_DESCRIPTION) .addOption(hiddenStorePathOption()) - .action(async (options: StatusOptions) => { + .action(async (options: StatusOptions & { jsonPretty?: boolean; toon?: boolean }) => { try { - await statusCommand(options); + await statusCommand(normalizeOptions(options)); } catch (error) { - failWithError(error, { enabled: options.json, fallbackCode: 'change_error' }); + failWithError(error, { format: resolveOutputFormat(options) ?? options.json, fallbackCode: 'change_error' }); process.exit(1); } }); @@ -585,20 +619,22 @@ program .option('--change ', 'Change name') .option('--schema ', 'Schema override (auto-detected from config.yaml)') .option('--json', 'Output as JSON') + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') .option('--store ', STORE_OPTION_DESCRIPTION) .addOption(hiddenStorePathOption()) - .action(async (artifactId: string | undefined, options: InstructionsOptions) => { + .action(async (artifactId: string | undefined, options: InstructionsOptions & { jsonPretty?: boolean; toon?: boolean }) => { try { // Workflow instruction surfaces are reserved command branches, not artifacts. if (artifactId === 'apply') { - await applyInstructionsCommand(options); + await applyInstructionsCommand(normalizeOptions(options)); } else if (artifactId === 'archive') { - await archiveInstructionsCommand(options); + await archiveInstructionsCommand(normalizeOptions(options)); } else { - await instructionsCommand(artifactId, options); + await instructionsCommand(artifactId, normalizeOptions(options)); } } catch (error) { - failWithError(error, { enabled: options.json, fallbackCode: 'change_error' }); + failWithError(error, { format: resolveOutputFormat(options) ?? options.json, fallbackCode: 'workflow_error' }); process.exit(1); } }); @@ -609,11 +645,13 @@ program .description('Show resolved template paths for all artifacts in a schema') .option('--schema ', `Schema to use (default: ${DEFAULT_SCHEMA})`) .option('--json', 'Output as JSON mapping artifact IDs to template paths') - .action(async (options: TemplatesOptions) => { + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') + .action(async (options: TemplatesOptions & { jsonPretty?: boolean; toon?: boolean }) => { try { - await templatesCommand(options); + await templatesCommand(normalizeOptions(options)); } catch (error) { - failWithError(error); + failWithError(error, { format: resolveOutputFormat(options) ?? options.json, fallbackCode: 'workflow_error' }); process.exit(1); } }); @@ -623,11 +661,13 @@ program .command('schemas') .description('List available workflow schemas with descriptions') .option('--json', 'Output as JSON (for agent use)') - .action(async (options: SchemasOptions) => { + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') + .action(async (options: SchemasOptions & { jsonPretty?: boolean; toon?: boolean }) => { try { - await schemasCommand(options); + await schemasCommand(normalizeOptions(options)); } catch (error) { - failWithError(error); + failWithError(error, { format: resolveOutputFormat(options) ?? options.json, fallbackCode: 'workflow_error' }); process.exit(1); } }); @@ -642,17 +682,19 @@ newCmd .option('--goal ', 'Optional goal metadata to store with the change') .option('--schema ', `Workflow schema to use (default: ${DEFAULT_SCHEMA})`) .option('--json', 'Output as JSON') + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') .option('--store ', STORE_OPTION_DESCRIPTION) .addOption(hiddenStorePathOption()) // Removed options kept registered (hidden) so users get a deliberate // explanation instead of a generic unknown-option error. .addOption(new Option('--initiative ', 'No longer supported').hideHelp()) .addOption(new Option('--areas ', 'No longer supported').hideHelp()) - .action(async (name: string, options: NewChangeOptions) => { + .action(async (name: string, options: NewChangeOptions & { jsonPretty?: boolean; toon?: boolean }) => { try { - await newChangeCommand(name, options); + await newChangeCommand(name, normalizeOptions(options)); } catch (error) { - failWithError(error); + failWithError(error, { format: resolveOutputFormat(options) ?? options.json, fallbackCode: 'workflow_error' }); process.exit(1); } }); diff --git a/src/commands/change.ts b/src/commands/change.ts index 23a23de5e5..8255619931 100644 --- a/src/commands/change.ts +++ b/src/commands/change.ts @@ -1,3 +1,5 @@ +import { printJson } from './shared-output.js'; +import type { OutputFormat } from '../core/format-output.js'; import { promises as fs } from 'fs'; import path from 'path'; import { JsonConverter } from '../core/converters/json-converter.js'; @@ -52,7 +54,7 @@ export class ChangeCommand { * - JSON mode: minimal object with deltas; --deltas-only returns same object with filtered deltas * Note: --requirements-only is deprecated alias for --deltas-only */ - async show(changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean; noInteractive?: boolean; rootOutput?: RootOutput }): Promise { + async show(changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean; noInteractive?: boolean; rootOutput?: RootOutput; format?: OutputFormat }): Promise { const changesPath = this.getChangesPath(); if (!changeName) { @@ -127,7 +129,7 @@ export class ChangeCommand { deltas, ...(options.rootOutput ? { root: options.rootOutput } : {}), }; - console.log(JSON.stringify(output, null, 2)); + printJson(output, options?.format ?? (options?.json ? 'json' : 'json-pretty')); } else { const content = await fs.readFile(proposalPath, 'utf-8'); console.log(content); @@ -139,7 +141,7 @@ export class ChangeCommand { * - Text default: IDs only; --long prints minimal details (title, counts) * - JSON: array of { id, title, deltaCount, taskStatus }, sorted by id */ - async list(options?: { json?: boolean; long?: boolean }): Promise { + async list(options?: { json?: boolean; long?: boolean; format?: OutputFormat }): Promise { const changesPath = path.join(process.cwd(), 'openspec', 'changes'); // Same directory-based resolution as `openspec list`, the command this @@ -185,7 +187,7 @@ export class ChangeCommand { ); const sorted = changeDetails.sort((a, b) => a.id.localeCompare(b.id)); - console.log(JSON.stringify(sorted, null, 2)); + printJson(sorted, options?.format ?? (options?.json ? 'json' : 'json-pretty')); } else { if (changes.length === 0) { console.log('No items found'); @@ -222,7 +224,7 @@ export class ChangeCommand { } } - async validate(changeName?: string, options?: { strict?: boolean; json?: boolean; noInteractive?: boolean }): Promise { + async validate(changeName?: string, options?: { strict?: boolean; json?: boolean; noInteractive?: boolean; format?: OutputFormat }): Promise { const changesPath = path.join(process.cwd(), 'openspec', 'changes'); if (!changeName) { @@ -263,7 +265,7 @@ export class ChangeCommand { }); if (options?.json) { - console.log(JSON.stringify(report, null, 2)); + printJson(report, options?.format ?? (options?.json ? 'json' : 'json-pretty')); } else { if (report.valid) { console.log(`Change "${changeName}" is valid`); diff --git a/src/commands/config.ts b/src/commands/config.ts index e594583e7f..23d7b6610e 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -1,3 +1,5 @@ +import type { OutputFormat } from '../core/format-output.js'; +import { printJson } from './shared-output.js'; import { Command } from 'commander'; import { spawn } from 'node:child_process'; import * as fs from 'node:fs'; @@ -233,11 +235,13 @@ export function registerConfigCommand(program: Command): void { .command('list') .description('Show all current settings') .option('--json', 'Output as JSON') - .action((options: { json?: boolean }) => { + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') + .action((options: { json?: boolean; format?: OutputFormat }) => { const config = getGlobalConfig(); if (options.json) { - console.log(JSON.stringify(config, null, 2)); + printJson(config, options?.format ?? (options?.json ? 'json' : 'json-pretty')); } else { // Read raw config to determine which values are explicit vs defaults const configPath = getGlobalConfigPath(); @@ -282,7 +286,7 @@ export function registerConfigCommand(program: Command): void { } if (typeof value === 'object' && value !== null) { - console.log(JSON.stringify(value)); + printJson(value, 'json-pretty'); } else { console.log(String(value)); } @@ -294,7 +298,7 @@ export function registerConfigCommand(program: Command): void { .description('Set a value (auto-coerce types)') .option('--string', 'Force value to be stored as string') .option('--allow-unknown', 'Allow setting unknown keys') - .action((key: string, value: string, options: { string?: boolean; allowUnknown?: boolean }) => { + .action((key: string, value: string, options: { string?: boolean; allowUnknown?: boolean; format?: OutputFormat }) => { const allowUnknown = Boolean(options.allowUnknown); const keyValidation = validateConfigKeyPath(key); // --allow-unknown relaxes the known-key check, but never the prototype-safety check. diff --git a/src/commands/context.ts b/src/commands/context.ts index 1a4b4a8312..1c468e29de 100644 --- a/src/commands/context.ts +++ b/src/commands/context.ts @@ -25,6 +25,7 @@ import { StoreError } from '../core/store/errors.js'; import { COMMAND_REGISTRY } from '../core/completions/command-registry.js'; import { COMMON_FLAGS } from '../core/completions/shared-flags.js'; import { emitFailure, printJson } from './shared-output.js'; +import { normalizeOptions, type OutputFormat } from '../core/format-output.js'; import { gatherRelationshipData } from './shared-gather.js'; const FAILURE_PAYLOAD = { root: null, members: [] }; @@ -170,6 +171,8 @@ export function registerContextCommand(program: Command): void { new Option('--store-path ', 'Removed; register the store and use --store').hideHelp() ) .option('--json', 'Output the agent brief as JSON') + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') .option('--code-workspace ', 'Also write a VS Code workspace file for the set') .option('--force', 'Overwrite an existing --code-workspace file') .action( @@ -179,7 +182,9 @@ export function registerContextCommand(program: Command): void { json?: boolean; codeWorkspace?: string; force?: boolean; + format?: OutputFormat; }) => { + options = normalizeOptions(options) as any; try { const root = await resolveRootForCommand( { store: options.store, storePath: options.storePath }, @@ -197,7 +202,7 @@ export function registerContextCommand(program: Command): void { if (options.codeWorkspace) { writeCodeWorkspace(workingSet, options.codeWorkspace, options.force === true); } - printJson(workingSet); + printJson(workingSet, options.format); } else { printHumanWorkingSet(workingSet, declaredReferenceCount); if (options.codeWorkspace) { @@ -205,7 +210,7 @@ export function registerContextCommand(program: Command): void { } } } catch (error) { - emitFailure(options.json, FAILURE_PAYLOAD, error, 'context_failed'); + emitFailure(options.format ?? options.json, FAILURE_PAYLOAD, error, 'context_failed'); } } ); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 94c5c32a82..ccffae156d 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -26,6 +26,7 @@ import { import { COMMAND_REGISTRY } from '../core/completions/command-registry.js'; import { COMMON_FLAGS } from '../core/completions/shared-flags.js'; import { emitFailure, printJson } from './shared-output.js'; +import { normalizeOptions, type OutputFormat } from '../core/format-output.js'; import * as path from 'node:path'; const FAILURE_PAYLOAD = { root: null, store: null, references: [] }; @@ -195,7 +196,10 @@ export function registerDoctorCommand(program: Command): void { new Option('--store-path ', 'Removed; register the store and use --store').hideHelp() ) .option('--json', 'Output as JSON') - .action(async (options: { store?: string; storePath?: string; json?: boolean }) => { + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') + .action(async (options: { store?: string; storePath?: string; json?: boolean; format?: OutputFormat; jsonPretty?: boolean; toon?: boolean; }) => { + options = normalizeOptions(options) as any; try { const root = await resolveRootForCommand( { store: options.store, storePath: options.storePath }, @@ -213,7 +217,7 @@ export function registerDoctorCommand(program: Command): void { } printHumanHealth(health, declaredReferenceCount); } catch (error) { - emitFailure(options.json, FAILURE_PAYLOAD, error, 'doctor_failed'); + emitFailure(options.format ?? options.json, FAILURE_PAYLOAD, error, 'doctor_failed'); } }); } diff --git a/src/commands/schema.ts b/src/commands/schema.ts index 5c01570beb..7ce3a4febb 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -1,3 +1,5 @@ +import type { OutputFormat } from '../core/format-output.js'; +import { printJson } from './shared-output.js'; import { Command } from 'commander'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -303,8 +305,10 @@ export function registerSchemaCommand(program: Command): void { .command('which [name]') .description('Show where a schema resolves from') .option('--json', 'Output as JSON') + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') .option('--all', 'List all schemas with their resolution sources') - .action(async (name?: string, options?: { json?: boolean; all?: boolean }) => { + .action(async (name?: string, options?: { json?: boolean; all?: boolean; format?: OutputFormat }) => { try { const projectRoot = process.cwd(); @@ -313,7 +317,7 @@ export function registerSchemaCommand(program: Command): void { const schemas = getAllSchemasWithResolution(projectRoot); if (options?.json) { - console.log(JSON.stringify(schemas, null, 2)); + printJson(schemas, options?.format ?? (options?.json ? 'json' : 'json-pretty')); } else { if (schemas.length === 0) { console.log('No schemas found.'); @@ -368,10 +372,10 @@ export function registerSchemaCommand(program: Command): void { if (!resolution) { const available = listSchemas(projectRoot); if (options?.json) { - console.log(JSON.stringify({ + printJson({ error: `Schema '${name}' not found`, available, - }, null, 2)); + }, options?.format ?? (options?.json ? 'json' : 'json-pretty')); } else { console.error(`Error: Schema '${name}' not found`); console.error(`Available schemas: ${available.join(', ')}`); @@ -381,7 +385,7 @@ export function registerSchemaCommand(program: Command): void { } if (options?.json) { - console.log(JSON.stringify(resolution, null, 2)); + printJson(resolution, options?.format ?? (options?.json ? 'json' : 'json-pretty')); } else { console.log(`Schema: ${resolution.name}`); console.log(`Source: ${resolution.source}`); @@ -405,8 +409,10 @@ export function registerSchemaCommand(program: Command): void { .command('validate [name]') .description('Validate a schema structure and templates') .option('--json', 'Output as JSON') + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') .option('--verbose', 'Show detailed validation steps') - .action(async (name?: string, options?: { json?: boolean; verbose?: boolean }) => { + .action(async (name?: string, options?: { json?: boolean; verbose?: boolean; format?: OutputFormat }) => { try { const projectRoot = process.cwd(); @@ -416,11 +422,11 @@ export function registerSchemaCommand(program: Command): void { if (!fs.existsSync(projectSchemasDir)) { if (options?.json) { - console.log(JSON.stringify({ + printJson({ valid: true, message: 'No project schemas directory found', schemas: [], - }, null, 2)); + }, options?.format ?? (options?.json ? 'json' : 'json-pretty')); } else { console.log('No project schemas directory found.'); } @@ -463,10 +469,10 @@ export function registerSchemaCommand(program: Command): void { } if (options?.json) { - console.log(JSON.stringify({ + printJson({ valid: !anyInvalid, schemas: schemaResults, - }, null, 2)); + }, options?.format ?? (options?.json ? 'json' : 'json-pretty')); } else { if (schemaResults.length === 0) { console.log('No schemas found in project.'); @@ -495,11 +501,11 @@ export function registerSchemaCommand(program: Command): void { if (!schemaDir) { const available = listSchemas(projectRoot); if (options?.json) { - console.log(JSON.stringify({ + printJson({ valid: false, error: `Schema '${name}' not found`, available, - }, null, 2)); + }, options?.format ?? (options?.json ? 'json' : 'json-pretty')); } else { console.error(`Error: Schema '${name}' not found`); console.error(`Available schemas: ${available.join(', ')}`); @@ -515,12 +521,12 @@ export function registerSchemaCommand(program: Command): void { const result = validateSchema(schemaDir, options?.verbose && !options?.json); if (options?.json) { - console.log(JSON.stringify({ + printJson({ name, path: schemaDir, valid: result.valid, issues: result.issues, - }, null, 2)); + }, options?.format ?? (options?.json ? 'json' : 'json-pretty')); } else { if (result.valid) { console.log(`✓ Schema '${name}' is valid`); @@ -534,10 +540,10 @@ export function registerSchemaCommand(program: Command): void { } } catch (error) { if (options?.json) { - console.log(JSON.stringify({ + printJson({ valid: false, error: (error as Error).message, - }, null, 2)); + }, options?.format ?? (options?.json ? 'json' : 'json-pretty')); } else { console.error(`Error: ${(error as Error).message}`); } @@ -550,8 +556,10 @@ export function registerSchemaCommand(program: Command): void { .command('fork [name]') .description('Copy an existing schema to project for customization') .option('--json', 'Output as JSON') + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') .option('--force', 'Overwrite existing destination') - .action(async (source: string, name?: string, options?: { json?: boolean; force?: boolean }) => { + .action(async (source: string, name?: string, options?: { json?: boolean; force?: boolean; format?: OutputFormat }) => { const spinner = options?.json ? null : ora(); try { @@ -561,10 +569,10 @@ export function registerSchemaCommand(program: Command): void { // Validate destination name if (!isValidSchemaName(destinationName)) { if (options?.json) { - console.log(JSON.stringify({ + printJson({ forked: false, error: `Invalid schema name '${destinationName}'. Use kebab-case (e.g., my-workflow)`, - }, null, 2)); + }, options?.format ?? (options?.json ? 'json' : 'json-pretty')); } else { console.error(`Error: Invalid schema name '${destinationName}'`); console.error('Schema names must be kebab-case (e.g., my-workflow)'); @@ -578,11 +586,11 @@ export function registerSchemaCommand(program: Command): void { if (!sourceDir) { const available = listSchemas(projectRoot); if (options?.json) { - console.log(JSON.stringify({ + printJson({ forked: false, error: `Schema '${source}' not found`, available, - }, null, 2)); + }, options?.format ?? (options?.json ? 'json' : 'json-pretty')); } else { console.error(`Error: Schema '${source}' not found`); console.error(`Available schemas: ${available.join(', ')}`); @@ -601,11 +609,11 @@ export function registerSchemaCommand(program: Command): void { if (fs.existsSync(destinationDir)) { if (!options?.force) { if (options?.json) { - console.log(JSON.stringify({ + printJson({ forked: false, error: `Schema '${destinationName}' already exists`, suggestion: 'Use --force to overwrite', - }, null, 2)); + }, options?.format ?? (options?.json ? 'json' : 'json-pretty')); } else { console.error(`Error: Schema '${destinationName}' already exists at ${destinationDir}`); console.error('Use --force to overwrite'); @@ -634,14 +642,14 @@ export function registerSchemaCommand(program: Command): void { if (spinner) spinner.succeed(`Forked '${source}' to '${destinationName}'`); if (options?.json) { - console.log(JSON.stringify({ + printJson({ forked: true, source, sourcePath: sourceDir, sourceLocation, destination: destinationName, destinationPath: destinationDir, - }, null, 2)); + }, options?.format ?? (options?.json ? 'json' : 'json-pretty')); } else { console.log(`\nSource: ${sourceDir} (${sourceLocation})`); console.log(`Destination: ${destinationDir}`); @@ -651,10 +659,10 @@ export function registerSchemaCommand(program: Command): void { } catch (error) { if (spinner) spinner.fail(`Fork failed`); if (options?.json) { - console.log(JSON.stringify({ + printJson({ forked: false, error: (error as Error).message, - }, null, 2)); + }, options?.format ?? (options?.json ? 'json' : 'json-pretty')); } else { console.error(`Error: ${(error as Error).message}`); } @@ -667,6 +675,8 @@ export function registerSchemaCommand(program: Command): void { .command('init ') .description('Create a new project-local schema') .option('--json', 'Output as JSON') + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') .option('--description ', 'Schema description') .option('--artifacts ', 'Comma-separated artifact IDs (proposal,specs,design,tasks)') .option('--default', 'Set as project default schema') @@ -680,6 +690,7 @@ export function registerSchemaCommand(program: Command): void { artifacts?: string; default?: boolean; force?: boolean; + format?: OutputFormat; } ) => { const spinner = options?.json ? null : ora(); @@ -690,10 +701,10 @@ export function registerSchemaCommand(program: Command): void { // Validate name if (!isValidSchemaName(name)) { if (options?.json) { - console.log(JSON.stringify({ + printJson({ created: false, error: `Invalid schema name '${name}'. Use kebab-case (e.g., my-workflow)`, - }, null, 2)); + }, options?.format ?? (options?.json ? 'json' : 'json-pretty')); } else { console.error(`Error: Invalid schema name '${name}'`); console.error('Schema names must be kebab-case (e.g., my-workflow)'); @@ -709,11 +720,11 @@ export function registerSchemaCommand(program: Command): void { if (schemaExists) { if (!options?.force) { if (options?.json) { - console.log(JSON.stringify({ + printJson({ created: false, error: `Schema '${name}' already exists`, suggestion: 'Use --force to overwrite or "openspec schema fork" to copy', - }, null, 2)); + }, options?.format ?? (options?.json ? 'json' : 'json-pretty')); } else { console.error(`Error: Schema '${name}' already exists at ${schemaDir}`); console.error('Use --force to overwrite or "openspec schema fork" to copy'); @@ -786,11 +797,11 @@ export function registerSchemaCommand(program: Command): void { for (const id of selectedArtifactIds) { if (!validIds.includes(id)) { if (options?.json) { - console.log(JSON.stringify({ + printJson({ created: false, error: `Unknown artifact '${id}'`, valid: validIds, - }, null, 2)); + }, options?.format ?? (options?.json ? 'json' : 'json-pretty')); } else { console.error(`Error: Unknown artifact '${id}'`); console.error(`Valid artifacts: ${validIds.join(', ')}`); @@ -900,13 +911,13 @@ export function registerSchemaCommand(program: Command): void { if (spinner) spinner.succeed(`Created schema '${name}'`); if (options?.json) { - console.log(JSON.stringify({ + printJson({ created: true, path: schemaDir, schema: name, artifacts: selectedArtifactIds, setAsDefault: options?.default || false, - }, null, 2)); + }, options?.format ?? (options?.json ? 'json' : 'json-pretty')); } else { console.log(`\nSchema created at: ${schemaDir}`); console.log(`\nArtifacts: ${selectedArtifactIds.join(', ')}`); @@ -921,10 +932,10 @@ export function registerSchemaCommand(program: Command): void { } catch (error) { if (spinner) spinner.fail(`Creation failed`); if (options?.json) { - console.log(JSON.stringify({ + printJson({ created: false, error: (error as Error).message, - }, null, 2)); + }, options?.format ?? (options?.json ? 'json' : 'json-pretty')); } else { console.error(`Error: ${(error as Error).message}`); } diff --git a/src/commands/shared-output.ts b/src/commands/shared-output.ts index 56fbb1ea20..7e9976fa31 100644 --- a/src/commands/shared-output.ts +++ b/src/commands/shared-output.ts @@ -6,8 +6,10 @@ */ import { StoreError, type StoreDiagnostic } from '../core/store/errors.js'; -export function printJson(payload: unknown): void { - console.log(JSON.stringify(payload, null, 2)); +import { formatAgentOutput, type OutputFormat } from '../core/format-output.js'; + +export function printJson(payload: unknown, format: OutputFormat = 'json-pretty'): void { + console.log(formatAgentOutput(payload, format)); } export function asErrorMessage(error: unknown): string { @@ -45,23 +47,24 @@ export function asStatus(error: unknown, fallbackCode: string): StoreDiagnostic } export function emitFailure( - json: boolean | undefined, + formatOptions: OutputFormat | boolean | undefined, payload: Record, error: unknown, fallbackCode: string ): void { // Ctrl-C in a prompt is the user's choice, not an error: every // command group gets the Cancelled./130 convention through here. - if (!json && isPromptCancellationError(error)) { + if (!formatOptions && isPromptCancellationError(error)) { console.error('Cancelled.'); process.exitCode = 130; return; } const status = asStatus(error, fallbackCode); - if (json) { + if (formatOptions) { const prior = Array.isArray(payload.status) ? payload.status : []; - printJson({ ...payload, status: [...prior, status] }); + const format = typeof formatOptions === 'string' ? formatOptions : 'json-pretty'; + printJson({ ...payload, status: [...prior, status] }, format); process.exitCode = 1; return; } diff --git a/src/commands/spec.ts b/src/commands/spec.ts index 01501505f1..08ae461a98 100644 --- a/src/commands/spec.ts +++ b/src/commands/spec.ts @@ -1,3 +1,5 @@ +import type { OutputFormat } from '../core/format-output.js'; +import { printJson } from './shared-output.js'; import { program } from 'commander'; import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; @@ -19,6 +21,7 @@ interface ShowOptions { requirement?: string; // JSON only noInteractive?: boolean; rootOutput?: RootOutput; + format?: OutputFormat; } function parseSpecFromFile(specPath: string, specId: string): Spec { @@ -116,7 +119,7 @@ export class SpecCommand { metadata: parsed.metadata ?? { version: '1.0.0', format: 'openspec' as const }, ...(options.rootOutput ? { root: options.rootOutput } : {}), }; - console.log(JSON.stringify(output, null, 2)); + printJson(output, options?.format ?? (options?.json ? 'json' : 'json-pretty')); return; } printSpecTextRaw(specPath); @@ -137,6 +140,8 @@ export function registerSpecCommand(rootProgram: typeof program) { .command('show [spec-id]') .description('Display a specific specification') .option('--json', 'Output as JSON') + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') .option('--requirements', 'JSON only: Show only requirements (exclude scenarios)') .option('--no-scenarios', 'JSON only: Exclude scenario content') .option('-r, --requirement ', 'JSON only: Show specific requirement by ID (1-based)') @@ -155,8 +160,10 @@ export function registerSpecCommand(rootProgram: typeof program) { .command('list') .description('List all available specifications') .option('--json', 'Output as JSON') + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') .option('--long', 'Show id and title with counts') - .action(async (options: { json?: boolean; long?: boolean }) => { + .action(async (options: { json?: boolean; long?: boolean; format?: OutputFormat }) => { try { if (!existsSync(SPECS_DIR)) { console.log('No items found'); @@ -185,7 +192,7 @@ export function registerSpecCommand(rootProgram: typeof program) { .sort((a, b) => a.id.localeCompare(b.id)); if (options.json) { - console.log(JSON.stringify(specs, null, 2)); + printJson(specs, options?.format ?? (options?.json ? 'json' : 'json-pretty')); } else { if (specs.length === 0) { console.log('No items found'); @@ -210,8 +217,10 @@ export function registerSpecCommand(rootProgram: typeof program) { .description('Validate a specification structure') .option('--strict', 'Enable strict validation mode') .option('--json', 'Output validation report as JSON') + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') .option('--no-interactive', 'Disable interactive prompts') - .action(async (specId: string | undefined, options: { strict?: boolean; json?: boolean; noInteractive?: boolean }) => { + .action(async (specId: string | undefined, options: { strict?: boolean; json?: boolean; noInteractive?: boolean; format?: OutputFormat }) => { try { if (!specId) { const canPrompt = isInteractive(options); @@ -237,7 +246,7 @@ export function registerSpecCommand(rootProgram: typeof program) { const report = await validator.validateSpec(specPath); if (options.json) { - console.log(JSON.stringify(report, null, 2)); + printJson(report, options?.format ?? (options?.json ? 'json' : 'json-pretty')); } else { if (report.valid) { console.log(`Specification '${specId}' is valid`); diff --git a/src/commands/store.ts b/src/commands/store.ts index 1a91d89984..5ab0ec7668 100644 --- a/src/commands/store.ts +++ b/src/commands/store.ts @@ -1,5 +1,6 @@ import * as os from 'node:os'; import { asErrorMessage, emitFailure, printJson } from './shared-output.js'; +import { normalizeOptions, type OutputFormat } from '../core/format-output.js'; import * as path from 'node:path'; import { Command } from 'commander'; @@ -32,6 +33,9 @@ interface StoreSetupOptions { path?: string; initGit?: boolean; json?: boolean; + format?: OutputFormat; + jsonPretty?: boolean; + toon?: boolean; remote?: string; } @@ -39,15 +43,24 @@ interface StoreRegisterOptions { id?: string; yes?: boolean; json?: boolean; + format?: OutputFormat; + jsonPretty?: boolean; + toon?: boolean; } interface StoreRemoveOptions { yes?: boolean; json?: boolean; + format?: OutputFormat; + jsonPretty?: boolean; + toon?: boolean; } interface StoreJsonOptions { json?: boolean; + format?: OutputFormat; + jsonPretty?: boolean; + toon?: boolean; } interface ResolvedStoreSetupInput extends SetupStoreInput { @@ -526,7 +539,7 @@ class StoreCommand { const payload = toMutationOutput(result); if (options.json) { - printJson(payload); + printJson(payload, options.format); return; } @@ -565,7 +578,7 @@ class StoreCommand { const payload = toMutationOutput(result); if (options.json) { - printJson(payload); + printJson(payload, options.format); return; } @@ -584,7 +597,7 @@ class StoreCommand { const payload = toCleanupOutput(await unregisterStore({ id })); if (options.json) { - printJson(payload); + printJson(payload, options.format); return; } @@ -605,7 +618,7 @@ class StoreCommand { const payload = toCleanupOutput(await removeStore(target)); if (options.json) { - printJson(payload); + printJson(payload, options.format); return; } @@ -624,7 +637,7 @@ class StoreCommand { const payload = toListOutput(await listStores()); if (options.json) { - printJson(payload); + printJson(payload, options.format); return; } @@ -639,7 +652,7 @@ class StoreCommand { const payload = toDoctorOutput(await doctorStores(id)); if (options.json) { - printJson(payload); + printJson(payload, options.format); return; } @@ -675,7 +688,11 @@ export function registerStoreCommand(program: Command): void { .option('--no-init-git', 'Skip every Git action: no init, no initial commit') .option('--remote ', 'Canonical clone source recorded in store.yaml') .option('--json', 'Output as JSON') - .action(async (id: string | undefined, options: StoreSetupOptions) => { + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') + .action( + async (id: string | undefined, options: StoreSetupOptions) => { + options = normalizeOptions(options) as any; await storeCommand.setup(id, options); }); @@ -685,7 +702,11 @@ export function registerStoreCommand(program: Command): void { .option('--id ', 'Store id; defaults to metadata or folder name') .option('--yes', 'Confirm creating store identity metadata for a healthy OpenSpec root') .option('--json', 'Output as JSON') - .action(async (inputPath: string | undefined, options: StoreRegisterOptions) => { + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') + .action( + async (inputPath: string | undefined, options: StoreRegisterOptions) => { + options = normalizeOptions(options) as any; await storeCommand.register(inputPath, options); }); @@ -693,7 +714,11 @@ export function registerStoreCommand(program: Command): void { .command('unregister ') .description('Forget a local store registration without deleting files') .option('--json', 'Output as JSON') - .action(async (id: string, options: StoreJsonOptions) => { + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') + .action( + async (id: string, options: StoreJsonOptions) => { + options = normalizeOptions(options) as any; await storeCommand.unregister(id, options); }); @@ -702,7 +727,11 @@ export function registerStoreCommand(program: Command): void { .description('Forget a local store registration and delete its local folder') .option('--yes', 'Confirm local store folder deletion') .option('--json', 'Output as JSON') - .action(async (id: string, options: StoreRemoveOptions) => { + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') + .action( + async (id: string, options: StoreRemoveOptions) => { + options = normalizeOptions(options) as any; await storeCommand.remove(id, options); }); @@ -711,7 +740,11 @@ export function registerStoreCommand(program: Command): void { .alias('ls') .description('List locally registered stores') .option('--json', 'Output as JSON') - .action(async (options: StoreJsonOptions) => { + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') + .action( + async (options: StoreJsonOptions) => { + options = normalizeOptions(options) as any; await storeCommand.list(options); }); @@ -719,7 +752,11 @@ export function registerStoreCommand(program: Command): void { .command('doctor [id]') .description('Check local store registration and metadata') .option('--json', 'Output as JSON') - .action(async (id: string | undefined, options: StoreJsonOptions) => { + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') + .action( + async (id: string | undefined, options: StoreJsonOptions) => { + options = normalizeOptions(options) as any; await storeCommand.doctor(id, options); }); diff --git a/src/commands/validate.ts b/src/commands/validate.ts index 0e74722493..909fe13e59 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -1,3 +1,5 @@ +import { printJson } from './shared-output.js'; +import type { OutputFormat } from '../core/format-output.js'; import ora from 'ora'; import path from 'path'; import { Validator } from '../core/validation/validator.js'; @@ -28,6 +30,7 @@ interface ExecuteOptions { concurrency?: string; store?: string; storePath?: string; + format?: OutputFormat; } interface BulkItemResult { @@ -38,6 +41,14 @@ interface BulkItemResult { durationMs: number; } +interface ValidationOptions { + strict: boolean; + json: boolean; + concurrency?: string; + noInteractive?: boolean; + format?: OutputFormat; +} + export class ValidateCommand { async execute(itemName: string | undefined, options: ExecuteOptions = {}): Promise { const root = await resolveRootForCommand(options, { json: options.json }); @@ -52,14 +63,14 @@ export class ValidateCommand { await this.runBulkValidation(root, { changes: !!options.all || !!options.changes, specs: !!options.all || !!options.specs, - }, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency, noInteractive: resolveNoInteractive(options) }); + }, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency, noInteractive: resolveNoInteractive(options), format: options.format }); return; } // No item and no flags if (!itemName) { if (interactive) { - await this.runInteractiveSelector(root, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency }); + await this.runInteractiveSelector(root, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency, format: options.format }); return; } this.printNonInteractiveHint(root); @@ -69,7 +80,7 @@ export class ValidateCommand { // Direct item validation with type detection or override const typeOverride = this.normalizeType(options.type); - await this.validateDirectItem(root, itemName, { typeOverride, strict: !!options.strict, json: !!options.json }); + await this.validateDirectItem(root, itemName, { typeOverride, strict: !!options.strict, json: !!options.json, format: options.format }); } private normalizeType(value?: string): ItemType | undefined { @@ -91,7 +102,7 @@ export class ValidateCommand { return ids.sort(); } - private async runInteractiveSelector(root: ResolvedOpenSpecRoot, opts: { strict: boolean; json: boolean; concurrency?: string }): Promise { + private async runInteractiveSelector(root: ResolvedOpenSpecRoot, opts: ValidationOptions): Promise { const { select } = await import('@inquirer/prompts'); const choice = await select({ message: 'What would you like to validate?', @@ -130,7 +141,7 @@ export class ValidateCommand { console.error('Or run in an interactive terminal.'); } - private async validateDirectItem(root: ResolvedOpenSpecRoot, itemName: string, opts: { typeOverride?: ItemType; strict: boolean; json: boolean }): Promise { + private async validateDirectItem(root: ResolvedOpenSpecRoot, itemName: string, opts: ValidationOptions & { typeOverride?: ItemType }): Promise { const [changes, specs] = await Promise.all([this.listChangeIds(root), getSpecIds(root.path)]); const isChange = changes.includes(itemName); const isSpec = specs.includes(itemName); @@ -192,14 +203,14 @@ export class ValidateCommand { await this.validateByType(root, type, itemName, opts); } - private async validateByType(root: ResolvedOpenSpecRoot, type: ItemType, id: string, opts: { strict: boolean; json: boolean }): Promise { + private async validateByType(root: ResolvedOpenSpecRoot, type: ItemType, id: string, opts: ValidationOptions): Promise { const validator = new Validator(opts.strict); if (type === 'change') { const changeDir = path.join(root.changesDir, id); const start = Date.now(); const report = await validator.validateChangeDeltaSpecs(changeDir, { mainSpecsDir: root.specsDir }); const durationMs = Date.now() - start; - this.printReport('change', id, report, durationMs, opts.json, root); + this.printReport('change', id, report, durationMs, opts, root); // Non-zero exit if invalid (keeps enriched output test semantics) process.exitCode = report.valid ? 0 : 1; return; @@ -208,14 +219,14 @@ export class ValidateCommand { const start = Date.now(); const report = await validator.validateSpec(file); const durationMs = Date.now() - start; - this.printReport('spec', id, report, durationMs, opts.json, root); + this.printReport('spec', id, report, durationMs, opts, root); process.exitCode = report.valid ? 0 : 1; } - private printReport(type: ItemType, id: string, report: { valid: boolean; issues: any[] }, durationMs: number, json: boolean, root: ResolvedOpenSpecRoot): void { - if (json) { + private printReport(type: ItemType, id: string, report: { valid: boolean; issues: any[] }, durationMs: number, opts: ValidationOptions, root: ResolvedOpenSpecRoot): void { + if (opts.json) { const out = { items: [{ id, type, valid: report.valid, issues: report.issues, durationMs }], summary: { totals: { items: 1, passed: report.valid ? 1 : 0, failed: report.valid ? 0 : 1 }, byType: { [type]: { items: 1, passed: report.valid ? 1 : 0, failed: report.valid ? 0 : 1 } } }, version: '1.0', root: toRootOutput(root) }; - console.log(JSON.stringify(out, null, 2)); + printJson(out, opts.format ?? 'json-pretty'); return; } if (report.valid) { @@ -262,7 +273,7 @@ export class ValidateCommand { bullets.forEach(b => console.error(` ${b}`)); } - private async runBulkValidation(root: ResolvedOpenSpecRoot, scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string; noInteractive?: boolean }): Promise { + private async runBulkValidation(root: ResolvedOpenSpecRoot, scope: { changes: boolean; specs: boolean }, opts: ValidationOptions): Promise { const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined; const [changeIds, specIds] = await Promise.all([ scope.changes ? this.listChangeIds(root) : Promise.resolve([]), @@ -307,7 +318,7 @@ export class ValidateCommand { if (opts.json) { const out = { items: [] as BulkItemResult[], summary, version: '1.0', root: toRootOutput(root) }; - console.log(JSON.stringify(out, null, 2)); + printJson(out, opts.format ?? (opts.json ? 'json' : 'json-pretty')); } else { console.log('No items found to validate.'); } @@ -363,7 +374,7 @@ export class ValidateCommand { if (opts.json) { const out = { items: results, summary, version: '1.0', root: toRootOutput(root) }; - console.log(JSON.stringify(out, null, 2)); + printJson(out, opts.format ?? (opts.json ? 'json' : 'json-pretty')); } else { for (const res of results) { if (res.valid) console.log(`✓ ${res.type}/${res.id}`); diff --git a/src/commands/workflow/instructions.ts b/src/commands/workflow/instructions.ts index 6e20ec3e60..f28ff5f84b 100644 --- a/src/commands/workflow/instructions.ts +++ b/src/commands/workflow/instructions.ts @@ -1,3 +1,5 @@ +import { printJson } from '../shared-output.js'; +import type { OutputFormat } from '../../core/format-output.js'; /** * Instructions Command * @@ -58,6 +60,7 @@ export interface InstructionsOptions { store?: string; storePath?: string; json?: boolean; + format?: OutputFormat; } export interface ApplyInstructionsOptions { @@ -66,6 +69,7 @@ export interface ApplyInstructionsOptions { store?: string; storePath?: string; json?: boolean; + format?: OutputFormat; } export type ArchiveInstructionsOptions = ApplyInstructionsOptions; @@ -168,7 +172,7 @@ export async function instructionsCommand( spinner?.stop(); if (options.json) { - console.log(JSON.stringify({ ...instructions, root: toRootOutput(root) }, null, 2)); + printJson({ ...instructions, root: toRootOutput(root) }, options.format); return; } @@ -511,7 +515,7 @@ export async function applyInstructionsCommand(options: ApplyInstructionsOptions spinner?.stop(); if (options.json) { - console.log(JSON.stringify({ ...instructions, root: toRootOutput(root) }, null, 2)); + printJson({ ...instructions, root: toRootOutput(root) }, options.format); return; } @@ -617,7 +621,7 @@ export async function archiveInstructionsCommand( spinner?.stop(); if (options.json) { - console.log(JSON.stringify({ ...instructions, root: toRootOutput(root) }, null, 2)); + printJson({ ...instructions, root: toRootOutput(root) }, options.format); return; } diff --git a/src/commands/workflow/new-change.ts b/src/commands/workflow/new-change.ts index 3e059242dc..83c7b50686 100644 --- a/src/commands/workflow/new-change.ts +++ b/src/commands/workflow/new-change.ts @@ -22,6 +22,7 @@ import { isStoreSelectedRoot, } from '../../core/root-selection.js'; import { printJson, statusFromError, validateSchemaExists } from './shared.js'; +import type { OutputFormat } from '../../core/format-output.js'; // ----------------------------------------------------------------------------- // Types @@ -36,6 +37,7 @@ export interface NewChangeOptions { initiative?: string; areas?: string; json?: boolean; + format?: OutputFormat; } interface NewChangeOutput { @@ -147,7 +149,7 @@ export async function newChangeCommand(name: string | undefined, options: NewCha }; if (options.json) { - printJson(payload); + printJson(payload, options.format); return; } @@ -159,7 +161,7 @@ export async function newChangeCommand(name: string | undefined, options: NewCha printJson({ change: null, status: [statusFromError(error)], - }); + }, options.format); process.exitCode = 1; return; } diff --git a/src/commands/workflow/schemas.ts b/src/commands/workflow/schemas.ts index b9af74a677..ecdbbfe68b 100644 --- a/src/commands/workflow/schemas.ts +++ b/src/commands/workflow/schemas.ts @@ -1,3 +1,5 @@ +import { printJson } from '../shared-output.js'; +import type { OutputFormat } from '../../core/format-output.js'; /** * Schemas Command * @@ -13,6 +15,7 @@ import { listSchemasWithInfo } from '../../core/artifact-graph/index.js'; export interface SchemasOptions { json?: boolean; + format?: OutputFormat; } // ----------------------------------------------------------------------------- @@ -24,7 +27,7 @@ export async function schemasCommand(options: SchemasOptions): Promise { const schemas = listSchemasWithInfo(projectRoot); if (options.json) { - console.log(JSON.stringify(schemas, null, 2)); + printJson(schemas, options.format); return; } diff --git a/src/commands/workflow/shared.ts b/src/commands/workflow/shared.ts index 2840e004ed..755e9b4e8b 100644 --- a/src/commands/workflow/shared.ts +++ b/src/commands/workflow/shared.ts @@ -70,8 +70,10 @@ export const DEFAULT_SCHEMA = 'spec-driven'; // Utility Functions // ----------------------------------------------------------------------------- -export function printJson(payload: unknown): void { - console.log(JSON.stringify(payload, null, 2)); +import { formatAgentOutput, type OutputFormat } from '../../core/format-output.js'; + +export function printJson(payload: unknown, format: OutputFormat = 'json-pretty'): void { + console.log(formatAgentOutput(payload, format)); } export function statusFromError(error: unknown): ChangeCommandStatus { diff --git a/src/commands/workflow/status.ts b/src/commands/workflow/status.ts index 2a09b48edb..c991019df1 100644 --- a/src/commands/workflow/status.ts +++ b/src/commands/workflow/status.ts @@ -1,3 +1,5 @@ +import { printJson } from '../shared-output.js'; +import type { OutputFormat } from '../../core/format-output.js'; /** * Status Command * @@ -37,6 +39,7 @@ export interface StatusOptions { store?: string; storePath?: string; json?: boolean; + format?: OutputFormat; } // ----------------------------------------------------------------------------- @@ -110,7 +113,7 @@ export async function statusCommand(options: StatusOptions): Promise { spinner?.stop(); if (options.json) { - console.log(JSON.stringify({ ...status, root: rootOutput }, null, 2)); + printJson({ ...status, root: rootOutput }, options.format); return; } diff --git a/src/commands/workflow/templates.ts b/src/commands/workflow/templates.ts index fedd323e0d..9c673c210e 100644 --- a/src/commands/workflow/templates.ts +++ b/src/commands/workflow/templates.ts @@ -1,3 +1,5 @@ +import { printJson } from '../shared-output.js'; +import type { OutputFormat } from '../../core/format-output.js'; /** * Templates Command * @@ -21,6 +23,7 @@ import { validateSchemaExists, DEFAULT_SCHEMA } from './shared.js'; export interface TemplatesOptions { schema?: string; json?: boolean; + format?: OutputFormat; } export interface TemplateInfo { @@ -82,7 +85,7 @@ export async function templatesCommand(options: TemplatesOptions): Promise for (const t of templates) { output[t.artifactId] = { path: t.templatePath, source: t.source }; } - console.log(JSON.stringify(output, null, 2)); + printJson(output, options.format); return; } diff --git a/src/commands/workset.ts b/src/commands/workset.ts index afb018aa83..58d621f914 100644 --- a/src/commands/workset.ts +++ b/src/commands/workset.ts @@ -49,6 +49,7 @@ import { isPromptCancellationError, printJson, } from './shared-output.js'; +import { normalizeOptions, type OutputFormat } from '../core/format-output.js'; import { finalizeWorkset, firstInstalledAlternative, @@ -81,16 +82,25 @@ interface WorksetCreateOptions { member?: string[]; tool?: string; json?: boolean; + format?: OutputFormat; + jsonPretty?: boolean; + toon?: boolean; } interface WorksetOpenOptions { tool?: string; json?: boolean; + format?: OutputFormat; + jsonPretty?: boolean; + toon?: boolean; } interface WorksetRemoveOptions { yes?: boolean; json?: boolean; + format?: OutputFormat; + jsonPretty?: boolean; + toon?: boolean; } function readOpenerTable(): OpenerDefinition[] { @@ -255,7 +265,7 @@ class WorksetCommand { `Open it any time with: openspec workset open ${workset.name}` ); } catch (error) { - emitFailure(options.json, { workset: null, status: [] }, error, 'workset_error'); + emitFailure(options.format ?? options.json, { workset: null, status: [] }, error, 'workset_error'); } } @@ -297,7 +307,7 @@ class WorksetCommand { return finalizeWorkset(name, members, options.tool, table); } - async list(options: { json?: boolean } = {}): Promise { + async list(options: { json?: boolean; format?: OutputFormat; jsonPretty?: boolean; toon?: boolean; } = {}): Promise { try { const state = await readWorksetsState(); const worksets = listWorksets(state); @@ -329,7 +339,7 @@ class WorksetCommand { } } } catch (error) { - emitFailure(options.json, { worksets: [], status: [] }, error, 'workset_error'); + emitFailure(options.format ?? options.json, { worksets: [], status: [] }, error, 'workset_error'); } } @@ -484,7 +494,7 @@ class WorksetCommand { process.exitCode = exitCode; } } catch (error) { - emitFailure(options.json, { status: [] }, error, 'workset_error'); + emitFailure(options.format ?? options.json, { status: [] }, error, 'workset_error'); // Never strand the user: once the derived file is regenerated, // every failure (except a prompt cancellation) carries the @@ -549,7 +559,7 @@ class WorksetCommand { console.log(`Removed workset '${name}'. Member folders were not touched.`); } catch (error) { - emitFailure(options.json, { removed: null, status: [] }, error, 'workset_error'); + emitFailure(options.format ?? options.json, { removed: null, status: [] }, error, 'workset_error'); } } } @@ -580,8 +590,10 @@ export function registerWorksetCommand(program: Command): void { ) .option('--tool ', 'Preferred tool to open this workset with') .option('--json', 'Output as JSON') + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') .action(async (name: string | undefined, _options: WorksetCreateOptions, command: Command) => { - await worksetCommand.create(name, command.optsWithGlobals()); + await worksetCommand.create(name, normalizeOptions(command.optsWithGlobals())); }); workset @@ -589,8 +601,10 @@ export function registerWorksetCommand(program: Command): void { .alias('ls') .description('Show saved worksets with their members') .option('--json', 'Output as JSON') + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') .action(async (_options: { json?: boolean }, command: Command) => { - await worksetCommand.list(command.optsWithGlobals()); + await worksetCommand.list(normalizeOptions(command.optsWithGlobals())); }); workset @@ -604,7 +618,7 @@ export function registerWorksetCommand(program: Command): void { new Option('--json', 'Not supported for open').hideHelp() ) .action(async (name: string, _options: WorksetOpenOptions, command: Command) => { - await worksetCommand.open(name, command.optsWithGlobals()); + await worksetCommand.open(name, normalizeOptions(command.optsWithGlobals())); }); workset @@ -612,8 +626,10 @@ export function registerWorksetCommand(program: Command): void { .description('Delete a saved workset (member folders are never touched)') .option('--yes', 'Confirm removal non-interactively') .option('--json', 'Output as JSON') + .option('--json-pretty', 'Output as formatted JSON') + .option('--toon', 'Output in TOON format') .action(async (name: string, _options: WorksetRemoveOptions, command: Command) => { - await worksetCommand.remove(name, command.optsWithGlobals()); + await worksetCommand.remove(name, normalizeOptions(command.optsWithGlobals())); }); const subcommandsLine = workset.commands diff --git a/src/core/archive.ts b/src/core/archive.ts index 4bafd51cd4..10b5fd7876 100644 --- a/src/core/archive.ts +++ b/src/core/archive.ts @@ -1,3 +1,5 @@ +import { printJson } from '../commands/shared-output.js'; +import type { OutputFormat } from './format-output.js'; import { promises as fs } from 'fs'; import path from 'path'; import { formatLocalDate } from '../utils/date.js'; @@ -60,6 +62,7 @@ export interface ArchiveOptions { json?: boolean; store?: string; storePath?: string; + format?: OutputFormat; } interface ArchiveDiagnostic { @@ -267,7 +270,7 @@ export class ArchiveCommand { if (!result) { return; } - console.log(JSON.stringify({ archive: result, root: toRootOutput(root) }, null, 2)); + printJson({ archive: result, root: toRootOutput(root) }, options?.format ?? (options?.json ? 'json' : 'json-pretty')); } catch (error) { this.printJsonFailure(root, toArchiveDiagnostic(error)); } diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 33db57e874..aedb5bbaef 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -61,6 +61,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ values: ['recent', 'name'], }, COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, COMMON_FLAGS.store, ], }, @@ -93,6 +95,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ COMMON_FLAGS.type, COMMON_FLAGS.strict, COMMON_FLAGS.jsonValidation, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, { name: 'concurrency', description: 'Max concurrent validations (defaults to env OPENSPEC_CONCURRENCY or 6)', @@ -110,6 +114,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ positionals: [{ name: 'item-name', type: 'change-or-spec-id', optional: true }], flags: [ COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, COMMON_FLAGS.type, COMMON_FLAGS.noInteractive, { @@ -161,6 +167,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ name: 'json', description: 'Output as JSON (non-interactive)', }, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, COMMON_FLAGS.store, ], }, @@ -179,6 +187,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ takesValue: true, }, COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, COMMON_FLAGS.store, ], }, @@ -199,6 +209,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ takesValue: true, }, COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, COMMON_FLAGS.store, ], }, @@ -212,6 +224,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ takesValue: true, }, COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, ], }, { @@ -219,6 +233,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'List available workflow schemas with descriptions', flags: [ COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, ], }, { @@ -248,6 +264,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ takesValue: true, }, COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, COMMON_FLAGS.store, ], }, @@ -284,6 +302,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ takesValue: true, }, COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, ], }, { @@ -302,6 +322,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Confirm creating store identity metadata', }, COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, ], }, { @@ -311,6 +333,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ positionals: [{ name: 'id' }], flags: [ COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, ], }, { @@ -324,6 +348,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Confirm local store folder deletion', }, COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, ], }, { @@ -331,6 +357,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'List registered stores', flags: [ COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, ], }, { @@ -338,6 +366,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'List registered stores', flags: [ COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, ], }, { @@ -347,6 +377,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ positionals: [{ name: 'id', optional: true }], flags: [ COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, ], }, ], @@ -356,6 +388,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Print the working context for the resolved OpenSpec root', flags: [ COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, COMMON_FLAGS.store, { name: 'code-workspace', @@ -373,6 +407,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Report relationship health for the resolved OpenSpec root', flags: [ COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, COMMON_FLAGS.store, ], }, @@ -399,17 +435,19 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ takesValue: true, }, COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, ], }, { name: 'list', description: 'Show saved worksets with their members', - flags: [COMMON_FLAGS.json], + flags: [COMMON_FLAGS.json, COMMON_FLAGS.jsonPretty, COMMON_FLAGS.toon], }, { name: 'ls', description: 'Show saved worksets with their members', - flags: [COMMON_FLAGS.json], + flags: [COMMON_FLAGS.json, COMMON_FLAGS.jsonPretty, COMMON_FLAGS.toon], }, { name: 'open', @@ -436,6 +474,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Confirm removal non-interactively', }, COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, ], }, ], @@ -466,6 +506,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ positionals: [{ name: 'change-name', type: 'change-id', optional: true }], flags: [ COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, { name: 'deltas-only', description: 'Show only deltas (JSON only)', @@ -482,6 +524,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'List all active changes (deprecated)', flags: [ COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, { name: 'long', description: 'Show id and title with counts', @@ -497,6 +541,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ flags: [ COMMON_FLAGS.strict, COMMON_FLAGS.jsonValidation, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, COMMON_FLAGS.noInteractive, ], }, @@ -515,6 +561,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ positionals: [{ name: 'spec-id', type: 'spec-id', optional: true }], flags: [ COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, { name: 'requirements', description: 'Show only requirements, exclude scenarios (JSON only)', @@ -537,6 +585,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'List all specifications', flags: [ COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, { name: 'long', description: 'Show id and title with counts', @@ -552,6 +602,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ flags: [ COMMON_FLAGS.strict, COMMON_FLAGS.jsonValidation, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, COMMON_FLAGS.noInteractive, ], }, @@ -621,6 +673,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ description: 'Show all current settings', flags: [ COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, ], }, { @@ -695,6 +749,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ positionals: [{ name: 'name', type: 'schema-name', optional: true }], flags: [ COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, { name: 'all', description: 'List all schemas with their resolution sources', @@ -709,6 +765,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ positionals: [{ name: 'name', type: 'schema-name', optional: true }], flags: [ COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, { name: 'verbose', description: 'Show detailed validation steps', @@ -726,6 +784,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ ], flags: [ COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, { name: 'force', description: 'Overwrite existing destination', @@ -739,6 +799,8 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ positionals: [{ name: 'name' }], flags: [ COMMON_FLAGS.json, + COMMON_FLAGS.jsonPretty, + COMMON_FLAGS.toon, { name: 'description', description: 'Schema description', diff --git a/src/core/completions/shared-flags.ts b/src/core/completions/shared-flags.ts index 3a0d998e48..bf69bf8e44 100644 --- a/src/core/completions/shared-flags.ts +++ b/src/core/completions/shared-flags.ts @@ -4,6 +4,14 @@ import type { FlagDefinition } from './types.js'; * Common flags used across multiple commands. */ export const COMMON_FLAGS = { + jsonPretty: { + name: 'json-pretty', + description: 'Output as formatted JSON', + } as FlagDefinition, + toon: { + name: 'toon', + description: 'Output in TOON format', + } as FlagDefinition, json: { name: 'json', description: 'Output as JSON', diff --git a/src/core/config-prompts.ts b/src/core/config-prompts.ts index f1f9242e18..00eccbb1b8 100644 --- a/src/core/config-prompts.ts +++ b/src/core/config-prompts.ts @@ -11,6 +11,10 @@ export function serializeConfig(config: Partial): string { // Schema (required) lines.push(`schema: ${config.schema}`); + + if (config.agentOutputFormat) { + lines.push(`agentOutputFormat: ${config.agentOutputFormat}`); + } lines.push(''); // Context section with comments diff --git a/src/core/format-output.ts b/src/core/format-output.ts new file mode 100644 index 0000000000..bca64a8e6f --- /dev/null +++ b/src/core/format-output.ts @@ -0,0 +1,41 @@ +import * as toon from '@toon-format/toon'; + +export type OutputFormat = 'json' | 'json-pretty' | 'toon'; + +export function formatAgentOutput(payload: unknown, format: OutputFormat): string { + if (format === 'toon') { + try { + return toon.encode(payload); + } catch (error) { + // Fallback to minified JSON if TOON serialization fails + return JSON.stringify(payload); + } + } else if (format === 'json-pretty') { + return JSON.stringify(payload, null, 2); + } + return JSON.stringify(payload); +} + +export function resolveOutputFormat(options?: { jsonPretty?: boolean; toon?: boolean; json?: boolean }): OutputFormat | undefined { + let format: OutputFormat | undefined; + if (options?.jsonPretty) format = 'json-pretty'; + else if (options?.toon) format = 'toon'; + else if (options?.json) format = 'json'; + + if (format && options) { + options.json = true; + } + + return format; +} + +export function normalizeOptions( + options?: T +): (T extends undefined ? object : T) & { format?: OutputFormat } { + const opts = options ?? ({} as unknown as T); + const format = resolveOutputFormat(opts); + if (format) { + opts.json = true; + } + return { ...opts, format } as any; +} diff --git a/src/core/init.ts b/src/core/init.ts index b2064b183d..ed21a14999 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -101,6 +101,7 @@ type InitCommandOptions = { profile?: string; /** Commander's --no-animation flag: false disables the welcome animation. */ animation?: boolean; + agentOutputFormat?: 'json' | 'toon'; }; /** @@ -121,6 +122,7 @@ export class InitCommand { private readonly interactiveOption?: boolean; private readonly profileOverride?: string; private readonly animation: boolean; + private readonly agentOutputFormat?: 'json' | 'toon'; constructor(options: InitCommandOptions = {}) { this.toolsArg = options.tools; @@ -128,6 +130,7 @@ export class InitCommand { this.interactiveOption = options.interactive; this.profileOverride = options.profile; this.animation = options.animation ?? true; + this.agentOutputFormat = options.agentOutputFormat; } async execute(targetPath: string): Promise { @@ -723,12 +726,19 @@ export class InitCommand { const skillFile = path.join(skillDir, 'SKILL.md'); // Generate SKILL.md content with YAML frontmatter including generatedBy - const transformer = getTransformerForTool( + let transformer = getTransformerForTool( tool.value, delivery, resolveCommandSurfaceCapability(tool.value), resolveCommandInvocation(tool.value) ); + if (this.agentOutputFormat === 'toon') { + const baseTransformer = transformer; + transformer = (instructions: string) => { + const transformed = baseTransformer ? baseTransformer(instructions) : instructions; + return transformed.replace(/--json/g, '--toon'); + }; + } const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); // Write the skill file @@ -802,7 +812,9 @@ export class InitCommand { try { - const yamlContent = serializeConfig({ schema: DEFAULT_SCHEMA }); + let agentOutputFormat: 'json' | 'toon' = this.agentOutputFormat ?? 'json'; + + const yamlContent = serializeConfig({ schema: DEFAULT_SCHEMA, agentOutputFormat }); await FileSystemUtils.writeFile(configPath, yamlContent); return 'created'; } catch { diff --git a/src/core/list.ts b/src/core/list.ts index f6b6faf2f8..e2c875f6cc 100644 --- a/src/core/list.ts +++ b/src/core/list.ts @@ -1,3 +1,5 @@ +import { printJson } from '../commands/shared-output.js'; +import type { OutputFormat } from './format-output.js'; import { promises as fs } from 'fs'; import path from 'path'; import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js'; @@ -17,6 +19,7 @@ interface ListOptions { sort?: 'recent' | 'name'; json?: boolean; root?: RootOutput; + format?: OutputFormat; } function isMissingPathError(error: unknown): boolean { @@ -96,7 +99,7 @@ function formatRelativeTime(date: Date): string { export class ListCommand { async execute(targetPath: string = '.', mode: 'changes' | 'specs' = 'changes', options: ListOptions = {}): Promise { - const { sort = 'recent', json = false, root } = options; + const { sort = 'recent', json = false, root, format } = options; if (mode === 'changes') { const changesDir = path.join(targetPath, 'openspec', 'changes'); @@ -109,7 +112,7 @@ export class ListCommand { if (changeDirs.length === 0) { if (json) { - console.log(JSON.stringify({ changes: [], ...(root ? { root } : {}) }, null, 2)); + printJson({ changes: [], ...(root ? { root } : {}) }, format ?? (json ? 'json' : 'json-pretty')); } else { console.log('No active changes found.'); } @@ -147,7 +150,7 @@ export class ListCommand { lastModified: c.lastModified.toISOString(), status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress' })); - console.log(JSON.stringify({ changes: jsonOutput, ...(root ? { root } : {}) }, null, 2)); + printJson({ changes: jsonOutput, ...(root ? { root } : {}) }, format ?? (json ? 'json' : 'json-pretty')); return; } @@ -170,7 +173,7 @@ export class ListCommand { await fs.access(specsDir); } catch { if (json) { - console.log(JSON.stringify({ specs: [], ...(root ? { root } : {}) }, null, 2)); + printJson({ specs: [], ...(root ? { root } : {}) }, format ?? (json ? 'json' : 'json-pretty')); } else { console.log('No specs found.'); } @@ -180,7 +183,7 @@ export class ListCommand { const discovered = await discoverSpecFiles(specsDir); if (discovered.length === 0) { if (json) { - console.log(JSON.stringify({ specs: [], ...(root ? { root } : {}) }, null, 2)); + printJson({ specs: [], ...(root ? { root } : {}) }, format ?? (json ? 'json' : 'json-pretty')); } else { console.log('No specs found.'); } @@ -204,7 +207,7 @@ export class ListCommand { specs.sort((a, b) => a.id.localeCompare(b.id)); if (json) { - console.log(JSON.stringify({ specs, ...(root ? { root } : {}) }, null, 2)); + printJson({ specs, ...(root ? { root } : {}) }, format ?? (json ? 'json' : 'json-pretty')); return; } diff --git a/src/core/project-config.ts b/src/core/project-config.ts index f385443191..c38a0c2b8b 100644 --- a/src/core/project-config.ts +++ b/src/core/project-config.ts @@ -43,6 +43,12 @@ export const ProjectConfigSchema = z.object({ .optional() .describe('Project context injected into all artifact instructions'), + // Optional: agent output format + agentOutputFormat: z + .enum(['json', 'toon']) + .optional() + .describe('Default output format for AI agents'), + // Optional: per-artifact rules (additive to schema's built-in guidance) rules: z .record( @@ -300,6 +306,16 @@ export function readProjectConfig(projectRoot: string): ProjectConfig | null { } } + // Parse agentOutputFormat field using Zod + if (raw.agentOutputFormat !== undefined) { + const outputFormatResult = z.enum(['json', 'toon']).safeParse(raw.agentOutputFormat); + if (outputFormatResult.success) { + config.agentOutputFormat = outputFormatResult.data; + } else { + console.warn(`Invalid 'agentOutputFormat' field in config (must be 'json' or 'toon')`); + } + } + // Parse rules field using Zod if (raw.rules !== undefined) { const rulesField = z.record(z.string(), z.array(z.string())); diff --git a/test/core/completions/command-registry.test.ts b/test/core/completions/command-registry.test.ts index 1137ff94ad..4d05e8143e 100644 --- a/test/core/completions/command-registry.test.ts +++ b/test/core/completions/command-registry.test.ts @@ -208,6 +208,8 @@ describe('command completion registry', () => { 'goal', 'schema', 'json', + 'json-pretty', + 'toon', 'store', ]); @@ -248,12 +250,16 @@ describe('command completion registry', () => { 'no-init-git', 'remote', 'json', + 'json-pretty', + 'toon', ]); const remove = store?.subcommands?.find((entry) => entry.name === 'remove'); expect(remove?.flags.map((flag) => flag.name)).toEqual([ 'yes', 'json', + 'json-pretty', + 'toon', ]); }); });