diff --git a/openspec/changes/archive/2026-02-22-clean-json-output/.openspec.yaml b/openspec/changes/archive/2026-02-22-clean-json-output/.openspec.yaml new file mode 100644 index 000000000..cbbb57832 --- /dev/null +++ b/openspec/changes/archive/2026-02-22-clean-json-output/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-22 diff --git a/openspec/changes/archive/2026-02-22-clean-json-output/design.md b/openspec/changes/archive/2026-02-22-clean-json-output/design.md new file mode 100644 index 000000000..e8243aa71 --- /dev/null +++ b/openspec/changes/archive/2026-02-22-clean-json-output/design.md @@ -0,0 +1,58 @@ +# Design: Clean JSON Output for OpenSpec CLI + +## Goals + +Ensure that when the `--json` flag is provided to any OpenSpec CLI command, the `stdout` stream contains ONLY valid, parseable JSON. + +- **Suppress UI Spinners**: Spinners (`ora`) must not start when `json` output is requested. +- **Silence Telemetry Notices**: The first-run notice must be suppressed during JSON output. +- **Cross-Platform Silence**: Both `stdout` and `stderr` should be managed to avoid pollution, though primarily focusing on `stdout` for JSON parsing. + +## Design Approach + +### 1. Global JSON Detection + +The CLI entry point (`src/cli/index.ts`) will detect the `--json` flag globally using the `commander` hook. + +- **Hook**: `program.hook('preAction', ...)` +- **Logic**: Check if the `actionCommand` has the `json` option set. + +### 2. Telemetry Silencing + +Update the telemetry module to support a "silent" mode. + +- **Update**: `maybeShowTelemetryNotice()` will check for a global silent state or accept an argument. +- **Implementation**: If silenced, `console.log()` for the notice will be skipped. + +### 3. Command Logic Pattern + +Every command using `ora` for UI feedback will adopt a standard conditional start pattern: + +```typescript +const spinner = ora('Doing work...'); +if (!options.json) { + spinner.start(); +} + +try { + // ... logic ... +} finally { + if (!options.json) spinner.stop(); +} +``` + +### 4. Target Files + +| Component | File Path | Action | +| --- | --- | --- | +| CLI Entry | `src/cli/index.ts` | Pass `json` status to telemetry notice. | +| Telemetry | `src/telemetry/index.ts` | Add silence support to `maybeShowTelemetryNotice`. | +| Status Cmd | `src/commands/workflow/status.ts` | Conditional spinner. | +| Instructions Cmd | `src/commands/workflow/instructions.ts` | Conditional spinner for both `instructions` and `applyInstructions`. | +| Templates Cmd | `src/commands/workflow/templates.ts` | Conditional spinner. | +| New Change Cmd | `src/commands/workflow/new-change.ts` | Conditional spinner. | + +## Risks / Trade-offs + +- **Error Visibility**: Errors should still go to `stderr`. If an error occurs during JSON generation, it might still produce a partial JSON or mixed output if not handled carefully. We will use `try/catch` and ensure `ora().fail()` is only called in non-JSON mode or use `console.error` for raw errors. +- **Breaking Change**: None identified, as this only affects programmatic consumers (who want clean JSON anyway). diff --git a/openspec/changes/archive/2026-02-22-clean-json-output/proposal.md b/openspec/changes/archive/2026-02-22-clean-json-output/proposal.md new file mode 100644 index 000000000..0f49982e2 --- /dev/null +++ b/openspec/changes/archive/2026-02-22-clean-json-output/proposal.md @@ -0,0 +1,25 @@ +## Why + +AI agents and automated tools rely on the `--json` flag for programmatic interaction with the OpenSpec CLI. Currently, the CLI prints UI elements like spinners and telemetry notices to `stdout` even when the `--json` flag is active. This pollution breaks standard JSON parsers and forces manual cleanup of the output stream. + +## What Changes + +- Modify workflow commands to suppress the `ora` spinner when `options.json` is provided. +- Update the global CLI hook and telemetry module to silence the first-run notice when outputting machine-readable data. +- Ensure that only valid JSON is printed to `stdout` on successful command execution, while maintaining `stderr` for errors. + +## Capabilities + +### New Capabilities + +- `machine-readable-output`: Ensures all CLI commands providing a `--json` flag produce clean, parseable stdout without terminal visual effects or side-channel messages. + +### Modified Capabilities + +- None. + +## Impact + +- **Core CLI**: `src/cli/index.ts` will need to pass silence flags down to hooks. +- **Workflow Commands**: `src/commands/workflow/*.ts` files will need conditional spinner starts. +- **Telemetry**: `src/telemetry/index.ts` needs a silence mechanism for the usage notice. diff --git a/openspec/changes/archive/2026-02-22-clean-json-output/specs/machine-readable-output/spec.md b/openspec/changes/archive/2026-02-22-clean-json-output/specs/machine-readable-output/spec.md new file mode 100644 index 000000000..13c911c4a --- /dev/null +++ b/openspec/changes/archive/2026-02-22-clean-json-output/specs/machine-readable-output/spec.md @@ -0,0 +1,34 @@ +# Capability: Machine Readable Output + +## Purpose + +Allow automated tools and AI agents to consume OpenSpec CLI output without stream corruption from UI elements. + +## Requirements + +### 1. Spinner Suppression +The `ora` spinner must not be started if the `--json` flag is provided. + +#### Scenario: Status with JSON +- **Given** an OpenSpec project with active changes +- **When** running `openspec status --change my-change --json` +- **Then** the `stdout` contains only the JSON object +- **And** no spinner characters are present in the output stream + +### 2. Telemetry Notice Suppression +The "Note: OpenSpec collects anonymous usage stats" message must not be printed if the `--json` flag is provided. + +#### Scenario: First run with JSON +- **Given** a new environment where the telemetry notice hasn't been shown +- **When** running `openspec list --json` +- **Then** the telemetry notice is NOT printed to stdout +- **And** only the JSON change list is printed + +### 3. Error Handling +Errors should be printed to `stderr` and not pollute the `stdout` JSON stream. + +#### Scenario: Command failure with JSON +- **Given** an invalid change name +- **When** running `openspec status --change invalid --json` +- **Then** the exit code is non-zero +- **And** the error message is printed to `stderr` diff --git a/openspec/changes/archive/2026-02-22-clean-json-output/tasks.md b/openspec/changes/archive/2026-02-22-clean-json-output/tasks.md new file mode 100644 index 000000000..7300901f7 --- /dev/null +++ b/openspec/changes/archive/2026-02-22-clean-json-output/tasks.md @@ -0,0 +1,20 @@ +# Tasks: Clean JSON Output + +## Phase 1: Global State & Telemetry + +- [x] 1.1 Update `src/telemetry/index.ts` to allow silencing the telemetry notice via a parameter or environment check. +- [x] 1.2 Update the `preAction` hook in `src/cli/index.ts` to detect the `--json` flag on the action command and trigger telemetry silence. + +## Phase 2: Command UI Silence + +- [x] 2.1 Update `src/commands/workflow/status.ts` to only start the `ora` spinner if `options.json` is false. +- [x] 2.2 Update `src/commands/workflow/instructions.ts` to only start the `ora` spinner in `instructionsCommand` and `applyInstructionsCommand` if `options.json` is false. +- [x] 2.3 Update `src/commands/workflow/templates.ts` to only start the `ora` spinner if `options.json` is false. +- [x] 2.4 Update `src/commands/workflow/new-change.ts` to only start the `ora` spinner if `options.json` is false (for metadata prep). + +## Phase 3: Verification + +- [x] 3.1 Verify `openspec status --json` produces clean JSON on Windows. +- [x] 3.2 Verify `openspec instructions apply --json` produces clean JSON on Windows. +- [x] 3.3 Verify first-run telemetry notice is suppressed when using `--json`. +- [x] 3.4 (Optional) Verify cross-platform silence if environment allows. diff --git a/openspec/specs/machine-readable-output/spec.md b/openspec/specs/machine-readable-output/spec.md new file mode 100644 index 000000000..13c911c4a --- /dev/null +++ b/openspec/specs/machine-readable-output/spec.md @@ -0,0 +1,34 @@ +# Capability: Machine Readable Output + +## Purpose + +Allow automated tools and AI agents to consume OpenSpec CLI output without stream corruption from UI elements. + +## Requirements + +### 1. Spinner Suppression +The `ora` spinner must not be started if the `--json` flag is provided. + +#### Scenario: Status with JSON +- **Given** an OpenSpec project with active changes +- **When** running `openspec status --change my-change --json` +- **Then** the `stdout` contains only the JSON object +- **And** no spinner characters are present in the output stream + +### 2. Telemetry Notice Suppression +The "Note: OpenSpec collects anonymous usage stats" message must not be printed if the `--json` flag is provided. + +#### Scenario: First run with JSON +- **Given** a new environment where the telemetry notice hasn't been shown +- **When** running `openspec list --json` +- **Then** the telemetry notice is NOT printed to stdout +- **And** only the JSON change list is printed + +### 3. Error Handling +Errors should be printed to `stderr` and not pollute the `stdout` JSON stream. + +#### Scenario: Command failure with JSON +- **Given** an invalid change name +- **When** running `openspec status --change invalid --json` +- **Then** the exit code is non-zero +- **And** the error message is printed to `stderr` diff --git a/src/cli/index.ts b/src/cli/index.ts index 8947736f7..baaea8b0c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -74,8 +74,12 @@ program.hook('preAction', async (thisCommand, actionCommand) => { process.env.NO_COLOR = '1'; } + // Check if action command is requesting JSON output + const actionOpts = actionCommand.opts(); + const isJson = actionOpts.json === true; + // Show first-run telemetry notice (if not seen) - await maybeShowTelemetryNotice(); + await maybeShowTelemetryNotice({ silent: isJson }); // Track command execution (use actionCommand to get the actual subcommand) const commandPath = getCommandPath(actionCommand); @@ -497,6 +501,7 @@ newCmd .description('Create a new change directory') .option('--description ', 'Description to add to README.md') .option('--schema ', `Workflow schema to use (default: ${DEFAULT_SCHEMA})`) + .option('--json', 'Output as JSON (success/failure details)') .action(async (name: string, options: NewChangeOptions) => { try { await newChangeCommand(name, options); diff --git a/src/commands/workflow/instructions.ts b/src/commands/workflow/instructions.ts index 0d501afec..3c60afedc 100644 --- a/src/commands/workflow/instructions.ts +++ b/src/commands/workflow/instructions.ts @@ -45,7 +45,10 @@ export async function instructionsCommand( artifactId: string | undefined, options: InstructionsOptions ): Promise { - const spinner = ora('Generating instructions...').start(); + const spinner = ora('Generating instructions...'); + if (!options.json) { + spinner.start(); + } try { const projectRoot = process.cwd(); @@ -400,7 +403,10 @@ export async function generateApplyInstructions( } export async function applyInstructionsCommand(options: ApplyInstructionsOptions): Promise { - const spinner = ora('Generating apply instructions...').start(); + const spinner = ora('Generating apply instructions...'); + if (!options.json) { + spinner.start(); + } try { const projectRoot = process.cwd(); diff --git a/src/commands/workflow/new-change.ts b/src/commands/workflow/new-change.ts index 1435e1add..8eca4e912 100644 --- a/src/commands/workflow/new-change.ts +++ b/src/commands/workflow/new-change.ts @@ -16,6 +16,7 @@ import { validateSchemaExists } from './shared.js'; export interface NewChangeOptions { description?: string; schema?: string; + json?: boolean; } // ----------------------------------------------------------------------------- @@ -40,7 +41,10 @@ export async function newChangeCommand(name: string | undefined, options: NewCha } const schemaDisplay = options.schema ? ` with schema '${options.schema}'` : ''; - const spinner = ora(`Creating change '${name}'${schemaDisplay}...`).start(); + const spinner = ora(`Creating change '${name}'${schemaDisplay}...`); + if (!options.json) { + spinner.start(); + } try { const result = await createChange(projectRoot, name, { schema: options.schema }); @@ -53,9 +57,20 @@ export async function newChangeCommand(name: string | undefined, options: NewCha await fs.writeFile(readmePath, `# ${name}\n\n${options.description}\n`, 'utf-8'); } - spinner.succeed(`Created change '${name}' at openspec/changes/${name}/ (schema: ${result.schema})`); + if (options.json) { + console.log(JSON.stringify({ + success: true, + name, + schema: result.schema, + path: `openspec/changes/${name}/` + }, null, 2)); + } else { + spinner.succeed(`Created change '${name}' at openspec/changes/${name}/ (schema: ${result.schema})`); + } } catch (error) { - spinner.fail(`Failed to create change '${name}'`); + if (!options.json) { + spinner.fail(`Failed to create change '${name}'`); + } throw error; } } diff --git a/src/commands/workflow/status.ts b/src/commands/workflow/status.ts index 59e46c253..35004258b 100644 --- a/src/commands/workflow/status.ts +++ b/src/commands/workflow/status.ts @@ -33,7 +33,10 @@ export interface StatusOptions { // ----------------------------------------------------------------------------- export async function statusCommand(options: StatusOptions): Promise { - const spinner = ora('Loading change status...').start(); + const spinner = ora('Loading change status...'); + if (!options.json) { + spinner.start(); + } try { const projectRoot = process.cwd(); diff --git a/src/commands/workflow/templates.ts b/src/commands/workflow/templates.ts index 0660e998e..5c20c7ed0 100644 --- a/src/commands/workflow/templates.ts +++ b/src/commands/workflow/templates.ts @@ -33,7 +33,10 @@ export interface TemplateInfo { // ----------------------------------------------------------------------------- export async function templatesCommand(options: TemplatesOptions): Promise { - const spinner = ora('Loading templates...').start(); + const spinner = ora('Loading templates...'); + if (!options.json) { + spinner.start(); + } try { const projectRoot = process.cwd(); diff --git a/src/telemetry/index.ts b/src/telemetry/index.ts index 753db89fe..d7ceb03b4 100644 --- a/src/telemetry/index.ts +++ b/src/telemetry/index.ts @@ -119,7 +119,7 @@ export async function trackCommand(commandName: string, version: string): Promis /** * Show first-run telemetry notice if not already seen. */ -export async function maybeShowTelemetryNotice(): Promise { +export async function maybeShowTelemetryNotice(options: { silent?: boolean } = {}): Promise { if (!isTelemetryEnabled()) { return; } @@ -130,10 +130,12 @@ export async function maybeShowTelemetryNotice(): Promise { return; } - // Display notice - console.log( - 'Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0' - ); + if (!options.silent) { + // Display notice + console.log( + 'Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0' + ); + } // Mark as seen await updateTelemetryConfig({ noticeSeen: true });