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
5 changes: 5 additions & 0 deletions .changeset/graceful-status-no-changes.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions openspec/changes/graceful-status-no-changes/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-25
38 changes: 38 additions & 0 deletions openspec/changes/graceful-status-no-changes/design.md
Original file line number Diff line number Diff line change
@@ -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 <name>`. 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 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.
25 changes: 25 additions & 0 deletions openspec/changes/graceful-status-no-changes/proposal.md
Original file line number Diff line number Diff line change
@@ -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 <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).

## 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 — `validateChangeExists` was internally refactored to delegate to the newly exported `getAvailableChanges`, but its behavior and public contract are unchanged. 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
Original file line number Diff line number Diff line change
@@ -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 <name>` 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)
16 changes: 16 additions & 0 deletions openspec/changes/graceful-status-no-changes/tasks.md
Original file line number Diff line number Diff line change
@@ -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 <name>` 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
37 changes: 20 additions & 17 deletions src/commands/workflow/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,23 @@ 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<string[]> {
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 (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw error;
}
}

/**
* Validates that a change exists and returns available changes if not.
* Checks directory existence directly to support scaffolded changes (without proposal.md).
Expand All @@ -94,22 +111,8 @@ export async function validateChangeExists(
changeName: string | undefined,
projectRoot: string
): Promise<string> {
const changesPath = path.join(projectRoot, 'openspec', 'changes');

// Get all change directories (not just those with proposal.md)
const getAvailableChanges = async (): Promise<string[]> => {
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 <name>');
}
Expand All @@ -125,11 +128,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 <name>`
Expand Down
22 changes: 22 additions & 0 deletions src/commands/workflow/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import {
validateChangeExists,
validateSchemaExists,
getAvailableChanges,
getStatusIndicator,
getStatusColor,
} from './shared.js';
Expand All @@ -37,6 +38,27 @@ export async function statusCommand(options: StatusOptions): Promise<void> {

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 <name>');
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
Expand Down
16 changes: 16 additions & 0 deletions test/commands/artifact-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down