From 738d6a19b70ae531e4646f5c8011b84d3c749437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A1n=20Silva=20Ortiz?= Date: Wed, 25 Feb 2026 15:15:39 -0300 Subject: [PATCH 1/3] fix(status): exit gracefully when no changes exist (#714) Extract `getAvailableChanges` as a public function from `validateChangeExists` and use it in `statusCommand` to detect the no-changes case early. Returns a friendly message (text and JSON modes) with exit code 0 instead of a fatal error. Generated with Claude Code using claude-opus-4-6. Co-Authored-By: Claude Opus 4.6 --- .changeset/graceful-status-no-changes.md | 5 +++ .../graceful-status-no-changes/.openspec.yaml | 2 + .../graceful-status-no-changes/design.md | 38 +++++++++++++++++++ .../graceful-status-no-changes/proposal.md | 25 ++++++++++++ .../specs/graceful-status-empty/spec.md | 27 +++++++++++++ .../graceful-status-no-changes/tasks.md | 16 ++++++++ src/commands/workflow/shared.ts | 36 +++++++++--------- src/commands/workflow/status.ts | 22 +++++++++++ test/commands/artifact-workflow.test.ts | 16 ++++++++ 9 files changed, 170 insertions(+), 17 deletions(-) create mode 100644 .changeset/graceful-status-no-changes.md create mode 100644 openspec/changes/graceful-status-no-changes/.openspec.yaml create mode 100644 openspec/changes/graceful-status-no-changes/design.md create mode 100644 openspec/changes/graceful-status-no-changes/proposal.md create mode 100644 openspec/changes/graceful-status-no-changes/specs/graceful-status-empty/spec.md create mode 100644 openspec/changes/graceful-status-no-changes/tasks.md diff --git a/.changeset/graceful-status-no-changes.md b/.changeset/graceful-status-no-changes.md new file mode 100644 index 000000000..0ef3345d7 --- /dev/null +++ b/.changeset/graceful-status-no-changes.md @@ -0,0 +1,5 @@ +--- +"@fission-ai/openspec": patch +--- + +fix: `openspec status` now exits gracefully when no changes exist instead of throwing a fatal error. Fixes #714. diff --git a/openspec/changes/graceful-status-no-changes/.openspec.yaml b/openspec/changes/graceful-status-no-changes/.openspec.yaml new file mode 100644 index 000000000..e331c975d --- /dev/null +++ b/openspec/changes/graceful-status-no-changes/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-25 diff --git a/openspec/changes/graceful-status-no-changes/design.md b/openspec/changes/graceful-status-no-changes/design.md new file mode 100644 index 000000000..dae6a1c6e --- /dev/null +++ b/openspec/changes/graceful-status-no-changes/design.md @@ -0,0 +1,38 @@ +## Context + +`statusCommand` in `src/commands/workflow/status.ts` calls `validateChangeExists()` from `shared.ts` as its first operation. When no `--change` option is provided and no change directories exist, `validateChangeExists` throws: `No changes found. Create one with: openspec new change `. This error propagates up as a fatal CLI error (non-zero exit code). + +This is correct behavior for commands like `apply` and `show` that require a change to operate on. However, `status` is an informational command — it should report the current state, even when that state is "no changes exist." + +The error surfaces during onboarding (issue #714) when AI agents call `openspec status` before any change has been created. + +## Goals / Non-Goals + +**Goals:** +- Make `openspec status` exit with code 0 and a friendly message when no changes exist +- Support both text and JSON output modes for the no-changes case +- Keep all other commands' validation behavior unchanged + +**Non-Goals:** +- Changing the behavior of `validateChangeExists` (keep it strict for all consumers; only extract its internal helper) +- Changing the onboard template or skill instructions +- Handling the case where `--change` is provided but the specific change doesn't exist (this should remain an error) + +## Decisions + +### Extract `getAvailableChanges` and check before validation + +**Rationale**: Extract the private `getAvailableChanges` closure from `validateChangeExists` into a public exported function in `shared.ts`. Then, in `statusCommand`, call `getAvailableChanges` *before* `validateChangeExists` to detect the no-changes case early and handle it gracefully. This avoids using try/catch for control flow and eliminates any coupling to error message strings. + +**Alternative considered**: Catching the error from `validateChangeExists` by matching `error.message.startsWith('No changes found')`. Rejected because string coupling is fragile — if the error message changes, the catch silently stops working. + +**Alternative considered**: Adding a `throwOnEmpty` parameter to `validateChangeExists`. Rejected because it adds complexity to a shared function for a single consumer's needs and mixes UX concerns into a validation utility. + +### Keep `validateChangeExists` strict + +**Rationale**: `validateChangeExists` remains unchanged in behavior — it still throws for all error cases. The graceful handling lives entirely in `statusCommand`, which is the appropriate layer for UX decisions. Other commands (`apply`, `show`, `instructions`) are unaffected. + +## Risks / Trade-offs + +- [Risk] Extra filesystem read when no `--change` is provided and no changes exist (calls `getAvailableChanges` then `validateChangeExists` would also call it) → Mitigation: `statusCommand` returns early before reaching `validateChangeExists`, so the extra read only happens when changes *do* exist — minimal overhead. +- [Risk] Other commands may also benefit from graceful no-changes handling in the future → Mitigation: `getAvailableChanges` is now public and reusable, making it easy to apply the same pattern elsewhere. diff --git a/openspec/changes/graceful-status-no-changes/proposal.md b/openspec/changes/graceful-status-no-changes/proposal.md new file mode 100644 index 000000000..2e2058bf7 --- /dev/null +++ b/openspec/changes/graceful-status-no-changes/proposal.md @@ -0,0 +1,25 @@ +## Why + +When `openspec status` is called without `--change` and no changes exist (e.g., during onboarding on a freshly initialized project), the CLI throws a fatal error: `No changes found. Create one with: openspec new change `. This breaks the onboarding flow because AI agents may call `openspec status` before any change has been created, causing the agent to halt or report failure. Fixes [#714](https://github.com/Fission-AI/OpenSpec/issues/714). + +## What Changes + +- `openspec status` will exit gracefully (code 0) with a friendly message when no changes exist, instead of throwing a fatal error +- `openspec status --json` will return a valid JSON object with an empty changes array when no changes exist +- Other commands (`apply`, `show`, etc.) retain their current strict validation behavior + +## Capabilities + +### New Capabilities + +- `graceful-status-empty`: Graceful handling of `openspec status` when no changes exist, covering both text and JSON output modes + +### Modified Capabilities + +_None — the change is scoped to `statusCommand` only; `validateChangeExists` and other consumers are unaffected._ + +## Impact + +- `src/commands/workflow/shared.ts` — extract `getAvailableChanges` as a public function (validation behavior unchanged) +- `src/commands/workflow/status.ts` — check for available changes before validation, handle empty case gracefully +- Tests for the status command need to cover the new graceful behavior diff --git a/openspec/changes/graceful-status-no-changes/specs/graceful-status-empty/spec.md b/openspec/changes/graceful-status-no-changes/specs/graceful-status-empty/spec.md new file mode 100644 index 000000000..7c5e390ad --- /dev/null +++ b/openspec/changes/graceful-status-no-changes/specs/graceful-status-empty/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: Status command exits gracefully when no changes exist +The `statusCommand` function SHALL check for available changes via `getAvailableChanges` before calling `validateChangeExists`. When no `--change` option is provided and no change directories exist, it SHALL print a friendly informational message and exit with code 0, instead of reaching `validateChangeExists` and propagating a fatal error. + +#### Scenario: No changes exist, text mode +- **WHEN** user runs `openspec status` without `--change` and no change directories exist under `openspec/changes/` +- **THEN** the CLI prints `No active changes. Create one with: openspec new change ` to stdout and exits with code 0 + +#### Scenario: No changes exist, JSON mode +- **WHEN** user runs `openspec status --json` without `--change` and no change directories exist +- **THEN** the CLI outputs `{"changes":[],"message":"No active changes."}` as valid JSON to stdout and exits with code 0 + +### Requirement: Existing status validation behavior is preserved +Other error paths in `validateChangeExists` that apply to the status command SHALL continue to throw errors as before. Commands other than `status` that use `validateChangeExists` SHALL NOT be affected. + +#### Scenario: Changes exist but --change not specified +- **WHEN** user runs `openspec status` without `--change` and one or more change directories exist +- **THEN** the CLI throws an error listing available changes with the message `Missing required option --change. Available changes: ...` + +#### Scenario: Specified change does not exist +- **WHEN** user runs `openspec status --change non-existent` +- **THEN** the CLI throws an error with message `Change 'non-existent' not found` + +#### Scenario: Other commands unaffected +- **WHEN** user runs `openspec show` or `openspec instructions` without `--change` and no changes exist +- **THEN** the CLI throws the original `No changes found` error (no behavior change) diff --git a/openspec/changes/graceful-status-no-changes/tasks.md b/openspec/changes/graceful-status-no-changes/tasks.md new file mode 100644 index 000000000..bf00684a0 --- /dev/null +++ b/openspec/changes/graceful-status-no-changes/tasks.md @@ -0,0 +1,16 @@ +## 1. Implementation + +- [x] 1.1 Extract `getAvailableChanges` in `shared.ts` and use it in `statusCommand` to check for changes before calling `validateChangeExists` +- [x] 1.2 In text mode: print `No active changes. Create one with: openspec new change ` and return (exit 0) +- [x] 1.3 In JSON mode: output `{"changes":[],"message":"No active changes."}` and return (exit 0) + +## 2. Tests + +- [x] 2.1 Add test: `openspec status` with no changes exits gracefully with friendly message (text mode) +- [x] 2.2 Add test: `openspec status --json` with no changes returns valid JSON with empty changes array +- [x] 2.3 Verify existing behavior: `openspec status` without `--change` when changes exist still throws missing option error +- [x] 2.4 Verify cross-platform: tests use `path.join()` for any path assertions + +## 3. Release + +- [x] 3.1 Add changeset describing the fix diff --git a/src/commands/workflow/shared.ts b/src/commands/workflow/shared.ts index a2c8bdcc7..653b079ac 100644 --- a/src/commands/workflow/shared.ts +++ b/src/commands/workflow/shared.ts @@ -86,6 +86,22 @@ export function getStatusIndicator(status: 'done' | 'ready' | 'blocked'): string } } +/** + * Returns the list of available change directory names under openspec/changes/. + * Excludes the archive directory and hidden directories. + */ +export async function getAvailableChanges(projectRoot: string): Promise { + const changesPath = path.join(projectRoot, 'openspec', 'changes'); + try { + const entries = await fs.promises.readdir(changesPath, { withFileTypes: true }); + return entries + .filter((e) => e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.')) + .map((e) => e.name); + } catch { + return []; + } +} + /** * Validates that a change exists and returns available changes if not. * Checks directory existence directly to support scaffolded changes (without proposal.md). @@ -94,22 +110,8 @@ export async function validateChangeExists( changeName: string | undefined, projectRoot: string ): Promise { - const changesPath = path.join(projectRoot, 'openspec', 'changes'); - - // Get all change directories (not just those with proposal.md) - const getAvailableChanges = async (): Promise => { - try { - const entries = await fs.promises.readdir(changesPath, { withFileTypes: true }); - return entries - .filter((e) => e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.')) - .map((e) => e.name); - } catch { - return []; - } - }; - if (!changeName) { - const available = await getAvailableChanges(); + const available = await getAvailableChanges(projectRoot); if (available.length === 0) { throw new Error('No changes found. Create one with: openspec new change '); } @@ -125,11 +127,11 @@ export async function validateChangeExists( } // Check directory existence directly - const changePath = path.join(changesPath, changeName); + const changePath = path.join(projectRoot, 'openspec', 'changes', changeName); const exists = fs.existsSync(changePath) && fs.statSync(changePath).isDirectory(); if (!exists) { - const available = await getAvailableChanges(); + const available = await getAvailableChanges(projectRoot); if (available.length === 0) { throw new Error( `Change '${changeName}' not found. No changes exist. Create one with: openspec new change ` diff --git a/src/commands/workflow/status.ts b/src/commands/workflow/status.ts index 59e46c253..5335bf5d4 100644 --- a/src/commands/workflow/status.ts +++ b/src/commands/workflow/status.ts @@ -14,6 +14,7 @@ import { import { validateChangeExists, validateSchemaExists, + getAvailableChanges, getStatusIndicator, getStatusColor, } from './shared.js'; @@ -37,6 +38,27 @@ export async function statusCommand(options: StatusOptions): Promise { try { const projectRoot = process.cwd(); + + // Handle no-changes case gracefully — status is informational, + // so "no changes" is a valid state, not an error. + if (!options.change) { + const available = await getAvailableChanges(projectRoot); + if (available.length === 0) { + spinner.stop(); + if (options.json) { + console.log(JSON.stringify({ changes: [], message: 'No active changes.' }, null, 2)); + return; + } + console.log('No active changes. Create one with: openspec new change '); + return; + } + // Changes exist but --change not provided + spinner.stop(); + throw new Error( + `Missing required option --change. Available changes:\n ${available.join('\n ')}` + ); + } + const changeName = await validateChangeExists(options.change, projectRoot); // Validate schema if explicitly provided diff --git a/test/commands/artifact-workflow.test.ts b/test/commands/artifact-workflow.test.ts index 181629940..17ed97740 100644 --- a/test/commands/artifact-workflow.test.ts +++ b/test/commands/artifact-workflow.test.ts @@ -131,6 +131,22 @@ describe('artifact-workflow CLI commands', () => { expect(result.stdout).toContain('All artifacts complete!'); }); + it('exits gracefully when no changes exist', async () => { + const result = await runCLI(['status'], { cwd: tempDir }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('No active changes'); + expect(result.stdout).toContain('openspec new change'); + }); + + it('exits gracefully with JSON when no changes exist', async () => { + const result = await runCLI(['status', '--json'], { cwd: tempDir }); + expect(result.exitCode).toBe(0); + + const json = JSON.parse(result.stdout); + expect(json.changes).toEqual([]); + expect(json.message).toBe('No active changes.'); + }); + it('errors when --change is missing and lists available changes', async () => { await createTestChange('some-change'); From 60c584973353fcc1db93d2389e5d1713230ca28f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A1n=20Silva=20Ortiz?= Date: Wed, 25 Feb 2026 15:26:36 -0300 Subject: [PATCH 2/3] docs: fix design risk description and proposal accuracy Address CodeRabbit review feedback: - Fix contradictory risk description in design.md (double-read happens when changes exist, not when they don't) - Clarify in proposal.md that validateChangeExists was internally refactored to delegate to getAvailableChanges Co-Authored-By: Claude Opus 4.6 --- openspec/changes/graceful-status-no-changes/design.md | 2 +- openspec/changes/graceful-status-no-changes/proposal.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openspec/changes/graceful-status-no-changes/design.md b/openspec/changes/graceful-status-no-changes/design.md index dae6a1c6e..0d4d1b21c 100644 --- a/openspec/changes/graceful-status-no-changes/design.md +++ b/openspec/changes/graceful-status-no-changes/design.md @@ -34,5 +34,5 @@ The error surfaces during onboarding (issue #714) when AI agents call `openspec ## Risks / Trade-offs -- [Risk] Extra filesystem read when no `--change` is provided and no changes exist (calls `getAvailableChanges` then `validateChangeExists` would also call it) → Mitigation: `statusCommand` returns early before reaching `validateChangeExists`, so the extra read only happens when changes *do* exist — minimal overhead. +- [Risk] Extra filesystem read when no `--change` is provided and changes *do* exist (`getAvailableChanges` is called first, then `validateChangeExists` performs its own read) → Mitigation: `statusCommand` returns early before reaching `validateChangeExists` when no changes exist, so the double-read only occurs when changes are present — minimal overhead. - [Risk] Other commands may also benefit from graceful no-changes handling in the future → Mitigation: `getAvailableChanges` is now public and reusable, making it easy to apply the same pattern elsewhere. diff --git a/openspec/changes/graceful-status-no-changes/proposal.md b/openspec/changes/graceful-status-no-changes/proposal.md index 2e2058bf7..dc24d61b8 100644 --- a/openspec/changes/graceful-status-no-changes/proposal.md +++ b/openspec/changes/graceful-status-no-changes/proposal.md @@ -16,7 +16,7 @@ When `openspec status` is called without `--change` and no changes exist (e.g., ### Modified Capabilities -_None — the change is scoped to `statusCommand` only; `validateChangeExists` and other consumers are unaffected._ +_None — `validateChangeExists` was internally refactored to delegate to the newly exported `getAvailableChanges`, but its behavior and public contract are unchanged. Other consumers are unaffected._ ## Impact From 9dda436ff447cfa6adb0b78a282a57728dcb513d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A1n=20Silva=20Ortiz?= Date: Wed, 25 Feb 2026 21:43:56 -0300 Subject: [PATCH 3/3] fix(status): narrow catch in getAvailableChanges to ENOENT only Return [] only when the changes directory doesn't exist (ENOENT). Rethrow other errors (EACCES, etc.) so real filesystem issues surface instead of being silently masked as "no changes". Co-Authored-By: Claude Opus 4.6 --- src/commands/workflow/shared.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/commands/workflow/shared.ts b/src/commands/workflow/shared.ts index 653b079ac..a85fcd585 100644 --- a/src/commands/workflow/shared.ts +++ b/src/commands/workflow/shared.ts @@ -97,8 +97,9 @@ export async function getAvailableChanges(projectRoot: string): Promise e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.')) .map((e) => e.name); - } catch { - return []; + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; } }