Skip to content

Commit 315b2d0

Browse files
fix: suppress UI spinners and telemetry notices when --json is used
- Modified status, instructions, templates, and new-change commands to conditionally start the ora spinner only when not in JSON mode. - Updated the global preAction hook to detect the --json flag and pass a silent flag to telemetry notices. - Added --json support to 'new change' command for machine-readable result reporting. - Added a new spec for machine-readable-output capability. This ensures stdout/stderr pollution does not break JSON parsing for automated tools and AI agents.
1 parent a0608d0 commit 315b2d0

7 files changed

Lines changed: 81 additions & 13 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Capability: Machine Readable Output
2+
3+
## Purpose
4+
5+
Allow automated tools and AI agents to consume OpenSpec CLI output without stream corruption from UI elements.
6+
7+
## Requirements
8+
9+
### 1. Spinner Suppression
10+
The `ora` spinner must not be started if the `--json` flag is provided.
11+
12+
#### Scenario: Status with JSON
13+
- **Given** an OpenSpec project with active changes
14+
- **When** running `openspec status --change my-change --json`
15+
- **Then** the `stdout` contains only the JSON object
16+
- **And** no spinner characters are present in the output stream
17+
18+
### 2. Telemetry Notice Suppression
19+
The "Note: OpenSpec collects anonymous usage stats" message must not be printed if the `--json` flag is provided.
20+
21+
#### Scenario: First run with JSON
22+
- **Given** a new environment where the telemetry notice hasn't been shown
23+
- **When** running `openspec list --json`
24+
- **Then** the telemetry notice is NOT printed to stdout
25+
- **And** only the JSON change list is printed
26+
27+
### 3. Error Handling
28+
Errors should be printed to `stderr` and not pollute the `stdout` JSON stream.
29+
30+
#### Scenario: Command failure with JSON
31+
- **Given** an invalid change name
32+
- **When** running `openspec status --change invalid --json`
33+
- **Then** the exit code is non-zero
34+
- **And** the error message is printed to `stderr`

src/cli/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,12 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
7474
process.env.NO_COLOR = '1';
7575
}
7676

77+
// Check if action command is requesting JSON output
78+
const actionOpts = actionCommand.opts();
79+
const isJson = actionOpts.json === true;
80+
7781
// Show first-run telemetry notice (if not seen)
78-
await maybeShowTelemetryNotice();
82+
await maybeShowTelemetryNotice({ silent: isJson });
7983

8084
// Track command execution (use actionCommand to get the actual subcommand)
8185
const commandPath = getCommandPath(actionCommand);
@@ -497,6 +501,7 @@ newCmd
497501
.description('Create a new change directory')
498502
.option('--description <text>', 'Description to add to README.md')
499503
.option('--schema <name>', `Workflow schema to use (default: ${DEFAULT_SCHEMA})`)
504+
.option('--json', 'Output as JSON (success/failure details)')
500505
.action(async (name: string, options: NewChangeOptions) => {
501506
try {
502507
await newChangeCommand(name, options);

src/commands/workflow/instructions.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ export async function instructionsCommand(
4545
artifactId: string | undefined,
4646
options: InstructionsOptions
4747
): Promise<void> {
48-
const spinner = ora('Generating instructions...').start();
48+
const spinner = ora('Generating instructions...');
49+
if (!options.json) {
50+
spinner.start();
51+
}
4952

5053
try {
5154
const projectRoot = process.cwd();
@@ -400,7 +403,10 @@ export async function generateApplyInstructions(
400403
}
401404

402405
export async function applyInstructionsCommand(options: ApplyInstructionsOptions): Promise<void> {
403-
const spinner = ora('Generating apply instructions...').start();
406+
const spinner = ora('Generating apply instructions...');
407+
if (!options.json) {
408+
spinner.start();
409+
}
404410

405411
try {
406412
const projectRoot = process.cwd();

src/commands/workflow/new-change.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { validateSchemaExists } from './shared.js';
1616
export interface NewChangeOptions {
1717
description?: string;
1818
schema?: string;
19+
json?: boolean;
1920
}
2021

2122
// -----------------------------------------------------------------------------
@@ -40,7 +41,10 @@ export async function newChangeCommand(name: string | undefined, options: NewCha
4041
}
4142

4243
const schemaDisplay = options.schema ? ` with schema '${options.schema}'` : '';
43-
const spinner = ora(`Creating change '${name}'${schemaDisplay}...`).start();
44+
const spinner = ora(`Creating change '${name}'${schemaDisplay}...`);
45+
if (!options.json) {
46+
spinner.start();
47+
}
4448

4549
try {
4650
const result = await createChange(projectRoot, name, { schema: options.schema });
@@ -53,9 +57,20 @@ export async function newChangeCommand(name: string | undefined, options: NewCha
5357
await fs.writeFile(readmePath, `# ${name}\n\n${options.description}\n`, 'utf-8');
5458
}
5559

56-
spinner.succeed(`Created change '${name}' at openspec/changes/${name}/ (schema: ${result.schema})`);
60+
if (options.json) {
61+
console.log(JSON.stringify({
62+
success: true,
63+
name,
64+
schema: result.schema,
65+
path: `openspec/changes/${name}/`
66+
}, null, 2));
67+
} else {
68+
spinner.succeed(`Created change '${name}' at openspec/changes/${name}/ (schema: ${result.schema})`);
69+
}
5770
} catch (error) {
58-
spinner.fail(`Failed to create change '${name}'`);
71+
if (!options.json) {
72+
spinner.fail(`Failed to create change '${name}'`);
73+
}
5974
throw error;
6075
}
6176
}

src/commands/workflow/status.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ export interface StatusOptions {
3333
// -----------------------------------------------------------------------------
3434

3535
export async function statusCommand(options: StatusOptions): Promise<void> {
36-
const spinner = ora('Loading change status...').start();
36+
const spinner = ora('Loading change status...');
37+
if (!options.json) {
38+
spinner.start();
39+
}
3740

3841
try {
3942
const projectRoot = process.cwd();

src/commands/workflow/templates.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ export interface TemplateInfo {
3333
// -----------------------------------------------------------------------------
3434

3535
export async function templatesCommand(options: TemplatesOptions): Promise<void> {
36-
const spinner = ora('Loading templates...').start();
36+
const spinner = ora('Loading templates...');
37+
if (!options.json) {
38+
spinner.start();
39+
}
3740

3841
try {
3942
const projectRoot = process.cwd();

src/telemetry/index.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ export async function trackCommand(commandName: string, version: string): Promis
119119
/**
120120
* Show first-run telemetry notice if not already seen.
121121
*/
122-
export async function maybeShowTelemetryNotice(): Promise<void> {
122+
export async function maybeShowTelemetryNotice(options: { silent?: boolean } = {}): Promise<void> {
123123
if (!isTelemetryEnabled()) {
124124
return;
125125
}
@@ -130,10 +130,12 @@ export async function maybeShowTelemetryNotice(): Promise<void> {
130130
return;
131131
}
132132

133-
// Display notice
134-
console.log(
135-
'Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0'
136-
);
133+
if (!options.silent) {
134+
// Display notice
135+
console.log(
136+
'Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0'
137+
);
138+
}
137139

138140
// Mark as seen
139141
await updateTelemetryConfig({ noticeSeen: true });

0 commit comments

Comments
 (0)