diff --git a/src/commands/workflow/instructions.ts b/src/commands/workflow/instructions.ts index 0d501afec..c90ff6a3d 100644 --- a/src/commands/workflow/instructions.ts +++ b/src/commands/workflow/instructions.ts @@ -45,7 +45,7 @@ export async function instructionsCommand( artifactId: string | undefined, options: InstructionsOptions ): Promise { - const spinner = ora('Generating instructions...').start(); + const spinner = options.json ? undefined : ora('Generating instructions...').start(); try { const projectRoot = process.cwd(); @@ -60,7 +60,7 @@ export async function instructionsCommand( const context = loadChangeContext(projectRoot, changeName, options.schema); if (!artifactId) { - spinner.stop(); + spinner?.stop(); const validIds = context.graph.getAllArtifacts().map((a) => a.id); throw new Error( `Missing required argument . Valid artifacts:\n ${validIds.join('\n ')}` @@ -70,7 +70,7 @@ export async function instructionsCommand( const artifact = context.graph.getArtifact(artifactId); if (!artifact) { - spinner.stop(); + spinner?.stop(); const validIds = context.graph.getAllArtifacts().map((a) => a.id); throw new Error( `Artifact '${artifactId}' not found in schema '${context.schemaName}'. Valid artifacts:\n ${validIds.join('\n ')}` @@ -80,7 +80,7 @@ export async function instructionsCommand( const instructions = generateInstructions(context, artifactId, projectRoot); const isBlocked = instructions.dependencies.some((d) => !d.done); - spinner.stop(); + spinner?.stop(); if (options.json) { console.log(JSON.stringify(instructions, null, 2)); @@ -89,7 +89,7 @@ export async function instructionsCommand( printInstructionsText(instructions, isBlocked); } catch (error) { - spinner.stop(); + spinner?.stop(); throw error; } } @@ -400,7 +400,7 @@ export async function generateApplyInstructions( } export async function applyInstructionsCommand(options: ApplyInstructionsOptions): Promise { - const spinner = ora('Generating apply instructions...').start(); + const spinner = options.json ? undefined : ora('Generating apply instructions...').start(); try { const projectRoot = process.cwd(); @@ -414,7 +414,7 @@ export async function applyInstructionsCommand(options: ApplyInstructionsOptions // generateApplyInstructions uses loadChangeContext which auto-detects schema const instructions = await generateApplyInstructions(projectRoot, changeName, options.schema); - spinner.stop(); + spinner?.stop(); if (options.json) { console.log(JSON.stringify(instructions, null, 2)); @@ -423,7 +423,7 @@ export async function applyInstructionsCommand(options: ApplyInstructionsOptions printApplyInstructionsText(instructions); } catch (error) { - spinner.stop(); + spinner?.stop(); throw error; } } diff --git a/src/commands/workflow/status.ts b/src/commands/workflow/status.ts index 5335bf5d4..1109ab188 100644 --- a/src/commands/workflow/status.ts +++ b/src/commands/workflow/status.ts @@ -34,7 +34,7 @@ export interface StatusOptions { // ----------------------------------------------------------------------------- export async function statusCommand(options: StatusOptions): Promise { - const spinner = ora('Loading change status...').start(); + const spinner = options.json ? undefined : ora('Loading change status...').start(); try { const projectRoot = process.cwd(); @@ -44,7 +44,7 @@ export async function statusCommand(options: StatusOptions): Promise { if (!options.change) { const available = await getAvailableChanges(projectRoot); if (available.length === 0) { - spinner.stop(); + spinner?.stop(); if (options.json) { console.log(JSON.stringify({ changes: [], message: 'No active changes.' }, null, 2)); return; @@ -53,7 +53,7 @@ export async function statusCommand(options: StatusOptions): Promise { return; } // Changes exist but --change not provided - spinner.stop(); + spinner?.stop(); throw new Error( `Missing required option --change. Available changes:\n ${available.join('\n ')}` ); @@ -70,7 +70,7 @@ export async function statusCommand(options: StatusOptions): Promise { const context = loadChangeContext(projectRoot, changeName, options.schema); const status = formatChangeStatus(context); - spinner.stop(); + spinner?.stop(); if (options.json) { console.log(JSON.stringify(status, null, 2)); @@ -79,7 +79,7 @@ export async function statusCommand(options: StatusOptions): Promise { printStatusText(status); } catch (error) { - spinner.stop(); + spinner?.stop(); throw error; } } diff --git a/src/commands/workflow/templates.ts b/src/commands/workflow/templates.ts index 0660e998e..989b92ffe 100644 --- a/src/commands/workflow/templates.ts +++ b/src/commands/workflow/templates.ts @@ -33,7 +33,7 @@ export interface TemplateInfo { // ----------------------------------------------------------------------------- export async function templatesCommand(options: TemplatesOptions): Promise { - const spinner = ora('Loading templates...').start(); + const spinner = options.json ? undefined : ora('Loading templates...').start(); try { const projectRoot = process.cwd(); @@ -72,7 +72,7 @@ export async function templatesCommand(options: TemplatesOptions): Promise source, })); - spinner.stop(); + spinner?.stop(); if (options.json) { const output: Record = {}; @@ -92,7 +92,7 @@ export async function templatesCommand(options: TemplatesOptions): Promise console.log(` ${t.templatePath}`); } } catch (error) { - spinner.stop(); + spinner?.stop(); throw error; } } diff --git a/test/cli-e2e/basic.test.ts b/test/cli-e2e/basic.test.ts index 33d045a5b..0d7e46de0 100644 --- a/test/cli-e2e/basic.test.ts +++ b/test/cli-e2e/basic.test.ts @@ -26,6 +26,12 @@ async function prepareFixture(fixtureName: string): Promise { return projectDir; } +function expectJsonOnlyOutput(result: Awaited>) { + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + expect(() => JSON.parse(result.stdout)).not.toThrow(); +} + afterAll(async () => { await Promise.all(tempRoots.map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); @@ -71,6 +77,46 @@ describe('openspec CLI e2e basics', () => { expect(json.items.some((item: any) => item.id === 'c1' && item.type === 'change')).toBe(true); }); + it('keeps list --json free of spinner output', async () => { + const projectDir = await prepareFixture('tmp-init'); + const result = await runCLI(['list', '--json'], { cwd: projectDir }); + expectJsonOnlyOutput(result); + }); + + it('keeps schemas --json free of spinner output', async () => { + const projectDir = await prepareFixture('tmp-init'); + const result = await runCLI(['schemas', '--json'], { cwd: projectDir }); + expectJsonOnlyOutput(result); + }); + + it('keeps status --json free of spinner output', async () => { + const projectDir = await prepareFixture('tmp-init'); + const result = await runCLI(['status', '--change', 'c1', '--json'], { cwd: projectDir }); + expectJsonOnlyOutput(result); + }); + + it('keeps instructions --json free of spinner output', async () => { + const projectDir = await prepareFixture('tmp-init'); + const result = await runCLI(['instructions', 'proposal', '--change', 'c1', '--json'], { + cwd: projectDir, + }); + expectJsonOnlyOutput(result); + }); + + it('keeps instructions apply --json free of spinner output', async () => { + const projectDir = await prepareFixture('tmp-init'); + const result = await runCLI(['instructions', 'apply', '--change', 'c1', '--json'], { + cwd: projectDir, + }); + expectJsonOnlyOutput(result); + }); + + it('keeps templates --json free of spinner output', async () => { + const projectDir = await prepareFixture('tmp-init'); + const result = await runCLI(['templates', '--json'], { cwd: projectDir }); + expectJsonOnlyOutput(result); + }); + it('returns an error for unknown items in the fixture', async () => { const projectDir = await prepareFixture('tmp-init'); const result = await runCLI(['validate', 'does-not-exist'], { cwd: projectDir }); diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts index 17ed97740..2a7c89969 100644 --- a/test/commands/artifact-workflow.test.ts +++ b/test/commands/artifact-workflow.test.ts @@ -110,6 +110,7 @@ describe('artifact-workflow CLI commands', () => { cwd: tempDir, }); expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); const json = JSON.parse(result.stdout); expect(json.changeName).toBe('json-change'); @@ -256,6 +257,7 @@ describe('artifact-workflow CLI commands', () => { cwd: tempDir, }); expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); const json = JSON.parse(result.stdout); expect(json.artifactId).toBe('design'); @@ -309,6 +311,7 @@ describe('artifact-workflow CLI commands', () => { it('outputs JSON mapping of templates', async () => { const result = await runCLI(['templates', '--json'], { cwd: tempDir }); expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); const json = JSON.parse(result.stdout); expect(json.proposal).toBeDefined(); @@ -405,6 +408,7 @@ describe('artifact-workflow CLI commands', () => { { cwd: tempDir } ); expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); const json = JSON.parse(result.stdout); expect(json.changeName).toBe('json-apply');