Compare commits

...
21 Commits
Author SHA1 Message Date
Tabish Bidiwale 4cd0720e9b 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:19:37 +10:00
Tabish Bidiwale af7b6e3ed1 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)
2025-09-29 22:17:28 +10:00
Tabish Bidiwale 63089e01df chore(ci): split pr and main workflows 2025-09-29 22:15:19 +10:00
Tabish Bidiwale 287c1f3654 Merge branch 'main' into e2e-tests-setup 2025-09-29 20:37:19 +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 e5b321c80c Merge branch 'main' into e2e-tests-setup 2025-09-26 00:49:50 +10:00
Tabish Bidiwale 86925b2b2d docs: add --yes flag to archive command template (#84) 2025-09-26 00:32:56 +10:00
Tabish Bidiwale c8b839f3b4 fix: update core validation and json converter 2025-09-26 00:07:00 +10:00
Tabish Bidiwale 3a0992020b fix: ci 2025-09-25 19:25:59 +10:00
Tabish Bidiwale 4ec34c744b fix ci 2025-09-25 19:22:19 +10:00
Tabish Bidiwale 5534779843 fix: use multiline YAML for diagnostics command 2025-09-25 19:18:57 +10:00
Tabish Bidiwale 54467f2e1c fix: correct YAML syntax in CI workflow diagnostics command 2025-09-25 18:57:07 +10:00
Tabish Bidiwale 745845dd3e 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
2025-09-25 17:20:41 +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
Tabish Bidiwale 6469593495 chore(release): publish to latest 2025-09-18 00:05:51 +10:00
39 changed files with 1227 additions and 491 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!"
+1 -1
View File
@@ -69,4 +69,4 @@ jobs:
- run: pnpm test
- name: Publish
run: pnpm publish --access public --provenance --no-git-checks --tag next
run: pnpm publish --access public --provenance --no-git-checks
+2 -1
View File
@@ -145,4 +145,5 @@ docs/
# Claude
.claude/
CLAUDE.md
CLAUDE.md
.DS_Store
+13 -34
View File
@@ -1,40 +1,19 @@
<!-- OPENSPEC:START -->
# OpenSpec Project
# Repository Guidelines
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.
## Project Structure & Module Organization
OpenSpec ships as a TypeScript-first CLI. Source code lives in `src`, with feature logic in `core`, interactive flows in `cli`, reusable helpers in `utils`, and command wiring in `commands`. After `pnpm run build`, deliverables land in `dist` and feed the published entry point `bin/openspec.js`. Specs and change proposals reside in `openspec/specs` and `openspec/changes`; update them whenever behavior shifts so automation stays aligned. Shared assets live in `assets`, and Vitest suites in `test` mirror the source layout for easy cross-reference.
This project uses OpenSpec for spec-driven development. Specifications are the source of truth.
## Build, Test, and Development Commands
Run `pnpm install` to sync dependencies. `pnpm run build` compiles TypeScript to `dist` and must stay green before release. Use `pnpm run dev` for a `tsc --watch` loop and `pnpm run dev:cli` to rebuild then execute the local CLI. `pnpm test` runs the Vitest suite once, `pnpm run test:watch` keeps it hot while iterating, and `pnpm run test:coverage` verifies instrumentation thresholds. Use `pnpm run changeset` when preparing a release entry.
See @openspec/AGENTS.md for detailed conventions and guidelines.
<!-- OPENSPEC:END -->
## Coding Style & Naming Conventions
We follow idiomatic TypeScript with ES modules, 2-space indentation, and semicolons. Prefer named exports from index barrels and keep filenames kebab-cased (e.g., `list-command.ts`). Classes use `PascalCase`, functions and variables use `camelCase`, and constants representing flags may use `SCREAMING_SNAKE_CASE`. Keep modules small, colocate helpers under `src/utils`, and avoid new dependencies without spec-backed justification.
## Complexity Management
## Testing Guidelines
Every behavior change needs Vitest coverage under `test`, co-located by feature (e.g., `test/core/update.test.ts`). Name suites after the module under test and lean on `vitest.setup.ts` for shared configuration. Run `pnpm test` before pushing and add regression cases for each bug fix or spec requirement.
**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
## Commit & Pull Request Guidelines
Commits follow Conventional Commits (`type(scope): subject`) and stay single-line. Reference the touched module in the scope when practical. Each PR should summarize the spec or issue it fulfills, list manual verification steps, and note updates to any `openspec/` assets. Include CLI output snippets or screenshots when the UX changes, and ensure CI and coverage checks pass before requesting review.
**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
## OpenSpec Workflow Tips
Treat specs as the contract: update `openspec/project.md` or the relevant `openspec/specs/*.md` before coding, then run `pnpm run dev:cli` to validate the CLI against the revised artifacts. `openspec list --specs` confirms the catalog, and `openspec change` drafts proposals—commit these alongside code so reviewers can trace rationale to implementation.
+12
View File
@@ -1,5 +1,17 @@
# @fission-ai/openspec
## 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);
}
}
+4 -3
View File
@@ -58,7 +58,7 @@ Skip proposal for:
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 +95,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 +117,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 +448,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.
@@ -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,13 @@
## 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
- [ ] 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.
## 3. Phase 3 – Package Validation (Optional)
- [ ] 3.1 Add a simple CI job on runners with registry access that runs `pnpm pack`, installs the tarball into a temp workspace (e.g., `pnpm add --no-save`), and executes `pnpm exec openspec --version`.
- [ ] 3.2 If network-restricted environments can’t exercise installs, skip the job and note the limitation in this proposal’s rollout log.
- [ ] 3.3 Close out the enumerated hardening items: extend `.gitattributes` to cover packaged assets, enforce executable bits for CLI shims during CI, and finish the outstanding SIGINT handling work; update this proposal once they land.
@@ -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.
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@fission-ai/openspec",
"version": "0.3.0",
"version": "0.4.0",
"description": "AI-native system for spec-driven development",
"keywords": [
"openspec",
@@ -18,8 +18,7 @@
"author": "OpenSpec Contributors",
"type": "module",
"publishConfig": {
"access": "public",
"tag": "next"
"access": "public"
},
"exports": {
".": {
+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 -241
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,33 @@ 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';
const PROGRESS_SPINNER = {
interval: 80,
frames: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓']
frames: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓'],
};
const PALETTE = {
white: chalk.hex('#f4f4f4'),
lightGray: chalk.hex('#c8c8c8'),
midGray: chalk.hex('#8a8a8a'),
darkGray: chalk.hex('#4a4a4a')
darkGray: chalk.hex('#4a4a4a'),
};
const LETTER_MAP: Record<string, string[]> = {
O: [
' ████ ',
'██ ██',
'██ ██',
'██ ██',
' ████ '
],
P: [
'█████ ',
'██ ██',
'█████ ',
'██ ',
'██ '
],
E: [
'██████',
'██ ',
'█████ ',
'██ ',
'██████'
],
N: [
'██ ██',
'███ ██',
'██ ███',
'██ ██',
'██ ██'
],
S: [
' █████',
'██ ',
' ████ ',
' ██',
'█████ '
],
C: [
' █████',
'██ ',
'██ ',
'██ ',
' █████'
],
' ': [
' ',
' ',
' ',
' ',
' '
]
O: [' ████ ', '██ ██', '██ ██', '██ ██', ' ████ '],
P: ['█████ ', '██ ██', '█████ ', '██ ', '██ '],
E: ['██████', '██ ', '█████ ', '██ ', '██████'],
N: ['██ ██', '███ ██', '██ ███', '██ ██', '██ ██'],
S: [' █████', '██ ', ' ████ ', ' ██', '█████ '],
C: [' █████', '██ ', '██ ', '██ ', ' █████'],
' ': [' ', ' ', ' ', ' ', ' '],
};
type ToolLabel = {
@@ -87,7 +50,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 +61,7 @@ const parseToolLabel = (raw: string): ToolLabel => {
}
return {
primary: match[1].trim(),
annotation: match[2].trim()
annotation: match[2].trim(),
};
};
@@ -118,165 +82,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 +295,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 +344,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 +396,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 +407,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 +423,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 +448,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 +456,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 +512,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 +599,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 +618,7 @@ export class InitCommand {
PALETTE.lightGray,
PALETTE.midGray,
PALETTE.lightGray,
PALETTE.white
PALETTE.white,
];
console.log();
@@ -544,7 +635,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*$/);
+4 -3
View File
@@ -58,7 +58,7 @@ Skip proposal for:
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 +95,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 +117,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 +448,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.
@@ -32,7 +32,7 @@ const applyReferences = `**Reference**
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.`;
+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;
}
}
}
+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);
});
});
+134 -41
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,23 +79,31 @@ 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');
@@ -98,13 +112,14 @@ describe('InitCommand', () => {
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');
@@ -134,9 +149,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 +178,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 +188,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 +218,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 +295,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 +309,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 +323,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 +333,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 +345,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 +357,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', () => {
+113 -31
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);
@@ -54,17 +54,22 @@ More content after.`;
expect(updatedContent).toContain('OpenSpec Instructions');
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 +89,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 +109,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 +142,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 +198,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 +212,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 +246,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 +265,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);
@@ -253,7 +323,9 @@ Old content
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 +333,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 +347,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 +372,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;
+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();
}