Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale 36a975153f restore missing opencode spec 2025-10-01 11:47:51 +10:00
Tabish Bidiwale 728243d2c2 Merge branch 'main' into codex/research-slash-command-support-for-windsurf 2025-10-01 11:19:06 +10:00
Tabish Bidiwale adc63069a9 chore(release): version packages (#100) 2025-09-30 17:35:29 +10:00
Tabish Bidiwale f8eca37796 feat(init): slim root agent instructions (#98)
* feat(init): slim root agent instructions

* Fix marker updates to ignore inline mentions

* styling updates

* update instructions

* fix tests
2025-09-30 17:03:56 +10:00
Tabish Bidiwale 19f7357ecb docs(windsurf): propose workflow support 2025-09-30 09:07:02 +10:00
Tabish Bidiwale a908dc5a05 chore(release): version packages (#93)
Bump version to 0.5.0 with new features and improvements:
- E2E testing with cross-platform CI matrix
- Improved apply instructions
- Documentation improvements and cleanup
2025-09-29 23:47:22 +10:00
Tabish Bidiwale b46f99b9bc Make apply instructions more specific (#92) 2025-09-29 23:41:24 +10:00
Tabish Bidiwale 6f7cc2abd2 archive completed changes (#91) 2025-09-29 23:03:04 +10:00
Tabish Bidiwale 4867bfade5 feat: implement Phase 1 E2E testing with cross-platform CI matrix (#80)
* feat: implement Phase 1 E2E testing with cross-platform CI matrix

- Add shared runCLI helper in test/helpers/run-cli.ts for spawn testing
- Create test/cli-e2e/basic.test.ts covering help, version, validate flows
- Migrate existing CLI exec tests to use runCLI helper
- Extend CI matrix to bash (Linux/macOS) and pwsh (Windows)
- Update Phase 1 tasks and proposal with implementation status

* fix: correct YAML syntax in CI workflow diagnostics command

* fix: use multiline YAML for diagnostics command

* fix ci

* fix: ci

* fix: update core validation and json converter

* chore(ci): split pr and main workflows

* refactor: simplify CI workflow with unified matrix strategy

- Consolidate test_pr and test_matrix into single test job
- Add proper shell configuration with defaults
- Add timeout protection (15 minutes)
- Simplify required-checks to single job
- Maintain cross-platform testing (bash on Linux/macOS, pwsh on Windows)

* fix: restore lean PR workflow with async main branch matrix

- PRs run only essential tests on ubuntu-latest (fast feedback)
- Main branch runs full cross-platform matrix asynchronously
- Separate required-checks for each workflow type
- Different timeouts: 10min for PR, 15min for matrix
2025-09-29 22:20:30 +10:00
Tabish Bidiwale 7359b4846a docs(archive): document non-interactive flag (#90) 2025-09-29 15:59:51 +10:00
Tabish Bidiwale 88526e6b93 docs(readme): replace discord badge (#87) 2025-09-26 18:23:37 +10:00
Tabish Bidiwale 367aa12892 chore(release): version packages (#86) 2025-09-26 16:03:15 +10:00
James G. Best dcfb6afe0c feat: add Opencode slash commands (#83)
* First pass at adding Opencode slash commands

* Fix the agent

* Pass in Arguments to opencode slash commands

* Remove unneeded agents file
2025-09-26 01:37:30 +10:00
Tabish Bidiwale 86925b2b2d docs: add --yes flag to archive command template (#84) 2025-09-26 00:32:56 +10:00
Tabish Bidiwale 604ecb8bd1 fix: normalize line endings in markdown parser to handle CRLF files (#79)
Fixes validation errors on Windows by normalizing CRLF/CR line endings
to LF before parsing sections. Adds comprehensive test coverage for
CRLF handling in both unit and integration tests.
2025-09-25 15:59:09 +10:00
Tabish Bidiwale 5a4837c37d feat: add OpenSpec change proposals for CLI improvements (#78)
* feat: add CLI e2e testing improvement plan

## Summary
- Add phased approach to stabilize CLI spawn testing
- Expand cross-shell/OS matrix coverage when stable
- Optional packaging validation for CI environments

* feat: add markdown parser CRLF fix proposal and update e2e plan

- Add comprehensive proposal for fixing CRLF parsing issues on Windows
- Update CLI path references from dist/cli.js to dist/cli/index.js
- Streamline e2e testing tasks based on refined approach

* fix: correct spec delta to add parsing requirement instead of modifying remediation

- Change from MODIFIED to ADDED Requirements for cross-platform line ending parsing
- Create focused requirement for parser behavior rather than validation messages
- Maintain logical coherence between requirement and scenario
2025-09-25 14:53:21 +10:00
Tabish Bidiwale 9d9539aaa2 docs: add Discord badge (#73)
* docs: add discord badge

* chore: ignore ds store
2025-09-24 03:10:13 +10:00
Tabish Bidiwale c3fecf0619 chore: add codeowners (#72) 2025-09-19 11:56:37 +10:00
83 changed files with 1685 additions and 718 deletions
+2
View File
@@ -0,0 +1,2 @@
# Default code ownership
* @TabishB
+89 -8
View File
@@ -15,10 +15,12 @@ concurrency:
cancel-in-progress: true
jobs:
test:
test_pr:
name: Test
runs-on: ubuntu-latest
timeout-minutes: 10
if: github.event_name == 'pull_request'
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -48,7 +50,68 @@ jobs:
- name: Upload test coverage
uses: actions/upload-artifact@v4
with:
name: coverage-report
name: coverage-report-pr
path: coverage/
retention-days: 7
test_matrix:
name: Test (${{ matrix.label }})
runs-on: ${{ matrix.os }}
timeout-minutes: 15
if: github.event_name != 'pull_request'
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
shell: bash
label: linux-bash
- os: macos-latest
shell: bash
label: macos-bash
- os: windows-latest
shell: pwsh
label: windows-pwsh
defaults:
run:
shell: ${{ matrix.shell }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Print environment diagnostics
run: |
node -p "JSON.stringify({ platform: process.platform, arch: process.arch, shell: process.env.SHELL || process.env.ComSpec || '' })"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project
run: pnpm run build
- name: Run tests
run: pnpm test
- name: Upload test coverage
if: matrix.os == 'ubuntu-latest'
uses: actions/upload-artifact@v4
with:
name: coverage-report-main
path: coverage/
retention-days: 7
@@ -122,15 +185,15 @@ jobs:
echo "Changesets not configured, skipping validation"
fi
required-checks:
required-checks-pr:
name: All checks passed
runs-on: ubuntu-latest
needs: [test, lint]
if: always()
needs: [test_pr, lint]
if: always() && github.event_name == 'pull_request'
steps:
- name: Verify all checks passed
run: |
if [[ "${{ needs.test.result }}" != "success" ]]; then
if [[ "${{ needs.test_pr.result }}" != "success" ]]; then
echo "Test job failed"
exit 1
fi
@@ -138,4 +201,22 @@ jobs:
echo "Lint job failed"
exit 1
fi
echo "All required checks passed!"
echo "All required checks passed!"
required-checks-main:
name: All checks passed
runs-on: ubuntu-latest
needs: [test_matrix, lint]
if: always() && github.event_name != 'pull_request'
steps:
- name: Verify all checks passed
run: |
if [[ "${{ needs.test_matrix.result }}" != "success" ]]; then
echo "Matrix test job failed"
exit 1
fi
if [[ "${{ needs.lint.result }}" != "success" ]]; then
echo "Lint job failed"
exit 1
fi
echo "All required checks passed!"
+2 -1
View File
@@ -145,4 +145,5 @@ docs/
# Claude
.claude/
CLAUDE.md
CLAUDE.md
.DS_Store
+4 -36
View File
@@ -1,40 +1,8 @@
<!-- OPENSPEC:START -->
# OpenSpec Project
# OpenSpec Instructions
This document provides instructions for AI coding assistants on how to use OpenSpec conventions for spec-driven development. Follow these rules precisely when working on OpenSpec-enabled projects.
This project uses OpenSpec to manage AI assistant workflows.
This project uses OpenSpec for spec-driven development. Specifications are the source of truth.
See @openspec/AGENTS.md for detailed conventions and guidelines.
- Full guidance lives in '@/openspec/AGENTS.md'.
- Keep this managed block so 'openspec update' can refresh the instructions.
<!-- OPENSPEC:END -->
## Complexity Management
**Default to minimal solutions:**
- Propose <100 lines of new code for features
- Prefer single-file implementations until proven insufficient
- Avoid frameworks, abstractions, and optimizations without clear justification
- Choose boring, well-understood patterns over novel approaches
**Question requests for complexity:**
- Caching? → Ask for performance data and targets
- New framework? → Suggest plain code first
- Extra layers? → Start with the thinnest viable design
**Justify complexity with data:**
- Performance metrics showing current solution is too slow
- Concrete scale requirements (e.g., >1000 users, >100MB data)
- Multiple proven use cases requiring an abstraction
## Package Manager
Always use pnpm (NOT npm or yarn) for all Node.js package management:
- Install dependencies: `pnpm install`
- Add packages: `pnpm add [package]`
- Run scripts: `pnpm run [script]`
## Git Commits
Use conventional commits with these rules:
- Format: `type(scope): subject` (e.g., `fix: resolve auth error`, `feat(api): add user endpoint`)
- Keep commit messages to ONE line only - no body or footer
- Common types: feat, fix, docs, style, refactor, test, chore
- Never add co-authorship lines or attribution
+42
View File
@@ -1,5 +1,47 @@
# @fission-ai/openspec
## 0.6.0
### Minor Changes
- Slim the generated root agent instructions down to a managed hand-off stub and update the init/update flows to refresh it safely.
## 0.5.0
### Minor Changes
- feat: implement Phase 1 E2E testing with cross-platform CI matrix
- Add shared runCLI helper in test/helpers/run-cli.ts for spawn testing
- Create test/cli-e2e/basic.test.ts covering help, version, validate flows
- Migrate existing CLI exec tests to use runCLI helper
- Extend CI matrix to bash (Linux/macOS) and pwsh (Windows)
- Split PR and main workflows for optimized feedback
### Patch Changes
- Make apply instructions more specific
Improve agent templates and slash command templates with more specific and actionable apply instructions.
- docs: improve documentation and cleanup
- Document non-interactive flag for archive command
- Replace discord badge in README
- Archive completed changes for better organization
## 0.4.0
### Minor Changes
- Add OpenSpec change proposals for CLI improvements and enhanced user experience
- Add Opencode slash commands support for AI-driven development workflows
### Patch Changes
- Add documentation improvements including --yes flag for archive command template and Discord badge
- Fix normalize line endings in markdown parser to handle CRLF files properly
## 0.3.0
### Minor Changes
+7 -5
View File
@@ -15,6 +15,7 @@
<a href="https://nodejs.org/"><img alt="node version" src="https://img.shields.io/node/v/@fission-ai/openspec?style=flat-square" /></a>
<a href="./LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square" /></a>
<a href="https://conventionalcommits.org"><img alt="Conventional Commits" src="https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg?style=flat-square" /></a>
<a href="https://discord.gg/saTQQGQZ"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20the%20community-5865F2?logo=discord&logoColor=white&style=flat-square" /></a>
</p>
<p align="center">
@@ -22,7 +23,7 @@
</p>
<p align="center">
Follow <a href="https://x.com/0xTab">@0xTab on X</a> for updates.
Follow <a href="https://x.com/0xTab">@0xTab on X</a> for updates · Join the <a href="https://discord.gg/saTQQGQZ">OpenSpec Discord</a> for help and questions.
</p>
# OpenSpec
@@ -82,13 +83,14 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe
|------|----------|
| **Claude Code** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` |
| **Cursor** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
| **OpenCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
#### AGENTS.md Compatible
These tools automatically read workflow instructions from `openspec/AGENTS.md`. Ask them to follow the OpenSpec workflow if they need a reminder. Learn more about the [AGENTS.md convention](https://agents.md/).
| Tools |
|-------|
| Codex • Amp • Jules • OpenCode • Gemini CLI • GitHub Copilot • Others |
| Codex • Amp • Jules • Gemini CLI • GitHub Copilot • Others |
### Install & Initialize
@@ -183,13 +185,13 @@ You: Please archive the change
(Shortcut for tools with slash commands: /openspec:archive add-profile-filters)
AI: I'll archive the add-profile-filters change.
*Runs: openspec archive add-profile-filters*
*Runs: openspec archive add-profile-filters --yes*
✓ Change archived successfully. Specs updated. Ready for the next feature!
```
Or run the command yourself in terminal:
```bash
$ openspec archive add-profile-filters # Archive the completed change
$ openspec archive add-profile-filters --yes # Archive the completed change without prompts
```
**Note:** Tools with native slash commands (Claude Code, Cursor) can use the shortcuts shown. All other tools work with natural language requests to "create an OpenSpec proposal", "apply the OpenSpec change", or "archive the change".
@@ -201,7 +203,7 @@ openspec list # View active change folders
openspec view # Interactive dashboard of specs and changes
openspec show <change> # Display change details (proposal, tasks, spec updates)
openspec validate <change> # Check spec formatting and structure
openspec archive <change> # Move a completed change into archive/
openspec archive <change> [--yes|-y] # Move a completed change into archive/ (non-interactive with --yes)
```
## Example: How AI Creates OpenSpec Files
+12 -4
View File
@@ -1,7 +1,15 @@
#!/usr/bin/env node
import { execSync } from 'child_process';
import { execFileSync } from 'child_process';
import { existsSync, rmSync } from 'fs';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const runTsc = (args = []) => {
const tscPath = require.resolve('typescript/bin/tsc');
execFileSync(process.execPath, [tscPath, ...args], { stdio: 'inherit' });
};
console.log('🔨 Building OpenSpec...\n');
@@ -14,10 +22,10 @@ if (existsSync('dist')) {
// Run TypeScript compiler (use local version explicitly)
console.log('Compiling TypeScript...');
try {
execSync('./node_modules/.bin/tsc -v', { stdio: 'inherit' });
execSync('./node_modules/.bin/tsc', { stdio: 'inherit' });
runTsc(['--version']);
runTsc();
console.log('\n✅ Build completed successfully!');
} catch (error) {
console.error('\n❌ Build failed!');
process.exit(1);
}
}
+8 -5
View File
@@ -47,18 +47,20 @@ Skip proposal for:
4. Run `openspec validate <id> --strict` and resolve any issues before sharing the proposal.
### Stage 2: Implementing Changes
Track these steps as TODOs and complete them one by one.
1. **Read proposal.md** - Understand what's being built
2. **Read design.md** (if exists) - Review technical decisions
3. **Read tasks.md** - Get implementation checklist
4. **Implement tasks sequentially** - Complete in order
5. **Mark complete immediately** - Update `- [x]` after each task
6. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses
6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality
7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
### Stage 3: Archiving Changes
After deployment, create separate PR to:
- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/`
- Update `specs/` if capabilities changed
- Use `openspec archive [change] --skip-specs` for tooling-only changes
- Use `openspec archive [change] --skip-specs --yes` for tooling-only changes
- Run `openspec validate --strict` to confirm the archived change passes checks
## Before Any Task
@@ -95,7 +97,7 @@ openspec list --specs # List specifications
openspec show [item] # Display change or spec
openspec diff [change] # Show spec differences
openspec validate [item] # Validate changes or specs
openspec archive [change] # Archive after deployment
openspec archive [change] [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
# Project management
openspec init [path] # Initialize OpenSpec
@@ -117,6 +119,7 @@ openspec validate [change] --strict
- `--strict` - Comprehensive validation
- `--no-interactive` - Disable prompts
- `--skip-specs` - Archive without spec updates
- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive)
## Directory Structure
@@ -447,7 +450,7 @@ openspec list # What's in progress?
openspec show [item] # View details
openspec diff [change] # What's changing?
openspec validate --strict # Is it correct?
openspec archive [change] # Mark complete
openspec archive [change] [--yes|-y] # Mark complete (add --yes for automation)
```
Remember: Specs are truth. Changes are proposals. Keep them in sync.
@@ -0,0 +1,17 @@
## Why
- Windsurf exposes "Workflows" as the vehicle for slash-like automation: saved Markdown files under `.windsurf/workflows/` that Cascade discovers across the workspace (including subdirectories and up to the git root), then executes when a user types `/workflow-name`. These files can be team-authored, must stay under 12k characters, and can call other workflows, making them the natural place to publish OpenSpec guidance for Windsurf users.\
([Windsurf Workflows documentation](https://docs.windsurf.com/windsurf/cascade/workflows))
- The Wave 12 changelog reiterates that workflows are invoked via slash commands and that Windsurf stores them in `.windsurf/workflows`, so the OpenSpec CLI just needs to generate Markdown there to participate in Windsurf's command palette.\
("Custom Workflows" section, [Windsurf changelog](https://windsurf.com/changelog))
- OpenSpec already ships shared command bodies for proposal/apply/archive and uses markers so commands stay up to date. Extending the same templates to Windsurf keeps behaviour consistent with Claude, Cursor, and OpenCode without inventing new content flows.
## What Changes
- Add Windsurf to the CLI tool picker (`openspec init`) and the slash-command registry so selecting it scaffolds `.windsurf/workflows/openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` with marker-managed bodies.
- Shape each Windsurf workflow with a short heading/description plus the existing OpenSpec guardrails/steps wrapped in markers, ensuring the total payload remains well below the 12,000 character limit.
- Ensure `openspec update` refreshes existing Windsurf workflows (and only those that already exist) in-place, mirroring current behaviour for other editors.
- Extend unit tests for init/update to cover Windsurf generation and updates, and update the README/tooling docs to advertise Windsurf support.
## Impact
- Specs: `cli-init`, `cli-update`
- Code: `src/core/configurators/slash/*`, `src/core/templates/slash-command-templates.ts`, CLI prompts, README
- Tests: init/update integration coverage for Windsurf workflows
@@ -0,0 +1,23 @@
## MODIFIED Requirements
### Requirement: AI Tool Configuration
The command SHALL configure AI coding assistants with OpenSpec instructions using a marker system.
#### Scenario: Prompting for AI tool selection
- **WHEN** run interactively
- **THEN** prompt the user with "Which AI tools do you use?" using a multi-select menu
- **AND** list every available tool with a checkbox:
- Claude Code (creates or refreshes CLAUDE.md and slash commands)
- Cursor (creates or refreshes `.cursor/commands/*` slash commands)
- OpenCode (creates or refreshes `.opencode/command/openspec-*.md` slash commands)
- Windsurf (creates or refreshes `.windsurf/workflows/openspec-*.md` workflows)
- AGENTS.md standard (creates or refreshes AGENTS.md with OpenSpec markers)
- **AND** show "(already configured)" beside tools whose managed files exist so users understand selections will refresh content
- **AND** treat disabled tools as "coming soon" and keep them unselectable
- **AND** allow confirming with Enter after selecting one or more tools
### Requirement: Slash Command Configuration
The init command SHALL generate slash command files for supported editors using shared templates.
#### Scenario: Generating slash commands for Windsurf
- **WHEN** the user selects Windsurf during initialization
- **THEN** create `.windsurf/workflows/openspec-proposal.md`, `.windsurf/workflows/openspec-apply.md`, and `.windsurf/workflows/openspec-archive.md`
- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
@@ -0,0 +1,8 @@
## MODIFIED Requirements
### Requirement: Slash Command Updates
The update command SHALL refresh existing slash command files for configured tools without creating new ones.
#### Scenario: Updating slash commands for Windsurf
- **WHEN** `.windsurf/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using shared templates wrapped in OpenSpec markers
- **AND** ensure templates include instructions for the relevant workflow stage
- **AND** skip creating missing files (the update command only refreshes what already exists)
@@ -0,0 +1,17 @@
## 1. CLI wiring
- [ ] 1.1 Add Windsurf to the selectable AI tools in `openspec init`, including "already configured" detection.
- [ ] 1.2 Register a `WindsurfSlashCommandConfigurator` that writes workflows to `.windsurf/workflows/` and ensures the directory exists.
- [ ] 1.3 Ensure `openspec update` pulls the Windsurf configurator when winds is selected and skips creation when files are absent.
## 2. Workflow templates
- [ ] 2.1 Reuse the shared proposal/apply/archive bodies, adding Windsurf-specific headings/description before the OpenSpec markers.
- [ ] 2.2 Confirm generated Markdown (per file) stays comfortably under the 12k character ceiling noted in the Windsurf docs.
## 3. Tests & safeguards
- [ ] 3.1 Extend init tests to assert creation of `.windsurf/workflows/openspec-*.md` when Windsurf is chosen.
- [ ] 3.2 Extend update tests to assert existing Windsurf workflows are refreshed and non-existent files are ignored.
- [ ] 3.3 Add regression coverage for marker preservation inside Windsurf workflow files.
## 4. Documentation
- [ ] 4.1 Update README (and any user-facing docs) to list Windsurf under native slash/workflow integrations.
- [ ] 4.2 Call out Windsurf workflow support in release notes or CHANGELOG if applicable.
@@ -13,3 +13,9 @@ The init command SHALL generate slash command files for supported editors using
- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for OpenCode
- **WHEN** the user selects OpenCode during initialization
- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
@@ -12,6 +12,11 @@ The update command SHALL refresh existing slash command files for configured too
- **THEN** refresh each file using shared templates
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Updating slash commands for OpenCode
- **WHEN** `.opencode/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using shared templates
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Missing slash command file
- **WHEN** a tool lacks a slash command file
- **THEN** do not create a new file during update
@@ -14,3 +14,7 @@
## 4. Verification
- [x] 4.1 Add tests verifying slash command files are created and updated correctly.
## 5. OpenCode Integration
- [x] 5.1 Generate `.opencode/commands/{openspec-proposal,openspec-apply,openspec-archive}.md` during `openspec init` using shared templates.
- [x] 5.2 Update existing `.opencode/commands/*` files during `openspec update`.
@@ -0,0 +1,19 @@
## Why
Recent cross-shell regressions for `openspec` commands revealed that our existing unit/integration tests do not exercise the packaged CLI or shell-specific behavior. The prior attempt at Vitest spawn tests stalled because it coupled e2e coverage with `pnpm pack` installs, which fail in network-restricted environments. With those findings incorporated, we now need an approved plan to realign the work.
## What Changes
- Adopt a phased strategy that first stabilizes direct spawn testing of the built CLI (`node dist/cli/index.js`) using lightweight fixtures and a shared `runCLI` helper.
- Expand coverage once the spawn harness is stable, keeping the initial matrix focused on bash jobs for Linux/macOS and `pwsh` on Windows while exercising both the direct `node dist/cli/index.js` invocation and the bin shim with non-TTY defaults and captured diagnostics.
- Treat packaging/install validation as an optional CI safeguard: when a runner has registry access, run a simple pnpm-based pack→install→smoke-test flow; otherwise document it as out of scope while closing remaining hardening items.
- Close out the remaining cross-shell hardening items: ensure `.gitattributes` covers packaged assets, enforce executable bits for CLI shims during CI, and finish the pending SIGINT handling improvements.
## Impact
- Tests: add `test/cli-e2e` spawn suite, create the shared `runCLI` helper, and adjust `vitest.setup.ts` as needed.
- Tooling: update GitHub Actions workflows with the lightweight matrix above and (optionally) a packaging install check where network is available.
- Docs: note phase progress and any limitations inline in this proposal (or the relevant spec) so future phases have clear context.
### Phase 1 Status
- Shared `test/helpers/run-cli.ts` guarantees the CLI bundle exists before spawning and enforces non-TTY defaults for every invocation.
- New `test/cli-e2e/basic.test.ts` covers `--help`, `--version`, a successful `validate --all --json`, and an unknown-item error path against the `tmp-init` fixture copy.
- Legacy top-level `validate` exec tests now rely on `runCLI`, avoiding manual `execSync` usage while keeping their fixture authoring intact.
- CI matrix groundwork is in place (bash on Linux/macOS, pwsh on Windows) so the spawn suite runs the same way the helper does across supported shells.
@@ -0,0 +1,9 @@
## 1. Phase 1 – Stabilize Local Spawn Coverage
- [x] 1.1 Add `test/helpers/run-cli.ts` that ensures the build runs once and executes `node dist/cli/index.js` with non-TTY defaults; update `vitest.setup.ts` to reuse the shared build step.
- [x] 1.2 Seed `test/cli-e2e` using the minimal fixture set (`tmp-init` or copy) to cover help/version, a happy-path `validate`, and a representative error flow via the new helper.
- [x] 1.3 Migrate the highest-value existing CLI exec tests (e.g., validate) onto `runCLI` and summarize Phase 1 coverage in this proposal for the next phase.
## 2. Phase 2 – Expand Cross-Shell Validation
- [x] 2.1 Exercise both entry points (`node dist/cli/index.js`, `bin/openspec.js`) in the spawn suite and add diagnostics for shell/OS context.
- [x] 2.2 Extend GitHub Actions to run the spawn suite on bash jobs for Linux/macOS and a `pwsh` job on Windows; capture shell/OS diagnostics and note follow-ups for additional shells.
@@ -21,5 +21,5 @@
## 5. Optional (Not Needed Now)
- [x] 5.1 Add optional root param to discovery helpers (default process.cwd())
- [ ] 5.2 Consider threading root through command constructors if ever required
@@ -0,0 +1,12 @@
## 1. Planning & Spec Updates
- [x] 1.1 Confirm overlap with `add-multi-agent-init` and coordinate extend-mode flow
- [x] 1.2 Update `openspec/specs/cli-init/spec.md` to capture multi-select onboarding requirements
## 2. Implementation
- [x] 2.1 Add multi-select support to the `openspec init` prompt, including indicators for existing tool configs
- [x] 2.2 Enhance success messaging to summarize created/refreshed assets per tool
- [x] 2.3 Ensure shared instruction template is applied consistently (CLAUDE.md, AGENTS.md, slash commands)
## 3. Quality
- [x] 3.1 Expand unit tests for init/update flows covering multi-select and summaries
- [x] 3.2 Perform `openspec init` smoke test in a temp directory (document output)
@@ -26,10 +26,6 @@
- Archive documentation
- Change proposals
## 6. Add Deprecation Notice (Optional Phase)
- [ ] Consider adding a deprecation warning before full removal
- [ ] Provide helpful message directing users to `openspec show` command
## 7. Testing
- [x] Ensure all tests pass after removal
- [x] Verify CLI help text no longer shows diff command
@@ -25,12 +25,16 @@ The command SHALL generate required template files with appropriate content for
### Requirement: AI Tool Configuration Details
The command SHALL properly configure selected AI tools with OpenSpec-specific instructions using a marker system.
#### Scenario: Creating new CLAUDE.md
- **WHEN** CLAUDE.md does not exist
- **THEN** create new file with OpenSpec content wrapped in markers including reference to `@openspec/AGENTS.md`
### Requirement: Success Output
The command SHALL provide clear, actionable next steps upon successful initialization.
#### Scenario: Displaying success message
- **WHEN** initialization completes successfully
- **THEN** include prompt: "Please explain the OpenSpec workflow from openspec/AGENTS.md and how I should work with you on this project"
@@ -0,0 +1,19 @@
# Update Markdown Parser CRLF Handling
## Problem
Windows users report that `openspec validate` raises “Change must have a Why section” even when the section exists (see GitHub issue #77). The CLI currently splits markdown on `\n` and compares headers without stripping `\r`, so files saved with CRLF line endings keep a trailing carriage return in the header token. As a result the parser fails to detect `## Why`/`## What Changes`, triggering false validation errors and breaking the workflow on Windows-default editors.
## Solution
- Normalize markdown content inside the parser so CRLF and lone-CR inputs are treated as `\n` before section detection, trimming any carriage returns from titles and content comparisons.
- Reuse the normalized reader everywhere `MarkdownParser` is constructed to keep behavior consistent for validation, view, spec, and list flows.
- Add regression coverage that reproduces the failure (unit test around `parseChange` and a CLI spawn/e2e test that writes a CRLF change then runs `openspec validate`).
- Update the `cli-validate` spec to codify the expectation that required sections are recognized regardless of line-ending style.
## Benefits
- Restores correct validation behavior for Windows editors without requiring manual line-ending conversion.
- Locks in the fix with targeted tests so future parser refactors keep cross-platform support.
- Clarifies the spec so downstream work (e.g., cross-shell e2e plan) understands the non-negotiable behavior.
## Risks
- Low: parser normalization touches shared code paths that parse specs and changes; need to ensure no regressions in other command consumers (mitigated by existing parser tests plus the new CRLF fixtures).
@@ -0,0 +1,9 @@
## ADDED Requirements
### Requirement: Parser SHALL handle cross-platform line endings
The markdown parser SHALL correctly identify sections regardless of line ending format (LF, CRLF, CR).
#### Scenario: Required sections parsed with CRLF line endings
- **GIVEN** a change proposal markdown saved with CRLF line endings
- **AND** the document contains `## Why` and `## What Changes`
- **WHEN** running `openspec validate <change-id>`
- **THEN** validation SHALL recognize the sections and NOT raise parsing errors
@@ -0,0 +1,11 @@
## 1. Guard the regression
- [x] 1.1 Add a unit test that feeds a CRLF change document into `MarkdownParser.parseChange` and asserts `Why`/`What Changes` are detected.
- [x] 1.2 Add a CLI spawn/e2e test that writes a CRLF change, runs `openspec validate`, and expects success.
## 2. Normalize parsing
- [x] 2.1 Normalize line endings when constructing `MarkdownParser` so headers and content comparisons ignore `\r`.
- [x] 2.2 Ensure all CLI entry points (validate, view, spec conversion) reuse the normalized parser path.
## 3. Document and verify
- [x] 3.1 Update the `cli-validate` spec with a scenario covering CRLF line endings.
- [x] 3.2 Run the parser and CLI test suites (`pnpm test`, relevant spawn tests) to confirm the fix.
@@ -1,12 +0,0 @@
## 1. Planning & Spec Updates
- [ ] 1.1 Confirm overlap with `add-multi-agent-init` and coordinate extend-mode flow
- [ ] 1.2 Update `openspec/specs/cli-init/spec.md` to capture multi-select onboarding requirements
## 2. Implementation
- [ ] 2.1 Add multi-select support to the `openspec init` prompt, including indicators for existing tool configs
- [ ] 2.2 Enhance success messaging to summarize created/refreshed assets per tool
- [ ] 2.3 Ensure shared instruction template is applied consistently (CLAUDE.md, AGENTS.md, slash commands)
## 3. Quality
- [ ] 3.1 Expand unit tests for init/update flows covering multi-select and summaries
- [ ] 3.2 Perform `openspec init` smoke test in a temp directory (document output)
@@ -0,0 +1,13 @@
## Why
The project root currently receives a full copy of the OpenSpec agent instructions, duplicating the content that also lives in `openspec/AGENTS.md`. When teams edit one copy but not the other, the files drift and onboarding assistants see conflicting guidance.
## What Changes
- Keep generating the complete template in `openspec/AGENTS.md` during `openspec init` and follow-up updates.
- Replace the root-level file (`AGENTS.md` or `CLAUDE.md`, depending on tool selection) with a short hand-off that explains the project uses OpenSpec and points directly to `openspec/AGENTS.md`.
- Add a dedicated stub template so both the init and update flows reuse the same minimal copy instructions.
- Update CLI tests and documentation to reflect the new root-level messaging and ensure the OpenSpec marker block still protects future updates.
## Impact
- Affected specs: `cli-init`, `cli-update`
- Affected code: `src/core/init.ts`, `src/core/update.ts`, `src/core/templates/agents-template.ts`
- Update assets/readmes that mention the root `AGENTS.md` contents to reference the new stub message.
@@ -0,0 +1,15 @@
## 1. Templates
- [x] 1.1 Add a shared stub template that renders the root agent instructions hand-off message.
- [x] 1.2 Ensure the stub covers both `AGENTS.md` and `CLAUDE.md` variants.
## 2. Init Flow
- [x] 2.1 Update `createInitArtifacts` to write the stub to the project root instead of the full instructions.
- [x] 2.2 Preserve the managed block markers so future updates can overwrite the stub safely.
## 3. Update Flow
- [x] 3.1 Make the update command refresh the root stub rather than the full instructions.
- [x] 3.2 Confirm the update log output still reflects the files that changed.
## 4. Tests & Docs
- [x] 4.1 Adjust CLI/init tests to match the new root content.
- [x] 4.2 Document the stub message in `openspec/specs/cli-init` and `openspec/specs/cli-update` (and any relevant README snippets).
+72 -91
View File
@@ -3,9 +3,7 @@
## Purpose
The `openspec init` command SHALL create a complete OpenSpec directory structure in any project, enabling immediate adoption of OpenSpec conventions with support for multiple AI coding assistants.
## Requirements
### Requirement: Progress Indicators
The command SHALL display progress indicators during initialization to provide clear feedback about each step.
@@ -21,11 +19,9 @@ The command SHALL display progress indicators during initialization to provide c
- Then success: "✔ AI tools configured"
### Requirement: Directory Creation
The command SHALL create the complete OpenSpec directory structure with all required directories and files.
#### Scenario: Creating OpenSpec structure
- **WHEN** `openspec init` is executed
- **THEN** create the following directory structure:
```
@@ -38,13 +34,11 @@ openspec/
```
### Requirement: File Generation
The command SHALL generate required template files with appropriate content for immediate use.
#### Scenario: Generating template files
- **WHEN** initializing OpenSpec
- **THEN** generate `AGENTS.md` containing complete OpenSpec instructions for AI assistants
- **THEN** generate `openspec/AGENTS.md` containing complete OpenSpec instructions for AI assistants
- **AND** generate `project.md` with project context template
### Requirement: AI Tool Configuration
@@ -54,10 +48,14 @@ The command SHALL configure AI coding assistants with OpenSpec instructions base
#### Scenario: Prompting for AI tool selection
- **WHEN** run interactively
- **THEN** prompt user to select AI tools to configure:
- Claude Code (updates/creates CLAUDE.md with OpenSpec markers)
- Cursor (future)
- Aider (future)
- **THEN** prompt the user with "Which AI tools do you use?" using a multi-select menu
- **AND** list every available tool with a checkbox:
- Claude Code (creates or refreshes CLAUDE.md and slash commands)
- Cursor (creates or refreshes `.cursor/commands/*` slash commands)
- AGENTS.md standard (creates or refreshes AGENTS.md stub with OpenSpec markers)
- **AND** show "(already configured)" beside tools whose managed files exist so users understand selections will refresh content
- **AND** treat disabled tools as "coming soon" and keep them unselectable
- **AND** allow confirming with Enter after selecting one or more tools
### Requirement: AI Tool Configuration Details
@@ -67,112 +65,49 @@ The command SHALL properly configure selected AI tools with OpenSpec-specific in
- **WHEN** Claude Code is selected
- **THEN** create or update `CLAUDE.md` in the project root directory (not inside openspec/)
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
#### Scenario: Creating new CLAUDE.md
- **WHEN** CLAUDE.md does not exist
- **THEN** create new file with OpenSpec content wrapped in markers:
- **THEN** create new file with stub instructions wrapped in markers so the full workflow stays in `openspec/AGENTS.md`:
```markdown
<!-- OPENSPEC:START -->
# OpenSpec Project
# OpenSpec Instructions
This document provides instructions for AI coding assistants on how to use OpenSpec conventions for spec-driven development. Follow these rules precisely when working on OpenSpec-enabled projects.
This project uses OpenSpec to manage AI assistant workflows.
This project uses OpenSpec for spec-driven development. Specifications are the source of truth.
See @openspec/AGENTS.md for detailed conventions and guidelines.
- Full guidance lives in '@/openspec/AGENTS.md'.
- Keep this managed block so 'openspec update' can refresh the instructions.
<!-- OPENSPEC:END -->
```
#### Scenario: Updating existing CLAUDE.md
- **WHEN** CLAUDE.md already exists
- **THEN** preserve all existing content
- **AND** insert OpenSpec content at the beginning of the file using markers
- **AND** ensure markers don't duplicate if they already exist
#### Scenario: Managing content with markers
- **WHEN** using the marker system
- **THEN** use `<!-- OPENSPEC:START -->` to mark the beginning of managed content
- **AND** use `<!-- OPENSPEC:END -->` to mark the end of managed content
- **AND** allow OpenSpec to update its content without affecting user customizations
- **AND** preserve all content outside the markers intact
WHY use markers:
- Users may have existing CLAUDE.md instructions they want to keep
- OpenSpec can update its instructions in future versions
- Clear boundary between OpenSpec-managed and user-managed content
### Requirement: Interactive Mode
The command SHALL provide an interactive menu for AI tool selection with clear navigation instructions.
#### Scenario: Displaying interactive menu
- **WHEN** run
- **THEN** prompt user with: "Which AI tool do you use?"
- **AND** show single-select menu with available tools:
- Claude Code
- **AND** show disabled options as "coming soon" (not selectable):
- Cursor (coming soon)
- Aider (coming soon)
- Continue (coming soon)
#### Scenario: Navigating the menu
- **WHEN** user is in the menu
- **THEN** allow arrow keys to move between options
- **AND** allow Enter key to select the highlighted option
- **WHEN** run in fresh or extend mode
- **THEN** present a looping select menu that lets users toggle tools with Enter and finish via a "Done" option
- **AND** label already configured tools with "(already configured)" while keeping disabled options marked "coming soon"
- **AND** change the prompt copy in extend mode to "Which AI tools would you like to add or refresh?"
- **AND** display inline instructions clarifying that Enter toggles a tool and selecting "Done" confirms the list
### Requirement: Safety Checks
The command SHALL perform safety checks to prevent overwriting existing structures and ensure proper permissions.
#### Scenario: Detecting existing initialization
- **WHEN** `openspec/` directory already exists
- **THEN** display error with ora fail indicator:
- "✖ Error: OpenSpec seems to already be initialized. Use 'openspec update' to update the structure."
#### Scenario: Checking write permissions
- **WHEN** checking initialization feasibility
- **THEN** verify write permissions in the target directory silently
- **AND** only display error if permissions are insufficient
- **WHEN** the `openspec/` directory already exists
- **THEN** inform the user that OpenSpec is already initialized, skip recreating the base structure, and enter an extend mode
- **AND** continue to the AI tool selection step so additional tools can be configured
- **AND** display the existing-initialization error message only when the user declines to add any AI tools
### Requirement: Success Output
The command SHALL provide clear, actionable next steps upon successful initialization.
#### Scenario: Displaying success message
- **WHEN** initialization completes successfully
- **THEN** display actionable prompts for AI-driven workflow:
```
✔ OpenSpec initialized successfully!
Next steps - Copy these prompts to Claude:
────────────────────────────────────────────────────────────
1. Populate your project context:
"Please read openspec/project.md and help me fill it out
with details about my project, tech stack, and conventions"
2. Create your first change proposal:
"I want to add [YOUR FEATURE HERE]. Please create an
OpenSpec change proposal for this feature"
3. Learn the OpenSpec workflow:
"Please explain the OpenSpec workflow from openspec/AGENTS.md
and how I should work with you on this project"
────────────────────────────────────────────────────────────
```
The prompts SHALL:
- Be copy-pasteable for immediate use with AI tools
- Guide users through the AI-driven workflow
- Replace placeholder text ([YOUR FEATURE HERE]) with actual features
- **THEN** include prompt: "Please explain the OpenSpec workflow from openspec/AGENTS.md and how I should work with you on this project"
### Requirement: Exit Codes
@@ -187,10 +122,56 @@ The command SHALL use consistent exit codes to indicate different failure modes.
- 2: Insufficient permissions (reserved for future use)
- 3: User cancelled operation (reserved for future use)
### Requirement: Additional AI Tool Initialization
`openspec init` SHALL allow users to add configuration files for new AI coding assistants after the initial setup.
#### Scenario: Configuring an extra tool after initial setup
- **GIVEN** an `openspec/` directory already exists and at least one AI tool file is present
- **WHEN** the user runs `openspec init` and selects a different supported AI tool
- **THEN** generate that tool's configuration files with OpenSpec markers the same way as during first-time initialization
- **AND** leave existing tool configuration files unchanged except for managed sections that need refreshing
- **AND** exit with code 0 and display a success summary highlighting the newly added tool files
### Requirement: Success Output Enhancements
`openspec init` SHALL summarize tool actions when initialization or extend mode completes.
#### Scenario: Showing tool summary
- **WHEN** the command completes successfully
- **THEN** display a categorized summary of tools that were created, refreshed, or skipped (including already-configured skips)
- **AND** personalize the "Next steps" header using the names of the selected tools, defaulting to a generic label when none remain
### Requirement: Exit Code Adjustments
`openspec init` SHALL treat extend mode with no selected tools as a guarded error.
#### Scenario: Preventing empty extend runs
- **WHEN** OpenSpec is already initialized and the user selects no additional tools
- **THEN** exit with code 1 after showing the existing-initialization guidance message
### Requirement: Slash Command Configuration
The init command SHALL generate slash command files for supported editors using shared templates.
#### Scenario: Generating slash commands for Claude Code
- **WHEN** the user selects Claude Code during initialization
- **THEN** create `.claude/commands/openspec/proposal.md`, `.claude/commands/openspec/apply.md`, and `.claude/commands/openspec/archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Cursor
- **WHEN** the user selects Cursor during initialization
- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for OpenCode
- **WHEN** the user selects OpenCode during initialization
- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
## Why
Manual creation of OpenSpec structure is error-prone and creates adoption friction. A standardized init command ensures:
- Consistent structure across all projects
- Proper AI instruction files are always included
- Quick onboarding for new projects
- Clear conventions from the start
- Clear conventions from the start
+37 -30
View File
@@ -5,22 +5,12 @@
As a developer using OpenSpec, I want to update the OpenSpec instructions in my project when new versions are released, so that I can benefit from improvements to AI agent instructions.
## Requirements
### Requirement: Update Behavior
The update command SHALL update OpenSpec instruction files to the latest templates in a team-friendly manner.
#### Scenario: Running update command
- **WHEN** a user runs `openspec update`
- **THEN** the command SHALL:
- Check if the `openspec` directory exists
- Replace `openspec/AGENTS.md` with the latest template (complete replacement)
- Update **only existing** AI tool configuration files (e.g., CLAUDE.md)
- Check each registered AI tool configurator
- For each configurator, check if its file exists
- Update only files that already exist using their markers
- Preserve user content outside markers
- **Never create new AI tool configuration files**
- Display success message listing updated files
- **THEN** replace `openspec/AGENTS.md` with the latest template
- **AND** if a root-level stub (`AGENTS.md`/`CLAUDE.md`) exists, refresh it so it points to `@/openspec/AGENTS.md`
### Requirement: Prerequisites
@@ -34,39 +24,56 @@ The command SHALL require an existing OpenSpec structure before allowing updates
- **AND** exit with code 1
### Requirement: File Handling
The update command SHALL handle file updates in a predictable and safe manner.
#### Scenario: Updating files
- **WHEN** updating files
- **THEN** completely replace `openspec/AGENTS.md` with the latest template
- **AND** if a root-level stub exists, update the managed block content so it keeps directing teammates to `@/openspec/AGENTS.md`
### Requirement: Tool-Agnostic Updates
The update command SHALL handle file updates in a predictable and safe manner while respecting team tool choices.
#### Scenario: Updating files
- **WHEN** updating files
- **THEN** completely replace `openspec/AGENTS.md` with the latest template
- **AND** update the root-level `AGENTS.md` using the OpenSpec markers only when that file already exists, keeping the stub content that links to `@/openspec/AGENTS.md`
- **AND** update only the OpenSpec-managed blocks in **existing** AI tool files using markers
- **AND** use the default directory name `openspec`
- **AND** be idempotent (repeated runs have no additional effect)
- **AND** respect team members' AI tool choices by not creating unwanted files
### Requirement: Tool-Agnostic Updates
The update command SHALL update only existing AI tool configuration files and SHALL NOT create new ones.
#### Scenario: Updating existing tool files
- **WHEN** a user runs `openspec update`
- **THEN** update each AI tool configuration file that exists (e.g., CLAUDE.md, COPILOT.md)
- **AND** do not create missing tool configuration files
- **AND** preserve user content outside OpenSpec markers
- **AND** respect team members' AI tool choices by not creating additional tool files beyond the root `AGENTS.md`
- **AND** do not create new root-level stub files when none are present
### Requirement: Core Files Always Updated
The update command SHALL always update the core OpenSpec files and display an ASCII-safe success message.
#### Scenario: Successful update
- **WHEN** the update completes successfully
- **THEN** replace `openspec/AGENTS.md` with the latest template
- **AND** update existing AI tool configuration files within markers
- **AND** display the message: "Updated OpenSpec instructions"
- **AND** if a root-level stub exists, refresh it so it still directs contributors to `@/openspec/AGENTS.md`
### Requirement: Slash Command Updates
The update command SHALL refresh existing slash command files for configured tools without creating new ones.
#### Scenario: Updating slash commands for Claude Code
- **WHEN** `.claude/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`
- **THEN** refresh each file using shared templates
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Updating slash commands for Cursor
- **WHEN** `.cursor/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using shared templates
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Updating slash commands for OpenCode
- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using shared templates
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Missing slash command file
- **WHEN** a tool lacks a slash command file
- **THEN** do not create a new file during update
## Edge Cases
@@ -101,4 +108,4 @@ Users SHALL be able to:
The update process SHALL be:
- Simple and fast (no version checking)
- Predictable (same result every time)
- Self-contained (no network required)
- Self-contained (no network required)
+9
View File
@@ -199,3 +199,12 @@ The validate command SHALL handle ambiguous names and explicit type overrides to
- **THEN** the CLI SHALL not display interactive prompts
- **AND** SHALL print non-interactive hints or chosen outputs as appropriate
### Requirement: Parser SHALL handle cross-platform line endings
The markdown parser SHALL correctly identify sections regardless of line ending format (LF, CRLF, CR).
#### Scenario: Required sections parsed with CRLF line endings
- **GIVEN** a change proposal markdown saved with CRLF line endings
- **AND** the document contains `## Why` and `## What Changes`
- **WHEN** running `openspec validate <change-id>`
- **THEN** validation SHALL recognize the sections and NOT raise parsing errors
-14
View File
@@ -36,28 +36,14 @@ The dashboard SHALL display a summary section with key project metrics.
- **THEN** summary shows zero counts for all metrics
### Requirement: Active Changes Display
The dashboard SHALL show active changes with visual progress indicators.
#### Scenario: Active changes with progress bars
- **WHEN** there are in-progress changes with tasks
- **THEN** system displays each change with change name left-aligned
- **AND** visual progress bar using Unicode characters
- **AND** percentage completion on the right
#### Scenario: Active changes ordered by completion percentage
- **WHEN** multiple active changes are displayed with progress information
- **THEN** list them sorted by completion percentage ascending so 0% items appear first
- **AND** treat missing progress values as 0% for ordering
- **AND** break ties by change identifier in ascending alphabetical order to keep output deterministic
#### Scenario: No active changes
- **WHEN** all changes are completed or no changes exist
- **THEN** active changes section is omitted from display
### Requirement: Completed Changes Display
The dashboard SHALL list completed changes in a separate section.
@@ -14,11 +14,9 @@ OpenSpec conventions SHALL mandate a structured spec format with clear requireme
- **THEN** authors SHALL use `### Requirement: ...` followed by at least one `#### Scenario: ...` section
### Requirement: Project Structure
An OpenSpec project SHALL maintain a consistent directory structure for specifications and changes.
#### Scenario: Initializing project structure
- **WHEN** an OpenSpec project is initialized
- **THEN** it SHALL have this structure:
```
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@fission-ai/openspec",
"version": "0.3.0",
"version": "0.6.0",
"description": "AI-native system for spec-driven development",
"keywords": [
"openspec",
+1
View File
@@ -19,5 +19,6 @@ export interface AIToolOption {
export const AI_TOOLS: AIToolOption[] = [
{ name: 'Claude Code (✅ OpenSpec custom slash commands available)', value: 'claude', available: true, successLabel: 'Claude Code' },
{ name: 'Cursor (✅ OpenSpec custom slash commands available)', value: 'cursor', available: true, successLabel: 'Cursor' },
{ name: 'OpenCode (✅ OpenSpec custom slash commands available)', value: 'opencode', available: true, successLabel: 'OpenCode' },
{ name: 'AGENTS.md (works with Codex, Amp, Copilot, …)', value: 'agents', available: true, successLabel: 'your AGENTS.md-compatible assistant' }
];
+41
View File
@@ -0,0 +1,41 @@
import { SlashCommandConfigurator } from "./base.js";
import { SlashCommandId } from "../../templates/index.js";
const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: ".opencode/command/openspec-proposal.md",
apply: ".opencode/command/openspec-apply.md",
archive: ".opencode/command/openspec-archive.md",
};
const FRONTMATTER: Record<SlashCommandId, string> = {
proposal: `---
agent: build
description: Scaffold a new OpenSpec change and validate strictly.
---
The user has requested the following change proposal. Use the openspec instructions to create their change proposal.
<UserRequest>
$ARGUMENTS
</UserRequest>
`,
apply: `---
agent: build
description: Implement an approved OpenSpec change and keep tasks in sync.
---`,
archive: `---
agent: build
description: Archive a deployed OpenSpec change and update specs.
---`,
};
export class OpenCodeSlashCommandConfigurator extends SlashCommandConfigurator {
readonly toolId = "opencode";
readonly isAvailable = true;
protected getRelativePath(id: SlashCommandId): string {
return FILE_PATHS[id];
}
protected getFrontmatter(id: SlashCommandId): string | undefined {
return FRONTMATTER[id];
}
}
+3
View File
@@ -1,6 +1,7 @@
import { SlashCommandConfigurator } from './base.js';
import { ClaudeSlashCommandConfigurator } from './claude.js';
import { CursorSlashCommandConfigurator } from './cursor.js';
import { OpenCodeSlashCommandConfigurator } from './opencode.js';
export class SlashCommandRegistry {
private static configurators: Map<string, SlashCommandConfigurator> = new Map();
@@ -8,9 +9,11 @@ export class SlashCommandRegistry {
static {
const claude = new ClaudeSlashCommandConfigurator();
const cursor = new CursorSlashCommandConfigurator();
const opencode = new OpenCodeSlashCommandConfigurator();
this.configurators.set(claude.toolId, claude);
this.configurators.set(cursor.toolId, cursor);
this.configurators.set(opencode.toolId, opencode);
}
static register(configurator: SlashCommandConfigurator): void {
+6 -4
View File
@@ -43,7 +43,8 @@ export class JsonConverter {
}
private extractNameFromPath(filePath: string): string {
const parts = filePath.split('/');
const normalizedPath = filePath.replaceAll('\\', '/');
const parts = normalizedPath.split('/');
for (let i = parts.length - 1; i >= 0; i--) {
if (parts[i] === 'specs' || parts[i] === 'changes') {
@@ -53,7 +54,8 @@ export class JsonConverter {
}
}
const fileName = parts[parts.length - 1];
return fileName.replace('.md', '');
const fileName = parts[parts.length - 1] ?? '';
const dotIndex = fileName.lastIndexOf('.');
return dotIndex > 0 ? fileName.slice(0, dotIndex) : fileName;
}
}
}
+332 -247
View File
@@ -8,7 +8,7 @@ import {
isUpKey,
useKeypress,
usePagination,
useState
useState,
} from '@inquirer/core';
import chalk from 'chalk';
import ora from 'ora';
@@ -16,70 +16,27 @@ import { FileSystemUtils } from '../utils/file-system.js';
import { TemplateManager, ProjectContext } from './templates/index.js';
import { ToolRegistry } from './configurators/registry.js';
import { SlashCommandRegistry } from './configurators/slash/registry.js';
import { OpenSpecConfig, AI_TOOLS, OPENSPEC_DIR_NAME, AIToolOption } from './config.js';
import {
OpenSpecConfig,
AI_TOOLS,
OPENSPEC_DIR_NAME,
AIToolOption,
} from './config.js';
import { PALETTE } from './styles/palette.js';
const PROGRESS_SPINNER = {
interval: 80,
frames: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓']
};
const PALETTE = {
white: chalk.hex('#f4f4f4'),
lightGray: chalk.hex('#c8c8c8'),
midGray: chalk.hex('#8a8a8a'),
darkGray: chalk.hex('#4a4a4a')
frames: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓'],
};
const LETTER_MAP: Record<string, string[]> = {
O: [
' ████ ',
'██ ██',
'██ ██',
'██ ██',
' ████ '
],
P: [
'█████ ',
'██ ██',
'█████ ',
'██ ',
'██ '
],
E: [
'██████',
'██ ',
'█████ ',
'██ ',
'██████'
],
N: [
'██ ██',
'███ ██',
'██ ███',
'██ ██',
'██ ██'
],
S: [
' █████',
'██ ',
' ████ ',
' ██',
'█████ '
],
C: [
' █████',
'██ ',
'██ ',
'██ ',
' █████'
],
' ': [
' ',
' ',
' ',
' ',
' '
]
O: [' ████ ', '██ ██', '██ ██', '██ ██', ' ████ '],
P: ['█████ ', '██ ██', '█████ ', '██ ', '██ '],
E: ['██████', '██ ', '█████ ', '██ ', '██████'],
N: ['██ ██', '███ ██', '██ ███', '██ ██', '██ ██'],
S: [' █████', '██ ', ' ████ ', ' ██', '█████ '],
C: [' █████', '██ ', '██ ', '██ ', ' █████'],
' ': [' ', ' ', ' ', ' ', ' '],
};
type ToolLabel = {
@@ -87,7 +44,8 @@ type ToolLabel = {
annotation?: string;
};
const sanitizeToolLabel = (raw: string): string => raw.replace(/✅/gu, '✔').trim();
const sanitizeToolLabel = (raw: string): string =>
raw.replace(/✅/gu, '✔').trim();
const parseToolLabel = (raw: string): ToolLabel => {
const sanitized = sanitizeToolLabel(raw);
@@ -97,7 +55,7 @@ const parseToolLabel = (raw: string): ToolLabel => {
}
return {
primary: match[1].trim(),
annotation: match[2].trim()
annotation: match[2].trim(),
};
};
@@ -118,165 +76,189 @@ type WizardStep = 'intro' | 'select' | 'review';
type ToolSelectionPrompt = (config: ToolWizardConfig) => Promise<string[]>;
const toolSelectionWizard = createPrompt<string[], ToolWizardConfig>((config, done) => {
const totalSteps = 3;
const [step, setStep] = useState<WizardStep>('intro');
const [cursor, setCursor] = useState<number>(0);
const [selected, setSelected] = useState<string[]>(() => config.initialSelected ?? []);
const [error, setError] = useState<string | null>(null);
const toolSelectionWizard = createPrompt<string[], ToolWizardConfig>(
(config, done) => {
const totalSteps = 3;
const [step, setStep] = useState<WizardStep>('intro');
const [cursor, setCursor] = useState<number>(0);
const [selected, setSelected] = useState<string[]>(
() => config.initialSelected ?? []
);
const [error, setError] = useState<string | null>(null);
const selectedSet = new Set(selected);
const pageSize = Math.max(Math.min(config.choices.length, 7), 1);
const selectedSet = new Set(selected);
const pageSize = Math.max(Math.min(config.choices.length, 7), 1);
const updateSelected = (next: Set<string>) => {
const ordered = config.choices
.map((choice) => choice.value)
.filter((value) => next.has(value));
setSelected(ordered);
};
const updateSelected = (next: Set<string>) => {
const ordered = config.choices
.map((choice) => choice.value)
.filter((value) => next.has(value));
setSelected(ordered);
};
const page = usePagination({
items: config.choices,
active: cursor,
pageSize,
loop: config.choices.length > 1,
renderItem: ({ item, isActive }) => {
const isSelected = selectedSet.has(item.value);
const cursorSymbol = isActive ? PALETTE.white('›') : PALETTE.midGray(' ');
const indicator = isSelected ? PALETTE.white('◉') : PALETTE.midGray('○');
const nameColor = isActive ? PALETTE.white : PALETTE.midGray;
const label = `${nameColor(item.label.primary)}${item.configured ? PALETTE.midGray(' (already configured)') : ''}`;
return `${cursorSymbol} ${indicator} ${label}`;
}
});
const page = usePagination({
items: config.choices,
active: cursor,
pageSize,
loop: config.choices.length > 1,
renderItem: ({ item, isActive }) => {
const isSelected = selectedSet.has(item.value);
const cursorSymbol = isActive
? PALETTE.white('›')
: PALETTE.midGray(' ');
const indicator = isSelected
? PALETTE.white('◉')
: PALETTE.midGray('○');
const nameColor = isActive ? PALETTE.white : PALETTE.midGray;
const label = `${nameColor(item.label.primary)}${
item.configured ? PALETTE.midGray(' (already configured)') : ''
}`;
return `${cursorSymbol} ${indicator} ${label}`;
},
});
useKeypress((key) => {
if (step === 'intro') {
if (isEnterKey(key)) {
setStep('select');
}
return;
}
if (step === 'select') {
if (isUpKey(key)) {
const previousIndex = cursor <= 0 ? config.choices.length - 1 : cursor - 1;
setCursor(previousIndex);
setError(null);
return;
}
if (isDownKey(key)) {
const nextIndex = cursor >= config.choices.length - 1 ? 0 : cursor + 1;
setCursor(nextIndex);
setError(null);
return;
}
if (isSpaceKey(key)) {
const current = config.choices[cursor];
if (!current) return;
const next = new Set(selected);
if (next.has(current.value)) {
next.delete(current.value);
} else {
next.add(current.value);
useKeypress((key) => {
if (step === 'intro') {
if (isEnterKey(key)) {
setStep('select');
}
updateSelected(next);
setError(null);
return;
}
if (isEnterKey(key)) {
if (selected.length === 0) {
setError('Select at least one AI tool to continue.');
if (step === 'select') {
if (isUpKey(key)) {
const previousIndex =
cursor <= 0 ? config.choices.length - 1 : cursor - 1;
setCursor(previousIndex);
setError(null);
return;
}
setStep('review');
setError(null);
if (isDownKey(key)) {
const nextIndex =
cursor >= config.choices.length - 1 ? 0 : cursor + 1;
setCursor(nextIndex);
setError(null);
return;
}
if (isSpaceKey(key)) {
const current = config.choices[cursor];
if (!current) return;
const next = new Set(selected);
if (next.has(current.value)) {
next.delete(current.value);
} else {
next.add(current.value);
}
updateSelected(next);
setError(null);
return;
}
if (isEnterKey(key)) {
if (selected.length === 0) {
setError('Select at least one AI tool to continue.');
return;
}
setStep('review');
setError(null);
return;
}
if (key.name === 'escape') {
setSelected([]);
setError(null);
}
return;
}
if (key.name === 'escape') {
setSelected([]);
setError(null);
if (step === 'review') {
if (isEnterKey(key)) {
const finalSelection = config.choices
.map((choice) => choice.value)
.filter((value) => selectedSet.has(value));
done(finalSelection);
return;
}
if (isBackspaceKey(key) || key.name === 'escape') {
setStep('select');
setError(null);
}
}
return;
}
});
if (step === 'review') {
if (isEnterKey(key)) {
const finalSelection = config.choices
.map((choice) => choice.value)
.filter((value) => selectedSet.has(value));
done(finalSelection);
return;
const selectedNames = config.choices
.filter((choice) => selectedSet.has(choice.value))
.map((choice) => choice.label.primary);
const stepIndex = step === 'intro' ? 1 : step === 'select' ? 2 : 3;
const lines: string[] = [];
lines.push(PALETTE.midGray(`Step ${stepIndex}/${totalSteps}`));
lines.push('');
if (step === 'intro') {
const introHeadline = config.extendMode
? 'Extend your OpenSpec tooling'
: 'Configure your OpenSpec tooling';
const introBody = config.extendMode
? 'We detected an existing setup. We will help you refresh or add integrations.'
: "Let's get your AI assistants connected so they understand OpenSpec.";
lines.push(PALETTE.white(introHeadline));
lines.push(PALETTE.midGray(introBody));
lines.push('');
lines.push(PALETTE.midGray('Press Enter to continue.'));
} else if (step === 'select') {
lines.push(PALETTE.white(config.baseMessage));
lines.push(
PALETTE.midGray(
'Use ↑/↓ to move · Space to toggle · Enter to review selections.'
)
);
lines.push('');
lines.push(page);
lines.push('');
if (selectedNames.length === 0) {
lines.push(
`${PALETTE.midGray('Selected')}: ${PALETTE.midGray(
'None selected yet'
)}`
);
} else {
lines.push(PALETTE.midGray('Selected:'));
selectedNames.forEach((name) => {
lines.push(` ${PALETTE.white('-')} ${PALETTE.white(name)}`);
});
}
if (isBackspaceKey(key) || key.name === 'escape') {
setStep('select');
setError(null);
}
}
});
const selectedNames = config.choices
.filter((choice) => selectedSet.has(choice.value))
.map((choice) => choice.label.primary);
const stepIndex = step === 'intro' ? 1 : step === 'select' ? 2 : 3;
const lines: string[] = [];
lines.push(PALETTE.midGray(`Step ${stepIndex}/${totalSteps}`));
lines.push('');
if (step === 'intro') {
const introHeadline = config.extendMode
? 'Extend your OpenSpec tooling'
: 'Configure your OpenSpec tooling';
const introBody = config.extendMode
? 'We detected an existing setup. We will help you refresh or add integrations.'
: "Let's get your AI assistants connected so they understand OpenSpec.";
lines.push(PALETTE.white(introHeadline));
lines.push(PALETTE.midGray(introBody));
lines.push('');
lines.push(PALETTE.midGray('Press Enter to continue.'));
} else if (step === 'select') {
lines.push(PALETTE.white(config.baseMessage));
lines.push(PALETTE.midGray('Use ↑/↓ to move · Space to toggle · Enter to review selections.'));
lines.push('');
lines.push(page);
lines.push('');
if (selectedNames.length === 0) {
lines.push(`${PALETTE.midGray('Selected')}: ${PALETTE.midGray('None selected yet')}`);
} else {
lines.push(PALETTE.midGray('Selected:'));
selectedNames.forEach((name) => {
lines.push(` ${PALETTE.white('-')} ${PALETTE.white(name)}`);
});
lines.push(PALETTE.white('Review selections'));
lines.push(
PALETTE.midGray('Press Enter to confirm or Backspace to adjust.')
);
lines.push('');
if (selectedNames.length === 0) {
lines.push(
PALETTE.midGray('No tools selected. Press Backspace to return.')
);
} else {
selectedNames.forEach((name) => {
lines.push(`${PALETTE.white('▌')} ${PALETTE.white(name)}`);
});
}
}
} else {
lines.push(PALETTE.white('Review selections'));
lines.push(PALETTE.midGray('Press Enter to confirm or Backspace to adjust.'));
lines.push('');
if (selectedNames.length === 0) {
lines.push(PALETTE.midGray('No tools selected. Press Backspace to return.'));
} else {
selectedNames.forEach((name) => {
lines.push(`${PALETTE.white('▌')} ${PALETTE.white(name)}`);
});
if (error) {
return [lines.join('\n'), chalk.red(error)];
}
}
if (error) {
return [lines.join('\n'), chalk.red(error)];
return lines.join('\n');
}
return lines.join('\n');
});
);
type InitCommandOptions = {
prompt?: ToolSelectionPrompt;
@@ -307,32 +289,48 @@ export class InitCommand {
if (extendMode) {
throw new Error(
`OpenSpec seems to already be initialized at ${openspecPath}.\n` +
`Use 'openspec update' to update the structure.`
`Use 'openspec update' to update the structure.`
);
}
throw new Error('You must select at least one AI tool to configure.');
}
const availableTools = AI_TOOLS.filter(tool => tool.available);
const availableTools = AI_TOOLS.filter((tool) => tool.available);
const selectedIds = new Set(config.aiTools);
const selectedTools = availableTools.filter(tool => selectedIds.has(tool.value));
const created = selectedTools.filter(tool => !existingToolStates[tool.value]);
const refreshed = selectedTools.filter(tool => existingToolStates[tool.value]);
const skippedExisting = availableTools.filter(tool => !selectedIds.has(tool.value) && existingToolStates[tool.value]);
const skipped = availableTools.filter(tool => !selectedIds.has(tool.value) && !existingToolStates[tool.value]);
const selectedTools = availableTools.filter((tool) =>
selectedIds.has(tool.value)
);
const created = selectedTools.filter(
(tool) => !existingToolStates[tool.value]
);
const refreshed = selectedTools.filter(
(tool) => existingToolStates[tool.value]
);
const skippedExisting = availableTools.filter(
(tool) => !selectedIds.has(tool.value) && existingToolStates[tool.value]
);
const skipped = availableTools.filter(
(tool) => !selectedIds.has(tool.value) && !existingToolStates[tool.value]
);
// Step 1: Create directory structure
if (!extendMode) {
const structureSpinner = this.startSpinner('Creating OpenSpec structure...');
const structureSpinner = this.startSpinner(
'Creating OpenSpec structure...'
);
await this.createDirectoryStructure(openspecPath);
await this.generateFiles(openspecPath, config);
structureSpinner.stopAndPersist({
symbol: PALETTE.white('▌'),
text: PALETTE.white('OpenSpec structure created')
text: PALETTE.white('OpenSpec structure created'),
});
} else {
ora({ stream: process.stdout }).info(PALETTE.midGray('ℹ OpenSpec already initialized. Skipping base scaffolding.'));
ora({ stream: process.stdout }).info(
PALETTE.midGray(
'ℹ OpenSpec already initialized. Skipping base scaffolding.'
)
);
}
// Step 2: Configure AI tools
@@ -340,30 +338,49 @@ export class InitCommand {
await this.configureAITools(projectPath, openspecDir, config.aiTools);
toolSpinner.stopAndPersist({
symbol: PALETTE.white('▌'),
text: PALETTE.white('AI tools configured')
text: PALETTE.white('AI tools configured'),
});
// Success message
this.displaySuccessMessage(selectedTools, created, refreshed, skippedExisting, skipped, extendMode);
this.displaySuccessMessage(
selectedTools,
created,
refreshed,
skippedExisting,
skipped,
extendMode
);
}
private async validate(projectPath: string, _openspecPath: string): Promise<boolean> {
private async validate(
projectPath: string,
_openspecPath: string
): Promise<boolean> {
const extendMode = await FileSystemUtils.directoryExists(_openspecPath);
// Check write permissions
if (!await FileSystemUtils.ensureWritePermissions(projectPath)) {
if (!(await FileSystemUtils.ensureWritePermissions(projectPath))) {
throw new Error(`Insufficient permissions to write to ${projectPath}`);
}
return extendMode;
}
private async getConfiguration(existingTools: Record<string, boolean>, extendMode: boolean): Promise<OpenSpecConfig> {
const selectedTools = await this.promptForAITools(existingTools, extendMode);
private async getConfiguration(
existingTools: Record<string, boolean>,
extendMode: boolean
): Promise<OpenSpecConfig> {
const selectedTools = await this.promptForAITools(
existingTools,
extendMode
);
return { aiTools: selectedTools };
}
private async promptForAITools(existingTools: Record<string, boolean>, extendMode: boolean): Promise<string[]> {
const availableTools = AI_TOOLS.filter(tool => tool.available);
private async promptForAITools(
existingTools: Record<string, boolean>,
extendMode: boolean
): Promise<string[]> {
const availableTools = AI_TOOLS.filter((tool) => tool.available);
if (availableTools.length === 0) {
return [];
@@ -373,7 +390,9 @@ export class InitCommand {
? 'Which AI tools would you like to add or refresh?'
: 'Which AI tools do you use?';
const initialSelected = extendMode
? availableTools.filter(tool => existingTools[tool.value]).map(tool => tool.value)
? availableTools
.filter((tool) => existingTools[tool.value])
.map((tool) => tool.value)
: [];
return this.prompt({
@@ -382,13 +401,15 @@ export class InitCommand {
choices: availableTools.map((tool) => ({
value: tool.value,
label: parseToolLabel(tool.name),
configured: Boolean(existingTools[tool.value])
configured: Boolean(existingTools[tool.value]),
})),
initialSelected
initialSelected,
});
}
private async getExistingToolStates(projectPath: string): Promise<Record<string, boolean>> {
private async getExistingToolStates(
projectPath: string
): Promise<Record<string, boolean>> {
const states: Record<string, boolean> = {};
for (const tool of AI_TOOLS) {
states[tool.value] = await this.isToolConfigured(projectPath, tool.value);
@@ -396,14 +417,22 @@ export class InitCommand {
return states;
}
private async isToolConfigured(projectPath: string, toolId: string): Promise<boolean> {
private async isToolConfigured(
projectPath: string,
toolId: string
): Promise<boolean> {
const configFile = ToolRegistry.get(toolId)?.configFileName;
if (configFile && await FileSystemUtils.fileExists(path.join(projectPath, configFile))) return true;
if (
configFile &&
(await FileSystemUtils.fileExists(path.join(projectPath, configFile)))
)
return true;
const slashConfigurator = SlashCommandRegistry.get(toolId);
if (!slashConfigurator) return false;
for (const target of slashConfigurator.getTargets()) {
if (await FileSystemUtils.fileExists(path.join(projectPath, target.path))) return true;
if (await FileSystemUtils.fileExists(path.join(projectPath, target.path)))
return true;
}
return false;
}
@@ -413,7 +442,7 @@ export class InitCommand {
openspecPath,
path.join(openspecPath, 'specs'),
path.join(openspecPath, 'changes'),
path.join(openspecPath, 'changes', 'archive')
path.join(openspecPath, 'changes', 'archive'),
];
for (const dir of directories) {
@@ -421,24 +450,32 @@ export class InitCommand {
}
}
private async generateFiles(openspecPath: string, config: OpenSpecConfig): Promise<void> {
private async generateFiles(
openspecPath: string,
config: OpenSpecConfig
): Promise<void> {
const context: ProjectContext = {
// Could be enhanced with prompts for project details
};
const templates = TemplateManager.getTemplates(context);
for (const template of templates) {
const filePath = path.join(openspecPath, template.path);
const content = typeof template.content === 'function'
? template.content(context)
: template.content;
const content =
typeof template.content === 'function'
? template.content(context)
: template.content;
await FileSystemUtils.writeFile(filePath, content);
}
}
private async configureAITools(projectPath: string, openspecDir: string, toolIds: string[]): Promise<void> {
private async configureAITools(
projectPath: string,
openspecDir: string,
toolIds: string[]
): Promise<void> {
for (const toolId of toolIds) {
const configurator = ToolRegistry.get(toolId);
if (configurator && configurator.isAvailable) {
@@ -469,34 +506,80 @@ export class InitCommand {
console.log();
console.log(PALETTE.lightGray('Tool summary:'));
const summaryLines = [
created.length ? `${PALETTE.white('▌')} ${PALETTE.white('Created:')} ${this.formatToolNames(created)}` : null,
refreshed.length ? `${PALETTE.lightGray('▌')} ${PALETTE.lightGray('Refreshed:')} ${this.formatToolNames(refreshed)}` : null,
skippedExisting.length ? `${PALETTE.midGray('▌')} ${PALETTE.midGray('Skipped (already configured):')} ${this.formatToolNames(skippedExisting)}` : null,
skipped.length ? `${PALETTE.darkGray('▌')} ${PALETTE.darkGray('Skipped:')} ${this.formatToolNames(skipped)}` : null
created.length
? `${PALETTE.white('▌')} ${PALETTE.white(
'Created:'
)} ${this.formatToolNames(created)}`
: null,
refreshed.length
? `${PALETTE.lightGray('▌')} ${PALETTE.lightGray(
'Refreshed:'
)} ${this.formatToolNames(refreshed)}`
: null,
skippedExisting.length
? `${PALETTE.midGray('▌')} ${PALETTE.midGray(
'Skipped (already configured):'
)} ${this.formatToolNames(skippedExisting)}`
: null,
skipped.length
? `${PALETTE.darkGray('▌')} ${PALETTE.darkGray(
'Skipped:'
)} ${this.formatToolNames(skipped)}`
: null,
].filter((line): line is string => Boolean(line));
for (const line of summaryLines) {
console.log(line);
}
console.log();
console.log(PALETTE.midGray('Use `openspec update` to refresh shared OpenSpec instructions in the future.'));
console.log(
PALETTE.midGray(
'Use `openspec update` to refresh shared OpenSpec instructions in the future.'
)
);
// Get the selected tool name(s) for display
const toolName = this.formatToolNames(selectedTools);
console.log();
console.log(`Next steps - Copy these prompts to ${toolName}:`);
console.log(chalk.gray('────────────────────────────────────────────────────────────'));
console.log(
chalk.gray('────────────────────────────────────────────────────────────')
);
console.log(PALETTE.white('1. Populate your project context:'));
console.log(PALETTE.lightGray(' "Please read openspec/project.md and help me fill it out'));
console.log(PALETTE.lightGray(' with details about my project, tech stack, and conventions"\n'));
console.log(
PALETTE.lightGray(
' "Please read openspec/project.md and help me fill it out'
)
);
console.log(
PALETTE.lightGray(
' with details about my project, tech stack, and conventions"\n'
)
);
console.log(PALETTE.white('2. Create your first change proposal:'));
console.log(PALETTE.lightGray(' "I want to add [YOUR FEATURE HERE]. Please create an'));
console.log(PALETTE.lightGray(' OpenSpec change proposal for this feature"\n'));
console.log(
PALETTE.lightGray(
' "I want to add [YOUR FEATURE HERE]. Please create an'
)
);
console.log(
PALETTE.lightGray(' OpenSpec change proposal for this feature"\n')
);
console.log(PALETTE.white('3. Learn the OpenSpec workflow:'));
console.log(PALETTE.lightGray(' "Please explain the OpenSpec workflow from openspec/AGENTS.md'));
console.log(PALETTE.lightGray(' and how I should work with you on this project"'));
console.log(PALETTE.darkGray('────────────────────────────────────────────────────────────\n'));
console.log(
PALETTE.lightGray(
' "Please explain the OpenSpec workflow from openspec/AGENTS.md'
)
);
console.log(
PALETTE.lightGray(' and how I should work with you on this project"')
);
console.log(
PALETTE.darkGray(
'────────────────────────────────────────────────────────────\n'
)
);
}
private formatToolNames(tools: AIToolOption[]): string {
@@ -510,7 +593,9 @@ export class InitCommand {
const base = names.slice(0, -1).map((name) => PALETTE.white(name));
const last = PALETTE.white(names[names.length - 1]);
return `${base.join(PALETTE.midGray(', '))}${base.length ? PALETTE.midGray(', and ') : ''}${last}`;
return `${base.join(PALETTE.midGray(', '))}${
base.length ? PALETTE.midGray(', and ') : ''
}${last}`;
}
private renderBanner(_extendMode: boolean): void {
@@ -527,7 +612,7 @@ export class InitCommand {
PALETTE.lightGray,
PALETTE.midGray,
PALETTE.lightGray,
PALETTE.white
PALETTE.white,
];
console.log();
@@ -544,7 +629,7 @@ export class InitCommand {
text,
stream: process.stdout,
color: 'gray',
spinner: PROGRESS_SPINNER
spinner: PROGRESS_SPINNER,
}).start();
}
}
+3 -2
View File
@@ -150,7 +150,7 @@ export class ChangeParser extends MarkdownParser {
private parseRenames(content: string): Array<{ from: string; to: string }> {
const renames: Array<{ from: string; to: string }> = [];
const lines = content.split('\n');
const lines = ChangeParser.normalizeContent(content).split('\n');
let currentRename: { from?: string; to?: string } = {};
@@ -177,7 +177,8 @@ export class ChangeParser extends MarkdownParser {
}
private parseSectionsFromContent(content: string): Section[] {
const lines = content.split('\n');
const normalizedContent = ChangeParser.normalizeContent(content);
const lines = normalizedContent.split('\n');
const sections: Section[] = [];
const stack: Section[] = [];
+6 -1
View File
@@ -12,10 +12,15 @@ export class MarkdownParser {
private currentLine: number;
constructor(content: string) {
this.lines = content.split('\n');
const normalized = MarkdownParser.normalizeContent(content);
this.lines = normalized.split('\n');
this.currentLine = 0;
}
protected static normalizeContent(content: string): string {
return content.replace(/\r\n?/g, '\n');
}
parseSpec(name: string): Spec {
const sections = this.parseSections();
const purpose = this.findSection(sections, 'Purpose')?.content || '';
+11 -5
View File
@@ -22,7 +22,8 @@ const REQUIREMENT_HEADER_REGEX = /^###\s*Requirement:\s*(.+)\s*$/;
* Extracts the Requirements section from a spec file and parses requirement blocks.
*/
export function extractRequirementsSection(content: string): RequirementsSectionParts {
const lines = content.split('\n');
const normalized = normalizeLineEndings(content);
const lines = normalized.split('\n');
const reqHeaderIndex = lines.findIndex(l => /^##\s+Requirements\s*$/i.test(l));
if (reqHeaderIndex === -1) {
@@ -102,11 +103,16 @@ export interface DeltaPlan {
renamed: Array<{ from: string; to: string }>;
}
function normalizeLineEndings(content: string): string {
return content.replace(/\r\n?/g, '\n');
}
/**
* Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks.
*/
export function parseDeltaSpec(content: string): DeltaPlan {
const sections = splitTopLevelSections(content);
const normalized = normalizeLineEndings(content);
const sections = splitTopLevelSections(normalized);
const added = parseRequirementBlocksFromSection(sections['ADDED Requirements'] || '');
const modified = parseRequirementBlocksFromSection(sections['MODIFIED Requirements'] || '');
const removedNames = parseRemovedNames(sections['REMOVED Requirements'] || '');
@@ -136,7 +142,7 @@ function splitTopLevelSections(content: string): Record<string, string> {
function parseRequirementBlocksFromSection(sectionBody: string): RequirementBlock[] {
if (!sectionBody) return [];
const lines = sectionBody.split('\n');
const lines = normalizeLineEndings(sectionBody).split('\n');
const blocks: RequirementBlock[] = [];
let i = 0;
while (i < lines.length) {
@@ -161,7 +167,7 @@ function parseRequirementBlocksFromSection(sectionBody: string): RequirementBloc
function parseRemovedNames(sectionBody: string): string[] {
if (!sectionBody) return [];
const names: string[] = [];
const lines = sectionBody.split('\n');
const lines = normalizeLineEndings(sectionBody).split('\n');
for (const line of lines) {
const m = line.match(REQUIREMENT_HEADER_REGEX);
if (m) {
@@ -180,7 +186,7 @@ function parseRemovedNames(sectionBody: string): string[] {
function parseRenamedPairs(sectionBody: string): Array<{ from: string; to: string }> {
if (!sectionBody) return [];
const pairs: Array<{ from: string; to: string }> = [];
const lines = sectionBody.split('\n');
const lines = normalizeLineEndings(sectionBody).split('\n');
let current: { from?: string; to?: string } = {};
for (const line of lines) {
const fromMatch = line.match(/^\s*-?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
+8
View File
@@ -0,0 +1,8 @@
import chalk from 'chalk';
export const PALETTE = {
white: chalk.hex('#f4f4f4'),
lightGray: chalk.hex('#c8c8c8'),
midGray: chalk.hex('#8a8a8a'),
darkGray: chalk.hex('#4a4a4a')
};
+16
View File
@@ -0,0 +1,16 @@
export const agentsRootStubTemplate = `# OpenSpec Instructions
These instructions are for AI assistants working in this project.
Always open \`@/openspec/AGENTS.md\` when the request:
- Mentions planning or proposals (words like proposal, spec, change, plan)
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
- Sounds ambiguous and you need the authoritative spec before coding
Use \`@/openspec/AGENTS.md\` to learn:
- How to create and apply change proposals
- Spec format and conventions
- Project structure and guidelines
Keep this managed block so 'openspec update' can refresh the instructions.
`;
+8 -5
View File
@@ -47,18 +47,20 @@ Skip proposal for:
4. Run \`openspec validate <id> --strict\` and resolve any issues before sharing the proposal.
### Stage 2: Implementing Changes
Track these steps as TODOs and complete them one by one.
1. **Read proposal.md** - Understand what's being built
2. **Read design.md** (if exists) - Review technical decisions
3. **Read tasks.md** - Get implementation checklist
4. **Implement tasks sequentially** - Complete in order
5. **Mark complete immediately** - Update \`- [x]\` after each task
6. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
5. **Confirm completion** - Ensure every item in \`tasks.md\` is finished before updating statuses
6. **Update checklist** - After all work is done, set every task to \`- [x]\` so the list reflects reality
7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
### Stage 3: Archiving Changes
After deployment, create separate PR to:
- Move \`changes/[name]/\` → \`changes/archive/YYYY-MM-DD-[name]/\`
- Update \`specs/\` if capabilities changed
- Use \`openspec archive [change] --skip-specs\` for tooling-only changes
- Use \`openspec archive [change] --skip-specs --yes\` for tooling-only changes
- Run \`openspec validate --strict\` to confirm the archived change passes checks
## Before Any Task
@@ -95,7 +97,7 @@ openspec list --specs # List specifications
openspec show [item] # Display change or spec
openspec diff [change] # Show spec differences
openspec validate [item] # Validate changes or specs
openspec archive [change] # Archive after deployment
openspec archive [change] [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
# Project management
openspec init [path] # Initialize OpenSpec
@@ -117,6 +119,7 @@ openspec validate [change] --strict
- \`--strict\` - Comprehensive validation
- \`--no-interactive\` - Disable prompts
- \`--skip-specs\` - Archive without spec updates
- \`--yes\`/\`-y\` - Skip confirmation prompts (non-interactive archive)
## Directory Structure
@@ -447,7 +450,7 @@ openspec list # What's in progress?
openspec show [item] # View details
openspec diff [change] # What's changing?
openspec validate --strict # Is it correct?
openspec archive [change] # Mark complete
openspec archive [change] [--yes|-y] # Mark complete (add --yes for automation)
\`\`\`
Remember: Specs are truth. Changes are proposals. Keep them in sync.
+1 -1
View File
@@ -1 +1 @@
export { agentsTemplate as claudeTemplate } from './agents-template.js';
export { agentsRootStubTemplate as claudeTemplate } from './agents-root-stub.js';
+2 -1
View File
@@ -1,6 +1,7 @@
import { agentsTemplate } from './agents-template.js';
import { projectTemplate, ProjectContext } from './project-template.js';
import { claudeTemplate } from './claude-template.js';
import { agentsRootStubTemplate } from './agents-root-stub.js';
import { getSlashCommandBody, SlashCommandId } from './slash-command-templates.js';
export interface Template {
@@ -27,7 +28,7 @@ export class TemplateManager {
}
static getAgentsStandardTemplate(): string {
return agentsTemplate;
return agentsRootStubTemplate;
}
static getSlashCommandBody(id: SlashCommandId): string {
@@ -22,17 +22,19 @@ const proposalReferences = `**Reference**
- Explore the codebase with \`rg <keyword>\`, \`ls\`, or direct file reads so proposals align with current implementation realities.`;
const applySteps = `**Steps**
Track these steps as TODOs and complete them one by one.
1. Read \`changes/<id>/proposal.md\`, \`design.md\` (if present), and \`tasks.md\` to confirm scope and acceptance criteria.
2. Work through tasks sequentially, keeping edits minimal and focused on the requested change.
3. Mark each task \`- [x]\` immediately after completing it to keep the checklist in sync.
4. Reference \`openspec list\` or \`openspec show <item>\` when additional context is required.`;
3. Confirm completion before updating statuses—make sure every item in \`tasks.md\` is finished.
4. Update the checklist after all work is done so each task is marked \`- [x]\` and reflects reality.
5. Reference \`openspec list\` or \`openspec show <item>\` when additional context is required.`;
const applyReferences = `**Reference**
- Use \`openspec show <id> --json --deltas-only\` if you need additional context from the proposal while implementing.`;
const archiveSteps = `**Steps**
1. Identify the requested change ID (via the prompt or \`openspec list\`).
2. Run \`openspec archive <id>\` to let the CLI move the change and apply spec updates (use \`--skip-specs\` only for tooling-only work).
2. Run \`openspec archive <id> --yes\` to let the CLI move the change and apply spec updates without prompts (use \`--skip-specs\` only for tooling-only work).
3. Review the command output to confirm the target specs were updated and the change landed in \`changes/archive/\`.
4. Validate with \`openspec validate --strict\` and inspect with \`openspec show <id>\` if anything looks off.`;
+76 -49
View File
@@ -1,10 +1,9 @@
import path from 'path';
import { FileSystemUtils } from '../utils/file-system.js';
import { OPENSPEC_DIR_NAME, OPENSPEC_MARKERS } from './config.js';
import { agentsTemplate } from './templates/agents-template.js';
import { TemplateManager } from './templates/index.js';
import { OPENSPEC_DIR_NAME } from './config.js';
import { ToolRegistry } from './configurators/registry.js';
import { SlashCommandRegistry } from './configurators/slash/registry.js';
import { agentsTemplate } from './templates/agents-template.js';
export class UpdateCommand {
async execute(projectPath: string): Promise<void> {
@@ -19,41 +18,51 @@ export class UpdateCommand {
// 2. Update AGENTS.md (full replacement)
const agentsPath = path.join(openspecPath, 'AGENTS.md');
const rootAgentsPath = path.join(resolvedProjectPath, 'AGENTS.md');
const rootAgentsExisted = await FileSystemUtils.fileExists(rootAgentsPath);
await FileSystemUtils.writeFile(agentsPath, agentsTemplate);
const agentsStandardContent = TemplateManager.getAgentsStandardTemplate();
await FileSystemUtils.updateFileWithMarkers(
rootAgentsPath,
agentsStandardContent,
OPENSPEC_MARKERS.start,
OPENSPEC_MARKERS.end
);
// 3. Update existing AI tool configuration files only
const configurators = ToolRegistry.getAll();
const slashConfigurators = SlashCommandRegistry.getAll();
let updatedFiles: string[] = [];
let failedFiles: string[] = [];
let updatedSlashFiles: string[] = [];
let failedSlashTools: string[] = [];
const updatedFiles: string[] = [];
const createdFiles: string[] = [];
const failedFiles: string[] = [];
const updatedSlashFiles: string[] = [];
const failedSlashTools: string[] = [];
for (const configurator of configurators) {
const configFilePath = path.join(resolvedProjectPath, configurator.configFileName);
// Only update if the file already exists
if (await FileSystemUtils.fileExists(configFilePath)) {
try {
if (!await FileSystemUtils.canWriteFile(configFilePath)) {
throw new Error(`Insufficient permissions to modify ${configurator.configFileName}`);
}
await configurator.configure(resolvedProjectPath, openspecPath);
updatedFiles.push(configurator.configFileName);
} catch (error) {
failedFiles.push(configurator.configFileName);
console.error(`Failed to update ${configurator.configFileName}: ${error instanceof Error ? error.message : String(error)}`);
const configFilePath = path.join(
resolvedProjectPath,
configurator.configFileName
);
const fileExists = await FileSystemUtils.fileExists(configFilePath);
const shouldConfigure =
fileExists || configurator.configFileName === 'AGENTS.md';
if (!shouldConfigure) {
continue;
}
try {
if (fileExists && !await FileSystemUtils.canWriteFile(configFilePath)) {
throw new Error(
`Insufficient permissions to modify ${configurator.configFileName}`
);
}
await configurator.configure(resolvedProjectPath, openspecPath);
updatedFiles.push(configurator.configFileName);
if (!fileExists) {
createdFiles.push(configurator.configFileName);
}
} catch (error) {
failedFiles.push(configurator.configFileName);
console.error(
`Failed to update ${configurator.configFileName}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
@@ -63,38 +72,56 @@ export class UpdateCommand {
}
try {
const updated = await slashConfigurator.updateExisting(resolvedProjectPath, openspecPath);
updatedSlashFiles = updatedSlashFiles.concat(updated);
const updated = await slashConfigurator.updateExisting(
resolvedProjectPath,
openspecPath
);
updatedSlashFiles.push(...updated);
} catch (error) {
failedSlashTools.push(slashConfigurator.toolId);
console.error(
`Failed to update slash commands for ${slashConfigurator.toolId}: ${error instanceof Error ? error.message : String(error)}`
`Failed to update slash commands for ${slashConfigurator.toolId}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
// 4. Success message (ASCII-safe)
const instructionUpdates = ['openspec/AGENTS.md'];
instructionUpdates.push(`AGENTS.md${rootAgentsExisted ? '' : ' (created)'}`);
const summaryParts: string[] = [];
const instructionFiles: string[] = ['openspec/AGENTS.md'];
const messages: string[] = [`Updated OpenSpec instructions (${instructionUpdates.join(', ')})`];
if (updatedFiles.length > 0) {
messages.push(`Updated AI tool files: ${updatedFiles.join(', ')}`);
if (updatedFiles.includes('AGENTS.md')) {
instructionFiles.push(
createdFiles.includes('AGENTS.md') ? 'AGENTS.md (created)' : 'AGENTS.md'
);
}
summaryParts.push(
`Updated OpenSpec instructions (${instructionFiles.join(', ')})`
);
const aiToolFiles = updatedFiles.filter((file) => file !== 'AGENTS.md');
if (aiToolFiles.length > 0) {
summaryParts.push(`Updated AI tool files: ${aiToolFiles.join(', ')}`);
}
if (updatedSlashFiles.length > 0) {
messages.push(`Updated slash commands: ${updatedSlashFiles.join(', ')}`);
}
if (failedFiles.length > 0) {
messages.push(`Failed to update: ${failedFiles.join(', ')}`);
summaryParts.push(
`Updated slash commands: ${updatedSlashFiles.join(', ')}`
);
}
if (failedSlashTools.length > 0) {
messages.push(`Failed slash command updates: ${failedSlashTools.join(', ')}`);
const failedItems = [
...failedFiles,
...failedSlashTools.map(
(toolId) => `slash command refresh (${toolId})`
),
];
if (failedItems.length > 0) {
summaryParts.push(`Failed to update: ${failedItems.join(', ')}`);
}
console.log(messages.join('\n'));
console.log(summaryParts.join(' | '));
}
}
+6 -4
View File
@@ -331,7 +331,8 @@ export class Validator {
}
private extractNameFromPath(filePath: string): string {
const parts = filePath.split('/');
const normalizedPath = filePath.replaceAll('\\', '/');
const parts = normalizedPath.split('/');
// Look for the directory name after 'specs' or 'changes'
for (let i = parts.length - 1; i >= 0; i--) {
@@ -343,8 +344,9 @@ export class Validator {
}
// Fallback to filename without extension if not in expected structure
const fileName = parts[parts.length - 1];
return fileName.replace('.md', '');
const fileName = parts[parts.length - 1] ?? '';
const dotIndex = fileName.lastIndexOf('.');
return dotIndex > 0 ? fileName.slice(0, dotIndex) : fileName;
}
private createReport(issues: ValidationIssue[]): ValidationReport {
@@ -393,4 +395,4 @@ export class Validator {
const matches = blockRaw.match(/^####\s+/gm);
return matches ? matches.length : 0;
}
}
}
+52 -4
View File
@@ -1,6 +1,46 @@
import { promises as fs } from 'fs';
import path from 'path';
function isMarkerOnOwnLine(content: string, markerIndex: number, markerLength: number): boolean {
let leftIndex = markerIndex - 1;
while (leftIndex >= 0 && content[leftIndex] !== '\n') {
const char = content[leftIndex];
if (char !== ' ' && char !== '\t' && char !== '\r') {
return false;
}
leftIndex--;
}
let rightIndex = markerIndex + markerLength;
while (rightIndex < content.length && content[rightIndex] !== '\n') {
const char = content[rightIndex];
if (char !== ' ' && char !== '\t' && char !== '\r') {
return false;
}
rightIndex++;
}
return true;
}
function findMarkerIndex(
content: string,
marker: string,
fromIndex = 0
): number {
let currentIndex = content.indexOf(marker, fromIndex);
while (currentIndex !== -1) {
if (isMarkerOnOwnLine(content, currentIndex, marker.length)) {
return currentIndex;
}
currentIndex = content.indexOf(marker, currentIndex + marker.length);
}
return -1;
}
export class FileSystemUtils {
static async createDirectory(dirPath: string): Promise<void> {
await fs.mkdir(dirPath, { recursive: true });
@@ -70,10 +110,18 @@ export class FileSystemUtils {
if (await this.fileExists(filePath)) {
existingContent = await this.readFile(filePath);
const startIndex = existingContent.indexOf(startMarker);
const endIndex = existingContent.indexOf(endMarker);
const startIndex = findMarkerIndex(existingContent, startMarker);
const endIndex = startIndex !== -1
? findMarkerIndex(existingContent, endMarker, startIndex + startMarker.length)
: findMarkerIndex(existingContent, endMarker);
if (startIndex !== -1 && endIndex !== -1) {
if (endIndex < startIndex) {
throw new Error(
`Invalid marker state in ${filePath}. End marker appears before start marker.`
);
}
const before = existingContent.substring(0, startIndex);
const after = existingContent.substring(endIndex + endMarker.length);
existingContent = before + startMarker + '\n' + content + '\n' + endMarker + after;
@@ -109,4 +157,4 @@ export class FileSystemUtils {
return false;
}
}
}
}
+56
View File
@@ -0,0 +1,56 @@
import { afterAll, describe, it, expect } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import { tmpdir } from 'os';
import { runCLI, cliProjectRoot } from '../helpers/run-cli.js';
const tempRoots: string[] = [];
async function prepareFixture(fixtureName: string): Promise<string> {
const base = await fs.mkdtemp(path.join(tmpdir(), 'openspec-cli-e2e-'));
tempRoots.push(base);
const projectDir = path.join(base, 'project');
await fs.mkdir(projectDir, { recursive: true });
const fixtureDir = path.join(cliProjectRoot, 'test', 'fixtures', fixtureName);
await fs.cp(fixtureDir, projectDir, { recursive: true });
return projectDir;
}
afterAll(async () => {
await Promise.all(tempRoots.map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe('openspec CLI e2e basics', () => {
it('shows help output', async () => {
const result = await runCLI(['--help']);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Usage: openspec');
expect(result.stderr).toBe('');
});
it('reports the package version', async () => {
const pkgRaw = await fs.readFile(path.join(cliProjectRoot, 'package.json'), 'utf-8');
const pkg = JSON.parse(pkgRaw);
const result = await runCLI(['--version']);
expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe(pkg.version);
});
it('validates the tmp-init fixture with --all --json', async () => {
const projectDir = await prepareFixture('tmp-init');
const result = await runCLI(['validate', '--all', '--json'], { cwd: projectDir });
expect(result.exitCode).toBe(0);
const output = result.stdout.trim();
expect(output).not.toBe('');
const json = JSON.parse(output);
expect(json.summary?.totals?.failed).toBe(0);
expect(json.items.some((item: any) => item.id === 'c1' && item.type === 'change')).toBe(true);
});
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 });
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("Unknown item 'does-not-exist'");
});
});
+91 -80
View File
@@ -1,31 +1,33 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import { runCLI } from '../helpers/run-cli.js';
describe('top-level validate command', () => {
const projectRoot = process.cwd();
const testDir = path.join(projectRoot, 'test-validate-command-tmp');
const changesDir = path.join(testDir, 'openspec', 'changes');
const specsDir = path.join(testDir, 'openspec', 'specs');
const bin = path.join(projectRoot, 'bin', 'openspec.js');
beforeEach(async () => {
await fs.mkdir(changesDir, { recursive: true });
await fs.mkdir(specsDir, { recursive: true });
// Create a valid spec
const specContent = `## Purpose
Valid spec for testing.
## Requirements
### Requirement: Foo
Text
#### Scenario: Bar
Given A\nWhen B\nThen C`;
const specContent = [
'## Purpose',
'This spec ensures the validation harness exercises a deterministic alpha module for automated tests.',
'',
'## Requirements',
'',
'### Requirement: Alpha module SHALL produce deterministic output',
'The alpha module SHALL produce a deterministic response for validation.',
'',
'#### Scenario: Deterministic alpha run',
'- **GIVEN** a configured alpha module',
'- **WHEN** the module runs the default flow',
'- **THEN** the output matches the expected fixture result',
].join('\n');
await fs.mkdir(path.join(specsDir, 'alpha'), { recursive: true });
await fs.writeFile(path.join(specsDir, 'alpha', 'spec.md'), specContent, 'utf-8');
@@ -33,10 +35,26 @@ Given A\nWhen B\nThen C`;
const changeContent = `# Test Change\n\n## Why\nBecause reasons that are sufficiently long for validation.\n\n## What Changes\n- **alpha:** Add something`;
await fs.mkdir(path.join(changesDir, 'c1'), { recursive: true });
await fs.writeFile(path.join(changesDir, 'c1', 'proposal.md'), changeContent, 'utf-8');
const deltaContent = [
'## ADDED Requirements',
'### Requirement: Validator SHALL support alpha change deltas',
'The validator SHALL accept deltas provided by the test harness.',
'',
'#### Scenario: Apply alpha delta',
'- **GIVEN** the test change delta',
'- **WHEN** openspec validate runs',
'- **THEN** the validator reports the change as valid',
].join('\n');
const c1DeltaDir = path.join(changesDir, 'c1', 'specs', 'alpha');
await fs.mkdir(c1DeltaDir, { recursive: true });
await fs.writeFile(path.join(c1DeltaDir, 'spec.md'), deltaContent, 'utf-8');
// Duplicate name for ambiguity test
await fs.mkdir(path.join(changesDir, 'dup'), { recursive: true });
await fs.writeFile(path.join(changesDir, 'dup', 'proposal.md'), changeContent, 'utf-8');
const dupDeltaDir = path.join(changesDir, 'dup', 'specs', 'dup');
await fs.mkdir(dupDeltaDir, { recursive: true });
await fs.writeFile(path.join(dupDeltaDir, 'spec.md'), deltaContent, 'utf-8');
await fs.mkdir(path.join(specsDir, 'dup'), { recursive: true });
await fs.writeFile(path.join(specsDir, 'dup', 'spec.md'), specContent, 'utf-8');
});
@@ -45,78 +63,71 @@ Given A\nWhen B\nThen C`;
await fs.rm(testDir, { recursive: true, force: true });
});
it('prints a helpful hint when no args in non-interactive mode', () => {
const originalCwd = process.cwd();
const originalEnv = { ...process.env };
try {
process.chdir(testDir);
process.env.OPEN_SPEC_INTERACTIVE = '0';
let err: any;
try {
execSync(`node ${bin} validate`, { encoding: 'utf-8' });
} catch (e) { err = e; }
expect(err).toBeDefined();
expect(err.status).not.toBe(0);
expect(err.stderr.toString()).toContain('Nothing to validate. Try one of:');
} finally {
process.chdir(originalCwd);
process.env = originalEnv;
}
it('prints a helpful hint when no args in non-interactive mode', async () => {
const result = await runCLI(['validate'], { cwd: testDir });
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('Nothing to validate. Try one of:');
});
it('validates all with --all and outputs JSON summary', () => {
const originalCwd = process.cwd();
try {
process.chdir(testDir);
let outStr = '';
try {
outStr = execSync(`node ${bin} validate --all --json`, { encoding: 'utf-8' });
} catch (e: any) {
// If exit code is non-zero (e.g., on failures), still parse stdout JSON
outStr = e.stdout?.toString?.() ?? '';
}
const json = JSON.parse(outStr);
expect(Array.isArray(json.items)).toBe(true);
expect(json.summary?.totals?.items).toBeDefined();
expect(json.version).toBe('1.0');
} finally {
process.chdir(originalCwd);
}
it('validates all with --all and outputs JSON summary', async () => {
const result = await runCLI(['validate', '--all', '--json'], { cwd: testDir });
expect(result.exitCode).toBe(0);
const output = result.stdout.trim();
expect(output).not.toBe('');
const json = JSON.parse(output);
expect(Array.isArray(json.items)).toBe(true);
expect(json.summary?.totals?.items).toBeDefined();
expect(json.version).toBe('1.0');
});
it('validates only specs with --specs and respects --concurrency', () => {
const originalCwd = process.cwd();
try {
process.chdir(testDir);
let outStr = '';
try {
outStr = execSync(`node ${bin} validate --specs --json --concurrency 1`, { encoding: 'utf-8' });
} catch (e: any) {
outStr = e.stdout?.toString?.() ?? '';
}
const json = JSON.parse(outStr);
// All items should be specs
expect(json.items.every((i: any) => i.type === 'spec')).toBe(true);
} finally {
process.chdir(originalCwd);
}
it('validates only specs with --specs and respects --concurrency', async () => {
const result = await runCLI(['validate', '--specs', '--json', '--concurrency', '1'], { cwd: testDir });
expect(result.exitCode).toBe(0);
const output = result.stdout.trim();
expect(output).not.toBe('');
const json = JSON.parse(output);
expect(json.items.every((i: any) => i.type === 'spec')).toBe(true);
});
it('errors on ambiguous item names and suggests type override', () => {
const originalCwd = process.cwd();
try {
process.chdir(testDir);
let err: any;
try {
execSync(`node ${bin} validate dup`, { encoding: 'utf-8' });
} catch (e) { err = e; }
expect(err).toBeDefined();
expect(err.stderr.toString()).toContain('Ambiguous item');
expect(err.status).not.toBe(0);
} finally {
process.chdir(originalCwd);
}
it('errors on ambiguous item names and suggests type override', async () => {
const result = await runCLI(['validate', 'dup'], { cwd: testDir });
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('Ambiguous item');
});
it('accepts change proposals saved with CRLF line endings', async () => {
const changeId = 'crlf-change';
const toCrlf = (segments: string[]) => segments.join('\n').replace(/\n/g, '\r\n');
const crlfContent = toCrlf([
'# CRLF Proposal',
'',
'## Why',
'This change verifies validation works with Windows line endings.',
'',
'## What Changes',
'- **alpha:** Ensure validation passes on CRLF files',
]);
await fs.mkdir(path.join(changesDir, changeId), { recursive: true });
await fs.writeFile(path.join(changesDir, changeId, 'proposal.md'), crlfContent, 'utf-8');
const deltaContent = toCrlf([
'## ADDED Requirements',
'### Requirement: Parser SHALL accept CRLF change proposals',
'The parser SHALL accept CRLF change proposals without manual edits.',
'',
'#### Scenario: Validate CRLF change',
'- **GIVEN** a change proposal saved with CRLF line endings',
'- **WHEN** a developer runs openspec validate on the proposal',
'- **THEN** validation succeeds without section errors',
]);
const deltaDir = path.join(changesDir, changeId, 'specs', 'alpha');
await fs.mkdir(deltaDir, { recursive: true });
await fs.writeFile(path.join(deltaDir, 'spec.md'), deltaContent, 'utf-8');
const result = await runCLI(['validate', changeId], { cwd: testDir });
expect(result.exitCode).toBe(0);
});
});
+140 -44
View File
@@ -43,7 +43,7 @@ describe('InitCommand', () => {
selectionQueue = [];
mockPrompt.mockReset();
initCommand = new InitCommand({ prompt: mockPrompt });
// Mock console.log to suppress output during tests
vi.spyOn(console, 'log').mockImplementation(() => {});
});
@@ -56,14 +56,20 @@ describe('InitCommand', () => {
describe('execute', () => {
it('should create OpenSpec directory structure', async () => {
queueSelections('claude', DONE);
await initCommand.execute(testDir);
const openspecPath = path.join(testDir, 'openspec');
expect(await directoryExists(openspecPath)).toBe(true);
expect(await directoryExists(path.join(openspecPath, 'specs'))).toBe(true);
expect(await directoryExists(path.join(openspecPath, 'changes'))).toBe(true);
expect(await directoryExists(path.join(openspecPath, 'changes', 'archive'))).toBe(true);
expect(await directoryExists(path.join(openspecPath, 'specs'))).toBe(
true
);
expect(await directoryExists(path.join(openspecPath, 'changes'))).toBe(
true
);
expect(
await directoryExists(path.join(openspecPath, 'changes', 'archive'))
).toBe(true);
});
it('should create AGENTS.md and project.md', async () => {
@@ -73,41 +79,52 @@ describe('InitCommand', () => {
const openspecPath = path.join(testDir, 'openspec');
expect(await fileExists(path.join(openspecPath, 'AGENTS.md'))).toBe(true);
expect(await fileExists(path.join(openspecPath, 'project.md'))).toBe(true);
expect(await fileExists(path.join(openspecPath, 'project.md'))).toBe(
true
);
const agentsContent = await fs.readFile(path.join(openspecPath, 'AGENTS.md'), 'utf-8');
const agentsContent = await fs.readFile(
path.join(openspecPath, 'AGENTS.md'),
'utf-8'
);
expect(agentsContent).toContain('OpenSpec Instructions');
const projectContent = await fs.readFile(path.join(openspecPath, 'project.md'), 'utf-8');
const projectContent = await fs.readFile(
path.join(openspecPath, 'project.md'),
'utf-8'
);
expect(projectContent).toContain('Project Context');
});
it('should create CLAUDE.md when Claude Code is selected', async () => {
queueSelections('claude', DONE);
await initCommand.execute(testDir);
const claudePath = path.join(testDir, 'CLAUDE.md');
expect(await fileExists(claudePath)).toBe(true);
const content = await fs.readFile(claudePath, 'utf-8');
expect(content).toContain('<!-- OPENSPEC:START -->');
expect(content).toContain('OpenSpec Instructions');
expect(content).toContain("@/openspec/AGENTS.md");
expect(content).toContain('openspec update');
expect(content).toContain('<!-- OPENSPEC:END -->');
});
it('should update existing CLAUDE.md with markers', async () => {
queueSelections('claude', DONE);
const claudePath = path.join(testDir, 'CLAUDE.md');
const existingContent = '# My Project Instructions\nCustom instructions here';
const existingContent =
'# My Project Instructions\nCustom instructions here';
await fs.writeFile(claudePath, existingContent);
await initCommand.execute(testDir);
const updatedContent = await fs.readFile(claudePath, 'utf-8');
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
expect(updatedContent).toContain('OpenSpec Instructions');
expect(updatedContent).toContain("@/openspec/AGENTS.md");
expect(updatedContent).toContain('openspec update');
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
expect(updatedContent).toContain('Custom instructions here');
});
@@ -122,7 +139,8 @@ describe('InitCommand', () => {
const content = await fs.readFile(rootAgentsPath, 'utf-8');
expect(content).toContain('<!-- OPENSPEC:START -->');
expect(content).toContain('OpenSpec Instructions');
expect(content).toContain("@/openspec/AGENTS.md");
expect(content).toContain('openspec update');
expect(content).toContain('<!-- OPENSPEC:END -->');
const claudeExists = await fileExists(path.join(testDir, 'CLAUDE.md'));
@@ -134,9 +152,18 @@ describe('InitCommand', () => {
await initCommand.execute(testDir);
const claudeProposal = path.join(testDir, '.claude/commands/openspec/proposal.md');
const claudeApply = path.join(testDir, '.claude/commands/openspec/apply.md');
const claudeArchive = path.join(testDir, '.claude/commands/openspec/archive.md');
const claudeProposal = path.join(
testDir,
'.claude/commands/openspec/proposal.md'
);
const claudeApply = path.join(
testDir,
'.claude/commands/openspec/apply.md'
);
const claudeArchive = path.join(
testDir,
'.claude/commands/openspec/archive.md'
);
expect(await fileExists(claudeProposal)).toBe(true);
expect(await fileExists(claudeApply)).toBe(true);
@@ -154,7 +181,9 @@ describe('InitCommand', () => {
const archiveContent = await fs.readFile(claudeArchive, 'utf-8');
expect(archiveContent).toContain('name: OpenSpec: Archive');
expect(archiveContent).toContain('openspec archive <id>');
expect(archiveContent).toContain('`--skip-specs` only for tooling-only work');
expect(archiveContent).toContain(
'`--skip-specs` only for tooling-only work'
);
});
it('should create Cursor slash command files with templates', async () => {
@@ -162,9 +191,18 @@ describe('InitCommand', () => {
await initCommand.execute(testDir);
const cursorProposal = path.join(testDir, '.cursor/commands/openspec-proposal.md');
const cursorApply = path.join(testDir, '.cursor/commands/openspec-apply.md');
const cursorArchive = path.join(testDir, '.cursor/commands/openspec-archive.md');
const cursorProposal = path.join(
testDir,
'.cursor/commands/openspec-proposal.md'
);
const cursorApply = path.join(
testDir,
'.cursor/commands/openspec-apply.md'
);
const cursorArchive = path.join(
testDir,
'.cursor/commands/openspec-archive.md'
);
expect(await fileExists(cursorProposal)).toBe(true);
expect(await fileExists(cursorApply)).toBe(true);
@@ -183,27 +221,76 @@ describe('InitCommand', () => {
expect(archiveContent).toContain('openspec list --specs');
});
it('should create OpenCode slash command files with templates', async () => {
queueSelections('opencode', DONE);
await initCommand.execute(testDir);
const openCodeProposal = path.join(
testDir,
'.opencode/command/openspec-proposal.md'
);
const openCodeApply = path.join(
testDir,
'.opencode/command/openspec-apply.md'
);
const openCodeArchive = path.join(
testDir,
'.opencode/command/openspec-archive.md'
);
expect(await fileExists(openCodeProposal)).toBe(true);
expect(await fileExists(openCodeApply)).toBe(true);
expect(await fileExists(openCodeArchive)).toBe(true);
const proposalContent = await fs.readFile(openCodeProposal, 'utf-8');
expect(proposalContent).toContain('agent: build');
expect(proposalContent).toContain(
'description: Scaffold a new OpenSpec change and validate strictly.'
);
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
const applyContent = await fs.readFile(openCodeApply, 'utf-8');
expect(applyContent).toContain('agent: build');
expect(applyContent).toContain(
'description: Implement an approved OpenSpec change and keep tasks in sync.'
);
expect(applyContent).toContain('Work through tasks sequentially');
const archiveContent = await fs.readFile(openCodeArchive, 'utf-8');
expect(archiveContent).toContain('agent: build');
expect(archiveContent).toContain(
'description: Archive a deployed OpenSpec change and update specs.'
);
expect(archiveContent).toContain('openspec list --specs');
});
it('should add new tool when OpenSpec already exists', async () => {
queueSelections('claude', DONE, 'cursor', DONE);
await initCommand.execute(testDir);
await initCommand.execute(testDir);
const cursorProposal = path.join(testDir, '.cursor/commands/openspec-proposal.md');
const cursorProposal = path.join(
testDir,
'.cursor/commands/openspec-proposal.md'
);
expect(await fileExists(cursorProposal)).toBe(true);
});
it('should error when extend mode selects no tools', async () => {
queueSelections('claude', DONE, DONE);
await initCommand.execute(testDir);
await expect(initCommand.execute(testDir)).rejects.toThrow(/OpenSpec seems to already be initialized/);
await expect(initCommand.execute(testDir)).rejects.toThrow(
/OpenSpec seems to already be initialized/
);
});
it('should handle non-existent target directory', async () => {
queueSelections('claude', DONE);
const newDir = path.join(testDir, 'new-project');
await initCommand.execute(newDir);
const openspecPath = path.join(newDir, 'openspec');
expect(await directoryExists(openspecPath)).toBe(true);
});
@@ -211,9 +298,9 @@ describe('InitCommand', () => {
it('should display success message with selected tool name', async () => {
queueSelections('claude', DONE);
const logSpy = vi.spyOn(console, 'log');
await initCommand.execute(testDir);
const calls = logSpy.mock.calls.flat().join('\n');
expect(calls).toContain('Copy these prompts to Claude Code');
});
@@ -225,7 +312,9 @@ describe('InitCommand', () => {
await initCommand.execute(testDir);
const calls = logSpy.mock.calls.flat().join('\n');
expect(calls).toContain('Copy these prompts to your AGENTS.md-compatible assistant');
expect(calls).toContain(
'Copy these prompts to your AGENTS.md-compatible assistant'
);
});
});
@@ -237,7 +326,7 @@ describe('InitCommand', () => {
expect(mockPrompt).toHaveBeenCalledWith(
expect.objectContaining({
baseMessage: expect.stringContaining('Which AI tools do you use?')
baseMessage: expect.stringContaining('Which AI tools do you use?'),
})
);
});
@@ -247,7 +336,7 @@ describe('InitCommand', () => {
queueSelections('claude', DONE);
await initCommand.execute(testDir);
// When other tools are added, we'd test their specific configurations here
const claudePath = path.join(testDir, 'CLAUDE.md');
expect(await fileExists(claudePath)).toBe(true);
@@ -259,7 +348,9 @@ describe('InitCommand', () => {
await initCommand.execute(testDir);
const secondRunArgs = mockPrompt.mock.calls[1][0];
const claudeChoice = secondRunArgs.choices.find((choice: any) => choice.value === 'claude');
const claudeChoice = secondRunArgs.choices.find(
(choice: any) => choice.value === 'claude'
);
expect(claudeChoice.configured).toBe(true);
});
});
@@ -269,16 +360,21 @@ describe('InitCommand', () => {
// This is tricky to test cross-platform, but we can test the error message
const readOnlyDir = path.join(testDir, 'readonly');
await fs.mkdir(readOnlyDir);
// Mock the permission check to fail
const originalCheck = fs.writeFile;
vi.spyOn(fs, 'writeFile').mockImplementation(async (filePath: any, ...args: any[]) => {
if (typeof filePath === 'string' && filePath.includes('.openspec-test-')) {
throw new Error('EACCES: permission denied');
vi.spyOn(fs, 'writeFile').mockImplementation(
async (filePath: any, ...args: any[]) => {
if (
typeof filePath === 'string' &&
filePath.includes('.openspec-test-')
) {
throw new Error('EACCES: permission denied');
}
return originalCheck.call(fs, filePath, ...args);
}
return originalCheck.call(fs, filePath, ...args);
});
);
queueSelections('claude', DONE);
await expect(initCommand.execute(readOnlyDir)).rejects.toThrow(
/Insufficient permissions/
+19
View File
@@ -169,6 +169,25 @@ Some general description of changes without specific deltas`;
expect(change.deltas).toHaveLength(0);
});
it('parses change documents saved with CRLF line endings', () => {
const crlfContent = [
'# CRLF Change',
'',
'## Why',
'Reasons on Windows editors should parse like POSIX environments.',
'',
'## What Changes',
'- **alpha:** Add cross-platform parsing coverage',
].join('\r\n');
const parser = new MarkdownParser(crlfContent);
const change = parser.parseChange('crlf-change');
expect(change.why).toContain('Windows editors should parse');
expect(change.deltas).toHaveLength(1);
expect(change.deltas[0].spec).toBe('alpha');
});
});
describe('section parsing', () => {
+119 -34
View File
@@ -15,11 +15,11 @@ describe('UpdateCommand', () => {
// Create a temporary test directory
testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);
await fs.mkdir(testDir, { recursive: true });
// Create openspec directory
const openspecDir = path.join(testDir, 'openspec');
await fs.mkdir(openspecDir, { recursive: true });
updateCommand = new UpdateCommand();
});
@@ -43,7 +43,7 @@ More content after.`;
await fs.writeFile(claudePath, initialContent);
const consoleSpy = vi.spyOn(console, 'log');
// Execute update command
await updateCommand.execute(testDir);
@@ -51,20 +51,26 @@ More content after.`;
const updatedContent = await fs.readFile(claudePath, 'utf-8');
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
expect(updatedContent).toContain('OpenSpec Instructions');
expect(updatedContent).toContain("@/openspec/AGENTS.md");
expect(updatedContent).toContain('openspec update');
expect(updatedContent).toContain('Some existing content here');
expect(updatedContent).toContain('More content after');
// Check console output
const [logMessage] = consoleSpy.mock.calls[0];
expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md');
expect(logMessage).toContain(
'Updated OpenSpec instructions (openspec/AGENTS.md'
);
expect(logMessage).toContain('AGENTS.md (created)');
expect(logMessage).toContain('Updated AI tool files: CLAUDE.md');
consoleSpy.mockRestore();
});
it('should refresh existing Claude slash command files', async () => {
const proposalPath = path.join(testDir, '.claude/commands/openspec/proposal.md');
const proposalPath = path.join(
testDir,
'.claude/commands/openspec/proposal.md'
);
await fs.mkdir(path.dirname(proposalPath), { recursive: true });
const initialContent = `---
name: OpenSpec: Proposal
@@ -84,13 +90,19 @@ Old slash content
const updated = await fs.readFile(proposalPath, 'utf-8');
expect(updated).toContain('name: OpenSpec: Proposal');
expect(updated).toContain('**Guardrails**');
expect(updated).toContain('Validate with `openspec validate <id> --strict`');
expect(updated).toContain(
'Validate with `openspec validate <id> --strict`'
);
expect(updated).not.toContain('Old slash content');
const [logMessage] = consoleSpy.mock.calls[0];
expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md');
expect(logMessage).toContain(
'Updated OpenSpec instructions (openspec/AGENTS.md'
);
expect(logMessage).toContain('AGENTS.md (created)');
expect(logMessage).toContain('Updated slash commands: .claude/commands/openspec/proposal.md');
expect(logMessage).toContain(
'Updated slash commands: .claude/commands/openspec/proposal.md'
);
consoleSpy.mockRestore();
});
@@ -98,7 +110,7 @@ Old slash content
it('should not create CLAUDE.md if it does not exist', async () => {
// Ensure CLAUDE.md does not exist
const claudePath = path.join(testDir, 'CLAUDE.md');
// Execute update command
await updateCommand.execute(testDir);
@@ -131,9 +143,51 @@ Old body
expect(updated).not.toContain('Old body');
const [logMessage] = consoleSpy.mock.calls[0];
expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md');
expect(logMessage).toContain(
'Updated OpenSpec instructions (openspec/AGENTS.md'
);
expect(logMessage).toContain('AGENTS.md (created)');
expect(logMessage).toContain('Updated slash commands: .cursor/commands/openspec-apply.md');
expect(logMessage).toContain(
'Updated slash commands: .cursor/commands/openspec-apply.md'
);
consoleSpy.mockRestore();
});
it('should refresh existing OpenCode slash command files', async () => {
const openCodePath = path.join(
testDir,
'.opencode/command/openspec-apply.md'
);
await fs.mkdir(path.dirname(openCodePath), { recursive: true });
const initialContent = `---
name: /openspec-apply
id: openspec-apply
category: OpenSpec
description: Old description
---
<!-- OPENSPEC:START -->
Old body
<!-- OPENSPEC:END -->`;
await fs.writeFile(openCodePath, initialContent);
const consoleSpy = vi.spyOn(console, 'log');
await updateCommand.execute(testDir);
const updated = await fs.readFile(openCodePath, 'utf-8');
expect(updated).toContain('id: openspec-apply');
expect(updated).toContain('Work through tasks sequentially');
expect(updated).not.toContain('Old body');
const [logMessage] = consoleSpy.mock.calls[0];
expect(logMessage).toContain(
'Updated OpenSpec instructions (openspec/AGENTS.md'
);
expect(logMessage).toContain('AGENTS.md (created)');
expect(logMessage).toContain(
'Updated slash commands: .opencode/command/openspec-apply.md'
);
consoleSpy.mockRestore();
});
@@ -145,7 +199,9 @@ Old body
// Should only update OpenSpec instructions
const [logMessage] = consoleSpy.mock.calls[0];
expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md');
expect(logMessage).toContain(
'Updated OpenSpec instructions (openspec/AGENTS.md'
);
expect(logMessage).toContain('AGENTS.md (created)');
consoleSpy.mockRestore();
});
@@ -157,23 +213,33 @@ Old body
// For now, we test with just CLAUDE.md.
const claudePath = path.join(testDir, 'CLAUDE.md');
await fs.mkdir(path.dirname(claudePath), { recursive: true });
await fs.writeFile(claudePath, '<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->');
await fs.writeFile(
claudePath,
'<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->'
);
const consoleSpy = vi.spyOn(console, 'log');
await updateCommand.execute(testDir);
// Should report updating with new format
const [logMessage] = consoleSpy.mock.calls[0];
expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md');
expect(logMessage).toContain(
'Updated OpenSpec instructions (openspec/AGENTS.md'
);
expect(logMessage).toContain('AGENTS.md (created)');
expect(logMessage).toContain('Updated AI tool files: CLAUDE.md');
consoleSpy.mockRestore();
});
it('should skip creating missing slash commands during update', async () => {
const proposalPath = path.join(testDir, '.claude/commands/openspec/proposal.md');
const proposalPath = path.join(
testDir,
'.claude/commands/openspec/proposal.md'
);
await fs.mkdir(path.dirname(proposalPath), { recursive: true });
await fs.writeFile(proposalPath, `---
await fs.writeFile(
proposalPath,
`---
name: OpenSpec: Proposal
description: Existing file
category: OpenSpec
@@ -181,12 +247,17 @@ tags: [openspec, change]
---
<!-- OPENSPEC:START -->
Old content
<!-- OPENSPEC:END -->`);
<!-- OPENSPEC:END -->`
);
await updateCommand.execute(testDir);
const applyExists = await FileSystemUtils.fileExists(path.join(testDir, '.claude/commands/openspec/apply.md'));
const archiveExists = await FileSystemUtils.fileExists(path.join(testDir, '.claude/commands/openspec/archive.md'));
const applyExists = await FileSystemUtils.fileExists(
path.join(testDir, '.claude/commands/openspec/apply.md')
);
const archiveExists = await FileSystemUtils.fileExists(
path.join(testDir, '.claude/commands/openspec/archive.md')
);
expect(applyExists).toBe(false);
expect(archiveExists).toBe(false);
@@ -195,7 +266,7 @@ Old content
it('should never create new AI tool files', async () => {
// Get all configurators
const configurators = ToolRegistry.getAll();
// Execute update command
await updateCommand.execute(testDir);
@@ -233,7 +304,8 @@ Old content
const content = await fs.readFile(rootAgentsPath, 'utf-8');
expect(content).toContain('<!-- OPENSPEC:START -->');
expect(content).toContain('OpenSpec Instructions');
expect(content).toContain("@/openspec/AGENTS.md");
expect(content).toContain('openspec update');
expect(content).toContain('<!-- OPENSPEC:END -->');
});
@@ -249,11 +321,14 @@ Old content
const updated = await fs.readFile(rootAgentsPath, 'utf-8');
expect(updated).toContain('# Custom intro');
expect(updated).toContain('# Footnotes');
expect(updated).toContain('OpenSpec Instructions');
expect(updated).toContain("@/openspec/AGENTS.md");
expect(updated).toContain('openspec update');
expect(updated).not.toContain('Old content');
const [logMessage] = consoleSpy.mock.calls[0];
expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md, AGENTS.md)');
expect(logMessage).toContain(
'Updated OpenSpec instructions (openspec/AGENTS.md, AGENTS.md)'
);
expect(logMessage).not.toContain('AGENTS.md (created)');
consoleSpy.mockRestore();
@@ -261,7 +336,10 @@ Old content
it('should throw error if openspec directory does not exist', async () => {
// Remove openspec directory
await fs.rm(path.join(testDir, 'openspec'), { recursive: true, force: true });
await fs.rm(path.join(testDir, 'openspec'), {
recursive: true,
force: true,
});
// Execute update command and expect error
await expect(updateCommand.execute(testDir)).rejects.toThrow(
@@ -272,19 +350,24 @@ Old content
it('should handle configurator errors gracefully', async () => {
// Create CLAUDE.md file but make it read-only to cause an error
const claudePath = path.join(testDir, 'CLAUDE.md');
await fs.writeFile(claudePath, '<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->');
await fs.writeFile(
claudePath,
'<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->'
);
await fs.chmod(claudePath, 0o444); // Read-only
const consoleSpy = vi.spyOn(console, 'log');
const errorSpy = vi.spyOn(console, 'error');
const originalWriteFile = FileSystemUtils.writeFile.bind(FileSystemUtils);
const writeSpy = vi.spyOn(FileSystemUtils, 'writeFile').mockImplementation(async (filePath, content) => {
if (filePath.endsWith('CLAUDE.md')) {
throw new Error('EACCES: permission denied, open');
}
const writeSpy = vi
.spyOn(FileSystemUtils, 'writeFile')
.mockImplementation(async (filePath, content) => {
if (filePath.endsWith('CLAUDE.md')) {
throw new Error('EACCES: permission denied, open');
}
return originalWriteFile(filePath, content);
});
return originalWriteFile(filePath, content);
});
// Execute update command - should not throw
await updateCommand.execute(testDir);
@@ -292,7 +375,9 @@ Old content
// Should report the failure
expect(errorSpy).toHaveBeenCalled();
const [logMessage] = consoleSpy.mock.calls[0];
expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md');
expect(logMessage).toContain(
'Updated OpenSpec instructions (openspec/AGENTS.md'
);
expect(logMessage).toContain('AGENTS.md (created)');
expect(logMessage).toContain('Failed to update: CLAUDE.md');
@@ -0,0 +1,7 @@
# Test Change
## Why
Because reasons that are sufficiently long for validation.
## What Changes
- **alpha:** Add something
@@ -0,0 +1,8 @@
## ADDED Requirements
### Requirement: Parser SHALL accept CRLF change proposals
The parser SHALL accept CRLF change proposals without manual edits.
#### Scenario: Validate CRLF change
- **GIVEN** a change proposal saved with CRLF line endings
- **WHEN** a developer runs openspec validate on the proposal
- **THEN** validation succeeds without section errors
+12
View File
@@ -0,0 +1,12 @@
## Purpose
This spec ensures the validation harness exercises a deterministic alpha module for automated tests.
## Requirements
### Requirement: Alpha module SHALL produce deterministic output
The alpha module SHALL produce a deterministic response for validation.
#### Scenario: Deterministic alpha run
- **GIVEN** a configured alpha module
- **WHEN** the module runs the default flow
- **THEN** the output matches the expected fixture result
+139
View File
@@ -0,0 +1,139 @@
import { spawn } from 'child_process';
import { existsSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, '..', '..');
const cliEntry = path.join(projectRoot, 'dist', 'cli', 'index.js');
let buildPromise: Promise<void> | undefined;
interface RunCommandOptions {
cwd?: string;
env?: NodeJS.ProcessEnv;
}
interface RunCLIOptions {
cwd?: string;
env?: NodeJS.ProcessEnv;
input?: string;
timeoutMs?: number;
}
export interface RunCLIResult {
exitCode: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
timedOut: boolean;
command: string;
}
function runCommand(command: string, args: string[], options: RunCommandOptions = {}) {
return new Promise<void>((resolve, reject) => {
const child = spawn(command, args, {
cwd: options.cwd ?? projectRoot,
env: { ...process.env, ...options.env },
stdio: 'inherit',
shell: process.platform === 'win32',
});
child.on('error', (error) => reject(error));
child.on('close', (code, signal) => {
if (code === 0) {
resolve();
} else {
const reason = signal ? `signal ${signal}` : `exit code ${code}`;
reject(new Error(`Command failed (${reason}): ${command} ${args.join(' ')}`));
}
});
});
}
export async function ensureCliBuilt() {
if (existsSync(cliEntry)) {
return;
}
if (!buildPromise) {
buildPromise = runCommand('pnpm', ['run', 'build']).catch((error) => {
buildPromise = undefined;
throw error;
});
}
await buildPromise;
if (!existsSync(cliEntry)) {
throw new Error('CLI entry point missing after build. Expected dist/cli/index.js');
}
}
export async function runCLI(args: string[] = [], options: RunCLIOptions = {}): Promise<RunCLIResult> {
await ensureCliBuilt();
const finalArgs = Array.isArray(args) ? args : [args];
const invocation = [cliEntry, ...finalArgs].join(' ');
return new Promise<RunCLIResult>((resolve, reject) => {
const child = spawn(process.execPath, [cliEntry, ...finalArgs], {
cwd: options.cwd ?? projectRoot,
env: {
...process.env,
OPEN_SPEC_INTERACTIVE: '0',
...options.env,
},
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
});
let stdout = '';
let stderr = '';
let timedOut = false;
const timeout = options.timeoutMs
? setTimeout(() => {
timedOut = true;
child.kill('SIGKILL');
}, options.timeoutMs)
: undefined;
child.stdout?.setEncoding('utf-8');
child.stdout?.on('data', (chunk) => {
stdout += chunk;
});
child.stderr?.setEncoding('utf-8');
child.stderr?.on('data', (chunk) => {
stderr += chunk;
});
child.on('error', (error) => {
if (timeout) clearTimeout(timeout);
reject(error);
});
child.on('close', (code, signal) => {
if (timeout) clearTimeout(timeout);
resolve({
exitCode: code,
signal,
stdout,
stderr,
timedOut,
command: `node ${invocation}`,
});
});
if (options.input && child.stdin) {
child.stdin.end(options.input);
} else if (child.stdin) {
child.stdin.end();
}
});
}
export const cliProjectRoot = projectRoot;
+36 -1
View File
@@ -248,5 +248,40 @@ Line 5 with gap`;
const result = await fs.readFile(filePath, 'utf-8');
expect(result).toContain(content);
});
it('should ignore inline mentions of markers when updating content', async () => {
const filePath = path.join(testDir, 'inline-mentions.md');
const existingFile = `Intro referencing markers like ${START_MARKER} and ${END_MARKER} inside text.
${START_MARKER}
Original content
${END_MARKER}
`;
await fs.writeFile(filePath, existingFile);
await FileSystemUtils.updateFileWithMarkers(
filePath,
'Updated content',
START_MARKER,
END_MARKER
);
const firstResult = await fs.readFile(filePath, 'utf-8');
expect(firstResult).toContain('Intro referencing markers like');
expect(firstResult).toContain('Updated content');
expect(firstResult.match(new RegExp(START_MARKER, 'g'))?.length).toBe(2);
expect(firstResult.match(new RegExp(END_MARKER, 'g'))?.length).toBe(2);
await FileSystemUtils.updateFileWithMarkers(
filePath,
'Updated content',
START_MARKER,
END_MARKER
);
const secondResult = await fs.readFile(filePath, 'utf-8');
expect(secondResult).toBe(firstResult);
});
});
});
});
+4 -19
View File
@@ -1,21 +1,6 @@
import { execSync } from 'child_process';
import { existsSync } from 'fs';
import path from 'path';
import { ensureCliBuilt } from './test/helpers/run-cli.js';
// Run once before all tests
// Ensure the CLI bundle exists before tests execute
export async function setup() {
const distPath = path.join(process.cwd(), 'dist', 'cli', 'index.js');
if (!existsSync(distPath)) {
console.log('Building project before tests...');
try {
execSync('pnpm run build', {
stdio: 'inherit',
cwd: process.cwd()
});
} catch (error) {
console.error('Failed to build project:', error);
process.exit(1);
}
}
}
await ensureCliBuilt();
}