Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-22
58 changes: 58 additions & 0 deletions openspec/changes/archive/2026-02-22-clean-json-output/design.md
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
@@ -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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice fix direction. One scope detail: this looks like a modification of existing capabilities, not a brand new one. We are changing telemetry notice behavior and workflow command output behavior. Could we move this to Modified Capabilities and list telemetry plus cli-artifact-workflow?


### 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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Capability: Machine Readable Output
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Same hyphenation nit as the canonical spec.

Title should be Machine-Readable Output. Based on learnings, this delta creates the spec from scratch, so the correction should be applied identically in both this archive copy and openspec/specs/machine-readable-output/spec.md.

🧰 Tools
🪛 LanguageTool

[grammar] ~1-~1: Use a hyphen to join words.
Context: # Capability: Machine Readable Output ## Purpose Allow autom...

(QB_NEW_EN_HYPHEN)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@openspec/changes/archive/2026-02-22-clean-json-output/specs/machine-readable-output/spec.md`
at line 1, The header "Capability: Machine Readable Output" should be
hyphenated—replace the title line so it reads "Machine-Readable Output" (i.e.,
change the header text containing "Capability: Machine Readable Output" to use
"Machine-Readable Output") and apply the identical correction to the canonical
spec copy as well as this archive copy so both titles match exactly.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This archived change spec looks like a full future-state document. Current conventions for change specs use delta sections like ## ADDED Requirements and ## MODIFIED Requirements. Could we convert this to delta format so it stays consistent with tooling and other archived changes?


## 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`
20 changes: 20 additions & 0 deletions openspec/changes/archive/2026-02-22-clean-json-output/tasks.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 34 additions & 0 deletions openspec/specs/machine-readable-output/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Capability: Machine Readable Output
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Machine-Readable Output — add hyphen to compound modifier.

"Machine Readable Output" should be "Machine-Readable Output" when used as a compound modifier (title/heading).

📝 Proposed fix
-# Capability: Machine Readable Output
+# Capability: Machine-Readable Output
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Capability: Machine Readable Output
# Capability: Machine-Readable Output
🧰 Tools
🪛 LanguageTool

[grammar] ~1-~1: Use a hyphen to join words.
Context: # Capability: Machine Readable Output ## Purpose Allow autom...

(QB_NEW_EN_HYPHEN)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openspec/specs/machine-readable-output/spec.md` at line 1, The heading
"Machine Readable Output" should use a hyphen as a compound modifier; update the
heading text (the top-level title string "# Capability: Machine Readable
Output") to "# Capability: Machine-Readable Output" so the compound adjective is
hyphenated consistently throughout the spec.


## Purpose

Allow automated tools and AI agents to consume OpenSpec CLI output without stream corruption from UI elements.

## Requirements

### 1. Spinner Suppression
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we switch these numbered sections to the standard requirement format? Using headings like ### Requirement: ... makes this consistent with OpenSpec conventions and easier for tooling to parse.

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`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with suppressing the notice in JSON mode. Can we make deferred notice behavior explicit here too: suppress on JSON runs, do not mark noticeSeen yet, and show it on the first later non-JSON run. That keeps JSON clean and still guarantees user disclosure.

- **Then** the telemetry notice is NOT printed to stdout
- **And** only the JSON change list is printed

### 3. Error Handling
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we define stdout behavior on failure more explicitly for JSON mode? Suggest: stdout stays empty, error text goes to stderr, and exit code is non-zero. That gives consumers a clear contract.

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`
7 changes: 6 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Comment on lines +77 to +82
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Telemetry notice is permanently suppressed for users whose first run uses --json.

maybeShowTelemetryNotice({ silent: true }) does not display the notice, but looking at src/telemetry/index.ts (lines 136–143), updateTelemetryConfig({ noticeSeen: true }) is called unconditionally — it runs outside the if (!options.silent) guard. On a first run with --json, the notice is silenced but noticeSeen is set to true, so the notice is never shown on any subsequent non-JSON invocation either. Users who start with --json are never informed about telemetry collection.

Fix in src/telemetry/index.ts:

// In maybeShowTelemetryNotice:
if (!options.silent) {
  console.log(
    'Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0'
  );
  await updateTelemetryConfig({ noticeSeen: true }); // ← move inside this block
}
// Remove the unconditional call below
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cli/index.ts` around lines 77 - 82, The telemetry notice is being marked
seen even when silenced by --json; modify maybeShowTelemetryNotice so that
updateTelemetryConfig({ noticeSeen: true }) is only called inside the branch
that displays the notice (i.e., only when options.silent is false).
Specifically, in the maybeShowTelemetryNotice function move the
updateTelemetryConfig call into the if (!options.silent) block (or add the
conditional around it) and remove any unconditional updateTelemetryConfig
invocation so noticeSeen is not set when the notice is suppressed.


// Track command execution (use actionCommand to get the actual subcommand)
const commandPath = getCommandPath(actionCommand);
Expand Down Expand Up @@ -497,6 +501,7 @@ newCmd
.description('Create a new change directory')
.option('--description <text>', 'Description to add to README.md')
.option('--schema <name>', `Workflow schema to use (default: ${DEFAULT_SCHEMA})`)
.option('--json', 'Output as JSON (success/failure details)')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice addition of --json here. One related fix is still needed in the catch block just below: it currently does console.log() before ora().fail(...). In JSON mode that writes to stdout and can break parsers. Can we guard the spacer with if (!options.json) and keep error output on stderr only?

.action(async (name: string, options: NewChangeOptions) => {
try {
await newChangeCommand(name, options);
Expand Down
10 changes: 8 additions & 2 deletions src/commands/workflow/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ export async function instructionsCommand(
artifactId: string | undefined,
options: InstructionsOptions
): Promise<void> {
const spinner = ora('Generating instructions...').start();
const spinner = ora('Generating instructions...');
if (!options.json) {
spinner.start();
}

try {
const projectRoot = process.cwd();
Expand Down Expand Up @@ -400,7 +403,10 @@ export async function generateApplyInstructions(
}

export async function applyInstructionsCommand(options: ApplyInstructionsOptions): Promise<void> {
const spinner = ora('Generating apply instructions...').start();
const spinner = ora('Generating apply instructions...');
if (!options.json) {
spinner.start();
}

try {
const projectRoot = process.cwd();
Expand Down
21 changes: 18 additions & 3 deletions src/commands/workflow/new-change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { validateSchemaExists } from './shared.js';
export interface NewChangeOptions {
description?: string;
schema?: string;
json?: boolean;
}

// -----------------------------------------------------------------------------
Expand All @@ -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 });
Expand All @@ -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;
}
Comment on lines 70 to 75
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Error path in JSON mode doesn't emit a structured error body before rethrowing.

When options.json is true, the function skips spinner.fail but still rethrows. The upstream catch block in src/cli/index.ts then executes console.log(), writing an empty line to stdout before calling ora().fail() on stderr. Any tool parsing stdout as JSON will fail — the spec's Requirement 3 is violated in the error case.

The cleanest fix is in the new change action handler in src/cli/index.ts (see the comment there), but an alternative is to fully resolve the error here without rethrowing in JSON mode:

🐛 Alternative fix (handle JSON error locally)
  } catch (error) {
-   if (!options.json) {
-     spinner.fail(`Failed to create change '${name}'`);
-   }
-   throw error;
+   if (options.json) {
+     console.log(JSON.stringify({ success: false, error: (error as Error).message }, null, 2));
+     process.exitCode = 1;
+     return;
+   }
+   spinner.fail(`Failed to create change '${name}'`);
+   throw error;
  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} catch (error) {
spinner.fail(`Failed to create change '${name}'`);
if (!options.json) {
spinner.fail(`Failed to create change '${name}'`);
}
throw error;
}
} catch (error) {
if (options.json) {
console.log(JSON.stringify({ success: false, error: (error as Error).message }, null, 2));
process.exitCode = 1;
return;
}
spinner.fail(`Failed to create change '${name}'`);
throw error;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/workflow/new-change.ts` around lines 70 - 75, In the catch block
where you currently call spinner.fail and then rethrow (checking options.json
and using spinner.fail(`Failed to create change '${name}'`)), ensure that when
options.json is true you emit a structured JSON error object to stdout (e.g., {
error: true, message: error.message, details: ... }) and do not rethrow;
specifically, replace the throw error path for options.json === true with a
console.log of the JSON error payload and a graceful return (or process.exit
with non-zero status) so the upstream handler doesn't print an empty line and
JSON consumers receive a valid error body, while keeping spinner.fail + throw
for non-JSON mode.

}
5 changes: 4 additions & 1 deletion src/commands/workflow/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ export interface StatusOptions {
// -----------------------------------------------------------------------------

export async function statusCommand(options: StatusOptions): Promise<void> {
const spinner = ora('Loading change status...').start();
const spinner = ora('Loading change status...');
if (!options.json) {
spinner.start();
}

try {
const projectRoot = process.cwd();
Expand Down
5 changes: 4 additions & 1 deletion src/commands/workflow/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ export interface TemplateInfo {
// -----------------------------------------------------------------------------

export async function templatesCommand(options: TemplatesOptions): Promise<void> {
const spinner = ora('Loading templates...').start();
const spinner = ora('Loading templates...');
if (!options.json) {
spinner.start();
}

try {
const projectRoot = process.cwd();
Expand Down
12 changes: 7 additions & 5 deletions src/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
export async function maybeShowTelemetryNotice(options: { silent?: boolean } = {}): Promise<void> {
if (!isTelemetryEnabled()) {
return;
}
Expand All @@ -130,10 +130,12 @@ export async function maybeShowTelemetryNotice(): Promise<void> {
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 });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small logic issue here: noticeSeen is saved even when silent mode suppresses the notice. If a user starts with --json, they may never see the disclosure. Can we move this update inside the if (!options.silent) block?

Expand Down
Loading