Skip to content

Commit afdca0d

Browse files
fsilvaortizclaudeTabishB
authored
fix(status): exit gracefully when no changes exist (Fission-AI#759)
* fix(status): exit gracefully when no changes exist (Fission-AI#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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
1 parent 61eb999 commit afdca0d

9 files changed

Lines changed: 171 additions & 17 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@fission-ai/openspec": patch
3+
---
4+
5+
fix: `openspec status` now exits gracefully when no changes exist instead of throwing a fatal error. Fixes #714.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-02-25
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
## Context
2+
3+
`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 <name>`. This error propagates up as a fatal CLI error (non-zero exit code).
4+
5+
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."
6+
7+
The error surfaces during onboarding (issue #714) when AI agents call `openspec status` before any change has been created.
8+
9+
## Goals / Non-Goals
10+
11+
**Goals:**
12+
- Make `openspec status` exit with code 0 and a friendly message when no changes exist
13+
- Support both text and JSON output modes for the no-changes case
14+
- Keep all other commands' validation behavior unchanged
15+
16+
**Non-Goals:**
17+
- Changing the behavior of `validateChangeExists` (keep it strict for all consumers; only extract its internal helper)
18+
- Changing the onboard template or skill instructions
19+
- Handling the case where `--change` is provided but the specific change doesn't exist (this should remain an error)
20+
21+
## Decisions
22+
23+
### Extract `getAvailableChanges` and check before validation
24+
25+
**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.
26+
27+
**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.
28+
29+
**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.
30+
31+
### Keep `validateChangeExists` strict
32+
33+
**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.
34+
35+
## Risks / Trade-offs
36+
37+
- [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.
38+
- [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.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
## Why
2+
3+
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 <name>`. 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).
4+
5+
## What Changes
6+
7+
- `openspec status` will exit gracefully (code 0) with a friendly message when no changes exist, instead of throwing a fatal error
8+
- `openspec status --json` will return a valid JSON object with an empty changes array when no changes exist
9+
- Other commands (`apply`, `show`, etc.) retain their current strict validation behavior
10+
11+
## Capabilities
12+
13+
### New Capabilities
14+
15+
- `graceful-status-empty`: Graceful handling of `openspec status` when no changes exist, covering both text and JSON output modes
16+
17+
### Modified Capabilities
18+
19+
_None — `validateChangeExists` was internally refactored to delegate to the newly exported `getAvailableChanges`, but its behavior and public contract are unchanged. Other consumers are unaffected._
20+
21+
## Impact
22+
23+
- `src/commands/workflow/shared.ts` — extract `getAvailableChanges` as a public function (validation behavior unchanged)
24+
- `src/commands/workflow/status.ts` — check for available changes before validation, handle empty case gracefully
25+
- Tests for the status command need to cover the new graceful behavior
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Status command exits gracefully when no changes exist
4+
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.
5+
6+
#### Scenario: No changes exist, text mode
7+
- **WHEN** user runs `openspec status` without `--change` and no change directories exist under `openspec/changes/`
8+
- **THEN** the CLI prints `No active changes. Create one with: openspec new change <name>` to stdout and exits with code 0
9+
10+
#### Scenario: No changes exist, JSON mode
11+
- **WHEN** user runs `openspec status --json` without `--change` and no change directories exist
12+
- **THEN** the CLI outputs `{"changes":[],"message":"No active changes."}` as valid JSON to stdout and exits with code 0
13+
14+
### Requirement: Existing status validation behavior is preserved
15+
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.
16+
17+
#### Scenario: Changes exist but --change not specified
18+
- **WHEN** user runs `openspec status` without `--change` and one or more change directories exist
19+
- **THEN** the CLI throws an error listing available changes with the message `Missing required option --change. Available changes: ...`
20+
21+
#### Scenario: Specified change does not exist
22+
- **WHEN** user runs `openspec status --change non-existent`
23+
- **THEN** the CLI throws an error with message `Change 'non-existent' not found`
24+
25+
#### Scenario: Other commands unaffected
26+
- **WHEN** user runs `openspec show` or `openspec instructions` without `--change` and no changes exist
27+
- **THEN** the CLI throws the original `No changes found` error (no behavior change)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
## 1. Implementation
2+
3+
- [x] 1.1 Extract `getAvailableChanges` in `shared.ts` and use it in `statusCommand` to check for changes before calling `validateChangeExists`
4+
- [x] 1.2 In text mode: print `No active changes. Create one with: openspec new change <name>` and return (exit 0)
5+
- [x] 1.3 In JSON mode: output `{"changes":[],"message":"No active changes."}` and return (exit 0)
6+
7+
## 2. Tests
8+
9+
- [x] 2.1 Add test: `openspec status` with no changes exits gracefully with friendly message (text mode)
10+
- [x] 2.2 Add test: `openspec status --json` with no changes returns valid JSON with empty changes array
11+
- [x] 2.3 Verify existing behavior: `openspec status` without `--change` when changes exist still throws missing option error
12+
- [x] 2.4 Verify cross-platform: tests use `path.join()` for any path assertions
13+
14+
## 3. Release
15+
16+
- [x] 3.1 Add changeset describing the fix

src/commands/workflow/shared.ts

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,23 @@ export function getStatusIndicator(status: 'done' | 'ready' | 'blocked'): string
8686
}
8787
}
8888

89+
/**
90+
* Returns the list of available change directory names under openspec/changes/.
91+
* Excludes the archive directory and hidden directories.
92+
*/
93+
export async function getAvailableChanges(projectRoot: string): Promise<string[]> {
94+
const changesPath = path.join(projectRoot, 'openspec', 'changes');
95+
try {
96+
const entries = await fs.promises.readdir(changesPath, { withFileTypes: true });
97+
return entries
98+
.filter((e) => e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.'))
99+
.map((e) => e.name);
100+
} catch (error: unknown) {
101+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
102+
throw error;
103+
}
104+
}
105+
89106
/**
90107
* Validates that a change exists and returns available changes if not.
91108
* Checks directory existence directly to support scaffolded changes (without proposal.md).
@@ -94,22 +111,8 @@ export async function validateChangeExists(
94111
changeName: string | undefined,
95112
projectRoot: string
96113
): Promise<string> {
97-
const changesPath = path.join(projectRoot, 'openspec', 'changes');
98-
99-
// Get all change directories (not just those with proposal.md)
100-
const getAvailableChanges = async (): Promise<string[]> => {
101-
try {
102-
const entries = await fs.promises.readdir(changesPath, { withFileTypes: true });
103-
return entries
104-
.filter((e) => e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.'))
105-
.map((e) => e.name);
106-
} catch {
107-
return [];
108-
}
109-
};
110-
111114
if (!changeName) {
112-
const available = await getAvailableChanges();
115+
const available = await getAvailableChanges(projectRoot);
113116
if (available.length === 0) {
114117
throw new Error('No changes found. Create one with: openspec new change <name>');
115118
}
@@ -125,11 +128,11 @@ export async function validateChangeExists(
125128
}
126129

127130
// Check directory existence directly
128-
const changePath = path.join(changesPath, changeName);
131+
const changePath = path.join(projectRoot, 'openspec', 'changes', changeName);
129132
const exists = fs.existsSync(changePath) && fs.statSync(changePath).isDirectory();
130133

131134
if (!exists) {
132-
const available = await getAvailableChanges();
135+
const available = await getAvailableChanges(projectRoot);
133136
if (available.length === 0) {
134137
throw new Error(
135138
`Change '${changeName}' not found. No changes exist. Create one with: openspec new change <name>`

src/commands/workflow/status.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import {
1515
validateChangeExists,
1616
validateSchemaExists,
17+
getAvailableChanges,
1718
getStatusIndicator,
1819
getStatusColor,
1920
} from './shared.js';
@@ -37,6 +38,27 @@ export async function statusCommand(options: StatusOptions): Promise<void> {
3738

3839
try {
3940
const projectRoot = process.cwd();
41+
42+
// Handle no-changes case gracefully — status is informational,
43+
// so "no changes" is a valid state, not an error.
44+
if (!options.change) {
45+
const available = await getAvailableChanges(projectRoot);
46+
if (available.length === 0) {
47+
spinner.stop();
48+
if (options.json) {
49+
console.log(JSON.stringify({ changes: [], message: 'No active changes.' }, null, 2));
50+
return;
51+
}
52+
console.log('No active changes. Create one with: openspec new change <name>');
53+
return;
54+
}
55+
// Changes exist but --change not provided
56+
spinner.stop();
57+
throw new Error(
58+
`Missing required option --change. Available changes:\n ${available.join('\n ')}`
59+
);
60+
}
61+
4062
const changeName = await validateChangeExists(options.change, projectRoot);
4163

4264
// Validate schema if explicitly provided

test/commands/artifact-workflow.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,22 @@ describe('artifact-workflow CLI commands', () => {
131131
expect(result.stdout).toContain('All artifacts complete!');
132132
});
133133

134+
it('exits gracefully when no changes exist', async () => {
135+
const result = await runCLI(['status'], { cwd: tempDir });
136+
expect(result.exitCode).toBe(0);
137+
expect(result.stdout).toContain('No active changes');
138+
expect(result.stdout).toContain('openspec new change');
139+
});
140+
141+
it('exits gracefully with JSON when no changes exist', async () => {
142+
const result = await runCLI(['status', '--json'], { cwd: tempDir });
143+
expect(result.exitCode).toBe(0);
144+
145+
const json = JSON.parse(result.stdout);
146+
expect(json.changes).toEqual([]);
147+
expect(json.message).toBe('No active changes.');
148+
});
149+
134150
it('errors when --change is missing and lists available changes', async () => {
135151
await createTestChange('some-change');
136152

0 commit comments

Comments
 (0)