mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-03 22:13:19 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffa7c17e17 | ||
|
|
7a44a5514b | ||
|
|
8e5d025ef5 | ||
|
|
3b72a98fed | ||
|
|
e28ccd0c46 | ||
|
|
a908dc5a05 | ||
|
|
b46f99b9bc | ||
|
|
6f7cc2abd2 | ||
|
|
4867bfade5 | ||
|
|
7359b4846a | ||
|
|
88526e6b93 | ||
|
|
367aa12892 | ||
|
|
dcfb6afe0c | ||
|
|
86925b2b2d | ||
|
|
604ecb8bd1 | ||
|
|
5a4837c37d | ||
|
|
9d9539aaa2 | ||
|
|
c3fecf0619 | ||
|
|
6469593495 | ||
|
|
157936cf68 | ||
|
|
fef33df753 | ||
|
|
78b61e8466 | ||
|
|
5fa0fa68a7 | ||
|
|
fe9eb44ec2 | ||
|
|
ee78f21b08 | ||
|
|
e3ae2ceaf0 | ||
|
|
20b2fee749 | ||
|
|
e7fff31df2 | ||
|
|
fdf9a30f0b | ||
|
|
161aa41cb3 | ||
|
|
9e092a185b | ||
|
|
38454bb2a6 | ||
|
|
b04f1cc923 | ||
|
|
ae86e9be9e | ||
|
|
f955e87fd9 | ||
|
|
55efd19953 | ||
|
|
dab5d93b85 | ||
|
|
dd7ba71fe5 | ||
|
|
66ad5658f9 |
@@ -1,302 +0,0 @@
|
||||
---
|
||||
description: OpenSpec conventions and workflow for Cursor agents working in this repo
|
||||
alwaysApply: false
|
||||
---
|
||||
# OpenSpec Rules for Cursor Agents
|
||||
|
||||
Instructions for AI coding assistants using OpenSpec for spec-driven development.
|
||||
|
||||
## Three-Stage Workflow
|
||||
|
||||
### Stage 1: Creating Changes
|
||||
Create proposal when you need to:
|
||||
- Add features or functionality
|
||||
- Make breaking changes (API, schema)
|
||||
- Change architecture or patterns
|
||||
- Optimize performance (changes behavior)
|
||||
- Update security patterns
|
||||
|
||||
Skip proposal for:
|
||||
- Bug fixes (restore intended behavior)
|
||||
- Typos, formatting, comments
|
||||
- Dependency updates (non-breaking)
|
||||
- Configuration changes
|
||||
- Tests for existing behavior
|
||||
|
||||
### Stage 2: Implementing Changes
|
||||
1. **Read proposal.md** - Understand what's being built
|
||||
2. **Read design.md** (if exists) - Review technical decisions
|
||||
3. **Read tasks.md** - Get implementation checklist
|
||||
4. **Implement tasks sequentially** - Complete in order
|
||||
5. **Mark complete immediately** - Update `- [x]` after each task
|
||||
|
||||
### Stage 3: Archiving Changes
|
||||
After deployment, create separate PR to:
|
||||
- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/`
|
||||
- Update `specs/` if capabilities changed
|
||||
- Use `openspec archive [change] --skip-specs` for tooling-only changes
|
||||
|
||||
## Before Any Task
|
||||
|
||||
**Context Checklist:**
|
||||
- [ ] Read relevant specs in `specs/[capability]/spec.md`
|
||||
- [ ] Check pending changes in `changes/` for conflicts
|
||||
- [ ] Read `openspec/project.md` for conventions
|
||||
- [ ] Run `openspec list` to see active changes
|
||||
- [ ] Run `openspec list --specs` to see existing capabilities
|
||||
|
||||
**Before Creating Specs:**
|
||||
- Always check if capability already exists
|
||||
- Prefer modifying existing specs over creating duplicates
|
||||
- Use `openspec show [spec]` to review current state
|
||||
|
||||
## Quick Start
|
||||
|
||||
### CLI Commands
|
||||
|
||||
```bash
|
||||
# Essential commands
|
||||
openspec list # List active changes
|
||||
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
|
||||
|
||||
# Project management
|
||||
openspec init [path] # Initialize OpenSpec
|
||||
openspec update [path] # Update instruction files
|
||||
|
||||
# Interactive mode
|
||||
openspec show # Prompts for selection
|
||||
openspec validate # Bulk validation mode
|
||||
|
||||
# Debugging
|
||||
openspec show [change] --json --deltas-only
|
||||
openspec validate [change] --strict
|
||||
```
|
||||
|
||||
### Command Flags
|
||||
|
||||
- `--json` - Machine-readable output
|
||||
- `--type change|spec` - Disambiguate items
|
||||
- `--strict` - Comprehensive validation
|
||||
- `--no-interactive` - Disable prompts
|
||||
- `--skip-specs` - Archive without spec updates
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
openspec/
|
||||
├── project.md # Project conventions
|
||||
├── specs/ # Current truth - what IS built
|
||||
│ └── [capability]/ # Single focused capability
|
||||
│ ├── spec.md # Requirements and scenarios
|
||||
│ └── design.md # Technical patterns
|
||||
├── changes/ # Proposals - what SHOULD change
|
||||
│ ├── [change-name]/
|
||||
│ │ ├── proposal.md # Why, what, impact
|
||||
│ │ ├── tasks.md # Implementation checklist
|
||||
│ │ ├── design.md # Technical decisions (optional)
|
||||
│ │ └── specs/ # Delta changes
|
||||
│ │ └── [capability]/
|
||||
│ │ └── spec.md # ADDED/MODIFIED/REMOVED
|
||||
│ └── archive/ # Completed changes
|
||||
```
|
||||
|
||||
## Creating Change Proposals
|
||||
|
||||
### Decision Tree
|
||||
|
||||
```
|
||||
New request?
|
||||
├─ Bug fix restoring spec behavior? → Fix directly
|
||||
├─ Typo/format/comment? → Fix directly
|
||||
├─ New feature/capability? → Create proposal
|
||||
├─ Breaking change? → Create proposal
|
||||
├─ Architecture change? → Create proposal
|
||||
└─ Unclear? → Create proposal (safer)
|
||||
```
|
||||
|
||||
### Proposal Structure
|
||||
|
||||
1. **Create directory:** `changes/[descriptive-name]/`
|
||||
|
||||
2. **Write proposal.md:**
|
||||
```markdown
|
||||
## Why
|
||||
[1-2 sentences on problem/opportunity]
|
||||
|
||||
## What Changes
|
||||
- [Bullet list of changes]
|
||||
- [Mark breaking changes with **BREAKING**]
|
||||
|
||||
## Impact
|
||||
- Affected specs: [list capabilities]
|
||||
- Affected code: [key files/systems]
|
||||
```
|
||||
|
||||
3. **Create spec deltas:** `specs/[capability]/spec.md`
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
### Requirement: New Feature
|
||||
The system SHALL provide...
|
||||
|
||||
#### Scenario: Success case
|
||||
- **WHEN** user performs action
|
||||
- **THEN** expected result
|
||||
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Existing Feature
|
||||
[Complete modified requirement]
|
||||
|
||||
## REMOVED Requirements
|
||||
### Requirement: Old Feature
|
||||
**Reason**: [Why removing]
|
||||
**Migration**: [How to handle]
|
||||
```
|
||||
|
||||
4. **Create tasks.md:**
|
||||
```markdown
|
||||
## 1. Implementation
|
||||
- [ ] 1.1 Create database schema
|
||||
- [ ] 1.2 Implement API endpoint
|
||||
- [ ] 1.3 Add frontend component
|
||||
- [ ] 1.4 Write tests
|
||||
```
|
||||
|
||||
## Spec File Format
|
||||
|
||||
### Critical: Scenario Formatting
|
||||
|
||||
**CORRECT** (use #### headers):
|
||||
```markdown
|
||||
#### Scenario: User login success
|
||||
- **WHEN** valid credentials provided
|
||||
- **THEN** return JWT token
|
||||
```
|
||||
|
||||
**WRONG** (don't use bullets or bold):
|
||||
```markdown
|
||||
- **Scenario: User login** ❌
|
||||
**Scenario**: User login ❌
|
||||
### Scenario: User login ❌
|
||||
```
|
||||
|
||||
Every requirement MUST have at least one scenario.
|
||||
|
||||
### Delta Operations
|
||||
|
||||
- `## ADDED Requirements` - New capabilities
|
||||
- `## MODIFIED Requirements` - Changed behavior
|
||||
- `## REMOVED Requirements` - Deprecated features
|
||||
- `## RENAMED Requirements` - Name changes
|
||||
|
||||
Headers matched with `trim(header)` - whitespace ignored.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Errors
|
||||
|
||||
**"Change must have at least one delta"**
|
||||
- Check `changes/[name]/specs/` exists with .md files
|
||||
- Verify files have operation prefixes (## ADDED Requirements)
|
||||
|
||||
**"Requirement must have at least one scenario"**
|
||||
- Check scenarios use `#### Scenario:` format (4 hashtags)
|
||||
- Don't use bullet points or bold for scenario headers
|
||||
|
||||
**Silent scenario parsing failures**
|
||||
- Exact format required: `#### Scenario: Name`
|
||||
- Debug with: `openspec show [change] --json --deltas-only`
|
||||
|
||||
### Validation Tips
|
||||
|
||||
```bash
|
||||
# Always use strict mode for comprehensive checks
|
||||
openspec validate [change] --strict
|
||||
|
||||
# Debug delta parsing
|
||||
openspec show [change] --json | jq '.deltas'
|
||||
|
||||
# Check specific requirement
|
||||
openspec show [spec] --json -r 1
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Simplicity First
|
||||
- Default to <100 lines of new code
|
||||
- Single-file implementations until proven insufficient
|
||||
- Avoid frameworks without clear justification
|
||||
- Choose boring, proven patterns
|
||||
|
||||
### Complexity Triggers
|
||||
Only add complexity with:
|
||||
- Performance data showing current solution too slow
|
||||
- Concrete scale requirements (>1000 users, >100MB data)
|
||||
- Multiple proven use cases requiring abstraction
|
||||
|
||||
### Clear References
|
||||
- Use `file.ts:42` format for code locations
|
||||
- Reference specs as `specs/auth/spec.md`
|
||||
- Link related changes and PRs
|
||||
|
||||
### Capability Naming
|
||||
- Use verb-noun: `user-auth`, `payment-capture`
|
||||
- Single purpose per capability
|
||||
- 10-minute understandability rule
|
||||
- Split if description needs "AND"
|
||||
|
||||
## Tool Selection Guide
|
||||
|
||||
| Task | Tool | Why |
|
||||
|------|------|-----|
|
||||
| Find files by pattern | Glob | Fast pattern matching |
|
||||
| Search code content | Grep | Optimized regex search |
|
||||
| Read specific files | Read | Direct file access |
|
||||
| Explore unknown scope | Task | Multi-step investigation |
|
||||
|
||||
## Error Recovery
|
||||
|
||||
### Change Conflicts
|
||||
1. Run `openspec list` to see active changes
|
||||
2. Check for overlapping specs
|
||||
3. Coordinate with change owners
|
||||
4. Consider combining proposals
|
||||
|
||||
### Validation Failures
|
||||
1. Run with `--strict` flag
|
||||
2. Check JSON output for details
|
||||
3. Verify spec file format
|
||||
4. Ensure scenarios properly formatted
|
||||
|
||||
### Missing Context
|
||||
1. Read project.md first
|
||||
2. Check related specs
|
||||
3. Review recent archives
|
||||
4. Ask for clarification
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Stage Indicators
|
||||
- `changes/` - Proposed, not yet built
|
||||
- `specs/` - Built and deployed
|
||||
- `archive/` - Completed changes
|
||||
|
||||
### File Purposes
|
||||
- `proposal.md` - Why and what
|
||||
- `tasks.md` - Implementation steps
|
||||
- `design.md` - Technical decisions
|
||||
- `spec.md` - Requirements and behavior
|
||||
|
||||
### CLI Essentials
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
Remember: Specs are truth. Changes are proposals. Keep them in sync.
|
||||
@@ -0,0 +1,2 @@
|
||||
# Default code ownership
|
||||
* @TabishB
|
||||
@@ -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!"
|
||||
|
||||
@@ -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
@@ -145,4 +145,5 @@ docs/
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
CLAUDE.md
|
||||
.DS_Store
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 24b4866: Initial release
|
||||
@@ -1,40 +1,8 @@
|
||||
<!-- OPENSPEC:START -->
|
||||
# OpenSpec Project
|
||||
# OpenSpec Instructions
|
||||
|
||||
This document provides instructions for AI coding assistants on how to use OpenSpec conventions for spec-driven development. Follow these rules precisely when working on OpenSpec-enabled projects.
|
||||
This project uses OpenSpec to manage AI assistant workflows.
|
||||
|
||||
This project uses OpenSpec for spec-driven development. Specifications are the source of truth.
|
||||
|
||||
See @openspec/AGENTS.md for detailed conventions and guidelines.
|
||||
- Full guidance lives in '@/openspec/AGENTS.md'.
|
||||
- Keep this managed block so 'openspec update' can refresh the instructions.
|
||||
<!-- OPENSPEC:END -->
|
||||
|
||||
## Complexity Management
|
||||
|
||||
**Default to minimal solutions:**
|
||||
- Propose <100 lines of new code for features
|
||||
- Prefer single-file implementations until proven insufficient
|
||||
- Avoid frameworks, abstractions, and optimizations without clear justification
|
||||
- Choose boring, well-understood patterns over novel approaches
|
||||
|
||||
**Question requests for complexity:**
|
||||
- Caching? → Ask for performance data and targets
|
||||
- New framework? → Suggest plain code first
|
||||
- Extra layers? → Start with the thinnest viable design
|
||||
|
||||
**Justify complexity with data:**
|
||||
- Performance metrics showing current solution is too slow
|
||||
- Concrete scale requirements (e.g., >1000 users, >100MB data)
|
||||
- Multiple proven use cases requiring an abstraction
|
||||
|
||||
## Package Manager
|
||||
Always use pnpm (NOT npm or yarn) for all Node.js package management:
|
||||
- Install dependencies: `pnpm install`
|
||||
- Add packages: `pnpm add [package]`
|
||||
- Run scripts: `pnpm run [script]`
|
||||
|
||||
## Git Commits
|
||||
Use conventional commits with these rules:
|
||||
- Format: `type(scope): subject` (e.g., `fix: resolve auth error`, `feat(api): add user endpoint`)
|
||||
- Keep commit messages to ONE line only - no body or footer
|
||||
- Common types: feat, fix, docs, style, refactor, test, chore
|
||||
- Never add co-authorship lines or attribution
|
||||
@@ -1,5 +1,47 @@
|
||||
# @fission-ai/openspec
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- feat: implement Phase 1 E2E testing with cross-platform CI matrix
|
||||
|
||||
- Add shared runCLI helper in test/helpers/run-cli.ts for spawn testing
|
||||
- Create test/cli-e2e/basic.test.ts covering help, version, validate flows
|
||||
- Migrate existing CLI exec tests to use runCLI helper
|
||||
- Extend CI matrix to bash (Linux/macOS) and pwsh (Windows)
|
||||
- Split PR and main workflows for optimized feedback
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Make apply instructions more specific
|
||||
|
||||
Improve agent templates and slash command templates with more specific and actionable apply instructions.
|
||||
|
||||
- docs: improve documentation and cleanup
|
||||
|
||||
- Document non-interactive flag for archive command
|
||||
- Replace discord badge in README
|
||||
- Archive completed changes for better organization
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add OpenSpec change proposals for CLI improvements and enhanced user experience
|
||||
- Add Opencode slash commands support for AI-driven development workflows
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add documentation improvements including --yes flag for archive command template and Discord badge
|
||||
- Fix normalize line endings in markdown parser to handle CRLF files properly
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Enhance `openspec init` with extend mode, multi-tool selection, and an interactive `AGENTS.md` configurator.
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -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,134 +23,187 @@
|
||||
</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
|
||||
|
||||
**Supported AI Tools:** ✅ Claude Code | 🔜 Cursor (coming soon) | ✅ AGENTS.md instructions
|
||||
|
||||
Create **alignment** between humans and AI coding assistants through spec-driven development. **No API keys required.**
|
||||
|
||||
OpenSpec ensures you and your AI assistant agree on what to build before any code is written. By discussing and refining specifications first, you bring determinism to AI code generation, getting exactly what you want, not what the AI thinks you might want.
|
||||
OpenSpec aligns humans and AI coding assistants with spec-driven development so you agree on what to build before any code is written. **No API keys required.**
|
||||
|
||||
## Why OpenSpec?
|
||||
|
||||
**The Problem:** AI coding assistants are powerful but unpredictable. Without clear specifications, they generate code based on assumptions, often missing requirements or adding unwanted features. Teams waste time in review cycles because humans and AI aren't aligned on what to build.
|
||||
AI coding assistants are powerful but unpredictable when requirements live in chat history. OpenSpec adds a lightweight specification workflow that locks intent before implementation, giving you deterministic, reviewable outputs.
|
||||
|
||||
**The Solution:** OpenSpec creates alignment BEFORE code is written:
|
||||
- **Human-AI Alignment** - You and your AI agree on specifications before implementation
|
||||
- **Deterministic, Predictable Output** - Clear specs lead to reliable, repeatable code generation
|
||||
- **Team Alignment via Spec Reviews** - Everyone reviews intentions, not code surprises
|
||||
- **Clear Feature Scope** - Know exactly what you're building—and what you're not
|
||||
- **Progress Tracking** - See what's proposed, in progress, or completed at a glance
|
||||
- **Living Documentation** - Specs evolve with your code as a natural byproduct
|
||||
- **Universal Tool Support** - Works with any AI assistant (Claude Code, Cursor, and more)
|
||||
- **No API Keys Required** - Integrates through context rules, not external services
|
||||
Key outcomes:
|
||||
- Human and AI stakeholders agree on specs before work begins.
|
||||
- Structured change folders (proposals, tasks, and spec updates) keep scope explicit and auditable.
|
||||
- Shared visibility into what's proposed, active, or archived.
|
||||
- Works with the AI tools you already use: custom slash commands where supported, context rules everywhere else.
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌──────────────┐
|
||||
│ SPECS │ │ CHANGES │ │ ARCHIVE │
|
||||
│ (Truth) │◀──────│ (Proposals) │──────▶│ (Completed) │
|
||||
└─────────────┘ └─────────────┘ └──────────────┘
|
||||
▲ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────┐ │
|
||||
└───────────────│ CODE │◀──────────────┘
|
||||
└─────────────┘
|
||||
┌────────────────────┐
|
||||
│ Draft Change │
|
||||
│ Proposal │
|
||||
└────────┬───────────┘
|
||||
│ share intent with your AI
|
||||
▼
|
||||
┌────────────────────┐
|
||||
│ Review & Align │
|
||||
│ (edit specs/tasks) │◀──── feedback loop ──────┐
|
||||
└────────┬───────────┘ │
|
||||
│ approved plan │
|
||||
▼ │
|
||||
┌────────────────────┐ │
|
||||
│ Implement Tasks │──────────────────────────┘
|
||||
│ (AI writes code) │
|
||||
└────────┬───────────┘
|
||||
│ ship the change
|
||||
▼
|
||||
┌────────────────────┐
|
||||
│ Archive & Update │
|
||||
│ Specs (source) │
|
||||
└────────────────────┘
|
||||
|
||||
1. SPECS define current capabilities (what IS built)
|
||||
2. CHANGES propose modifications using deltas (what SHOULD change)
|
||||
3. CODE implements the changes following tasks
|
||||
4. ARCHIVE preserves completed changes after deployment
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js >= 20.19.0
|
||||
|
||||
### Install OpenSpec
|
||||
|
||||
Install globally:
|
||||
|
||||
```bash
|
||||
npm install -g @fission-ai/openspec
|
||||
1. Draft a change proposal that captures the spec updates you want.
|
||||
2. Review the proposal with your AI assistant until everyone agrees.
|
||||
3. Implement tasks that reference the agreed specs.
|
||||
4. Archive the change to merge the approved updates back into the source-of-truth specs.
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
### 1. Initialize OpenSpec in Your Project
|
||||
### Supported AI Tools
|
||||
|
||||
#### Native Slash Commands
|
||||
These tools have built-in OpenSpec commands. Select the OpenSpec integration when prompted.
|
||||
|
||||
| Tool | Commands |
|
||||
|------|----------|
|
||||
| **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 • Gemini CLI • GitHub Copilot • Others |
|
||||
|
||||
### Install & Initialize
|
||||
|
||||
#### Prerequisites
|
||||
- **Node.js >= 20.19.0** - Check your version with `node --version`
|
||||
|
||||
#### Step 1: Install the CLI globally
|
||||
|
||||
```bash
|
||||
# Navigate to your project
|
||||
npm install -g @fission-ai/openspec@latest
|
||||
```
|
||||
|
||||
Verify installation:
|
||||
```bash
|
||||
openspec --version
|
||||
```
|
||||
|
||||
#### Step 2: Initialize OpenSpec in your project
|
||||
|
||||
Navigate to your project directory:
|
||||
```bash
|
||||
cd my-project
|
||||
```
|
||||
|
||||
# Initialize OpenSpec
|
||||
Run the initialization:
|
||||
```bash
|
||||
openspec init
|
||||
|
||||
# Select your AI tool (more coming soon!):
|
||||
# "Which AI tool do you use?"
|
||||
# > Claude Code
|
||||
# Cursor (coming soon)
|
||||
|
||||
# This creates:
|
||||
# openspec/
|
||||
# ├── specs/ # Current specifications (truth)
|
||||
# ├── changes/ # Proposed changes
|
||||
# └── AGENTS.md # AI instructions for your tool
|
||||
```
|
||||
|
||||
### 2. Create Your First Change
|
||||
**What happens during initialization:**
|
||||
- You'll be prompted to select your AI tool (Claude Code, Cursor, etc.)
|
||||
- OpenSpec automatically configures slash commands or `AGENTS.md` based on your selection
|
||||
- A new `openspec/` directory structure is created in your project
|
||||
|
||||
Jump straight into creating a change proposal with your AI assistant (works with Claude Code, Cursor, or any AI tool):
|
||||
**After setup:**
|
||||
- Primary AI tools can trigger `/openspec` workflows without additional configuration
|
||||
- Run `openspec list` to verify the setup and view any active changes
|
||||
|
||||
```markdown
|
||||
// Quick win - Add a simple new feature:
|
||||
You: "I want to add a user profile API endpoint.
|
||||
Please create an OpenSpec change proposal for this."
|
||||
### Create Your First Change
|
||||
|
||||
AI: "I'll create an OpenSpec change proposal for the user profile API..."
|
||||
*Creates openspec/changes/add-user-profile-api/ with:*
|
||||
- proposal.md (why this feature is needed)
|
||||
- tasks.md (implementation checklist)
|
||||
- design.md (API design decisions)
|
||||
- specs/user-profile/spec.md (new requirements)
|
||||
Here's a real example showing the complete OpenSpec workflow. This works with any AI tool. Those with native slash commands will recognize the shortcuts automatically.
|
||||
|
||||
You: "The proposal looks good. Let's implement it."
|
||||
#### 1. Draft the Proposal
|
||||
Start by asking your AI to create a change proposal:
|
||||
|
||||
AI: "Following the tasks in openspec/changes/add-user-profile-api/tasks.md:
|
||||
Task 1.1: Create user profile model..."
|
||||
*Implements each task systematically*
|
||||
```text
|
||||
You: Create an OpenSpec change proposal for adding profile search filters by role and team
|
||||
(Shortcut for tools with slash commands: /openspec:proposal Add profile search filters)
|
||||
|
||||
AI: I'll create an OpenSpec change proposal for profile filters.
|
||||
*Scaffolds openspec/changes/add-profile-filters/ with proposal.md, tasks.md, spec deltas.*
|
||||
```
|
||||
|
||||
### 3. Track Your Work
|
||||
#### 2. Verify & Review
|
||||
Check that the change was created correctly and review the proposal:
|
||||
|
||||
```bash
|
||||
# View active changes (what's being worked on)
|
||||
openspec list
|
||||
|
||||
# Validate your changes are properly formatted
|
||||
openspec validate add-2fa --strict
|
||||
|
||||
# After deployment, archive the completed change
|
||||
openspec archive add-2fa
|
||||
# This moves the change to archive/ and updates specs/
|
||||
$ openspec list # Confirm the change folder exists
|
||||
$ openspec validate add-profile-filters # Validate spec formatting
|
||||
$ openspec show add-profile-filters # Review proposal, tasks, and spec delta
|
||||
```
|
||||
|
||||
## Common Commands
|
||||
#### 3. Refine the Specs
|
||||
Iterate on the specifications until they match your needs:
|
||||
|
||||
```text
|
||||
You: Can you add acceptance criteria for the role and team filters?
|
||||
|
||||
AI: I'll update the spec delta with scenarios for role and team filters.
|
||||
*Edits openspec/changes/add-profile-filters/specs/profile/spec.md and tasks.md.*
|
||||
```
|
||||
|
||||
#### 4. Implement the Change
|
||||
Once specs look good, start implementation:
|
||||
|
||||
```text
|
||||
You: The specs look good. Let's implement this change.
|
||||
(Shortcut for tools with slash commands: /openspec:apply add-profile-filters)
|
||||
|
||||
AI: I'll work through the tasks in the add-profile-filters change.
|
||||
*Implements tasks from openspec/changes/add-profile-filters/tasks.md*
|
||||
*Marks tasks complete: Task 1.1 ✓, Task 1.2 ✓, Task 2.1 ✓...*
|
||||
```
|
||||
|
||||
#### 5. Archive the Completed Change
|
||||
After implementation is complete, archive the change:
|
||||
|
||||
```text
|
||||
AI: All tasks are complete. The implementation is ready.
|
||||
|
||||
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 --yes*
|
||||
✓ Change archived successfully. Specs updated. Ready for the next feature!
|
||||
```
|
||||
|
||||
Or run the command yourself in terminal:
|
||||
```bash
|
||||
$ 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".
|
||||
|
||||
## Command Reference
|
||||
|
||||
```bash
|
||||
# Most used:
|
||||
openspec list # See what changes you're working on
|
||||
openspec archive <change> # Mark a change as complete after deployment
|
||||
|
||||
# Also useful:
|
||||
openspec validate <change> # Check formatting before committing
|
||||
openspec show <change> # View change details
|
||||
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> [--yes|-y] # Move a completed change into archive/ (non-interactive with --yes)
|
||||
```
|
||||
|
||||
## Example: How AI Creates OpenSpec Files
|
||||
@@ -236,46 +290,38 @@ Deltas are "patches" that show how specs change:
|
||||
- Every requirement needs at least one `#### Scenario:` block
|
||||
- Use SHALL/MUST in requirement text
|
||||
|
||||
|
||||
## Why OpenSpec Works
|
||||
|
||||
OpenSpec creates **alignment** between you and your AI coding assistant:
|
||||
|
||||
1. **You describe** what you want to build
|
||||
2. **AI creates specs** before writing any code
|
||||
3. **You review and adjust** the specifications
|
||||
4. **AI implements** exactly what was specified
|
||||
5. **Everyone understands** what's being built through clear specs
|
||||
|
||||
**True Interoperability:** OpenSpec is designed to be universal. No API keys, no vendor lock-in. It works by adding context rules to ANY AI coding tool - whether you use Claude Code today, switch to Cursor tomorrow, or adopt the next breakthrough AI assistant. Your specs remain portable and your workflow stays consistent.
|
||||
|
||||
|
||||
## How OpenSpec Compares
|
||||
|
||||
### vs. Kiro.dev
|
||||
OpenSpec groups all changes for a feature in one place (`openspec/changes/feature-name/`), making it easy to track what needs to be done. Kiro spreads changes across multiple spec folders, making feature tracking harder.
|
||||
OpenSpec groups every change for a feature in one folder (`openspec/changes/feature-name/`), making it easy to track related specs, tasks, and designs together. Kiro spreads updates across multiple spec folders, which can make feature tracking harder.
|
||||
|
||||
### vs. No Specs
|
||||
Without specs, AI coding assistants generate code based on vague prompts, often missing requirements or adding unwanted features. OpenSpec ensures alignment before any code is written.
|
||||
Without specs, AI coding assistants generate code from vague prompts, often missing requirements or adding unwanted features. OpenSpec brings predictability by agreeing on the desired behavior before any code is written.
|
||||
|
||||
## Team Adoption
|
||||
|
||||
### Getting Started with Your Team
|
||||
1. **Initialize OpenSpec** – Run `openspec init` in your repo.
|
||||
2. **Start with new features** – Ask your AI to capture upcoming work as change proposals.
|
||||
3. **Grow incrementally** – Each change archives into living specs that document your system.
|
||||
4. **Stay flexible** – Different teammates can use Claude Code, Cursor, or any AGENTS.md-compatible tool while sharing the same specs.
|
||||
|
||||
1. **Initialize OpenSpec** - Run `openspec init` in your project
|
||||
2. **Start with new features** - Use OpenSpec for your next change proposal
|
||||
3. **Build incrementally** - Each new feature adds to your spec library
|
||||
4. **Future capability** - We're working on tools to generate specs from existing code
|
||||
Run `openspec update` whenever someone switches tools so your agents pick up the latest instructions and slash-command bindings.
|
||||
|
||||
**Tool Freedom:** Your team can use different AI assistants. One developer might use Claude Code while another uses Cursor - OpenSpec keeps everyone aligned through shared specifications. Run `openspec update` to configure for any supported tool without affecting others.
|
||||
## Updating OpenSpec
|
||||
|
||||
1. **Upgrade the package**
|
||||
```bash
|
||||
npm install -g @fission-ai/openspec@latest
|
||||
```
|
||||
2. **Refresh agent instructions**
|
||||
- Run `openspec update` inside each project to regenerate AI guidance and ensure the latest slash commands are active.
|
||||
|
||||
## Contributing
|
||||
|
||||
- Install dependencies: `npm install`
|
||||
- Build: `npm run build`
|
||||
- Test: `npm test`
|
||||
- Develop CLI locally: `npm run dev` or `npm run dev:cli`
|
||||
- Install dependencies: `pnpm install`
|
||||
- Build: `pnpm run build`
|
||||
- Test: `pnpm test`
|
||||
- Develop CLI locally: `pnpm run dev` or `pnpm run dev:cli`
|
||||
- Conventional commits (one-line): `type(scope): subject`
|
||||
|
||||
## License
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { existsSync, rmSync } from 'fs';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const runTsc = (args = []) => {
|
||||
const tscPath = require.resolve('typescript/bin/tsc');
|
||||
execFileSync(process.execPath, [tscPath, ...args], { stdio: 'inherit' });
|
||||
};
|
||||
|
||||
console.log('🔨 Building OpenSpec...\n');
|
||||
|
||||
@@ -14,10 +22,10 @@ if (existsSync('dist')) {
|
||||
// Run TypeScript compiler (use local version explicitly)
|
||||
console.log('Compiling TypeScript...');
|
||||
try {
|
||||
execSync('./node_modules/.bin/tsc -v', { stdio: 'inherit' });
|
||||
execSync('./node_modules/.bin/tsc', { stdio: 'inherit' });
|
||||
runTsc(['--version']);
|
||||
runTsc();
|
||||
console.log('\n✅ Build completed successfully!');
|
||||
} catch (error) {
|
||||
console.error('\n❌ Build failed!');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-5
@@ -47,18 +47,20 @@ Skip proposal for:
|
||||
4. Run `openspec validate <id> --strict` and resolve any issues before sharing the proposal.
|
||||
|
||||
### Stage 2: Implementing Changes
|
||||
Track these steps as TODOs and complete them one by one.
|
||||
1. **Read proposal.md** - Understand what's being built
|
||||
2. **Read design.md** (if exists) - Review technical decisions
|
||||
3. **Read tasks.md** - Get implementation checklist
|
||||
4. **Implement tasks sequentially** - Complete in order
|
||||
5. **Mark complete immediately** - Update `- [x]` after each task
|
||||
6. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
|
||||
5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses
|
||||
6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality
|
||||
7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
|
||||
|
||||
### Stage 3: Archiving Changes
|
||||
After deployment, create separate PR to:
|
||||
- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/`
|
||||
- Update `specs/` if capabilities changed
|
||||
- Use `openspec archive [change] --skip-specs` for tooling-only changes
|
||||
- Use `openspec archive [change] --skip-specs --yes` for tooling-only changes
|
||||
- Run `openspec validate --strict` to confirm the archived change passes checks
|
||||
|
||||
## Before Any Task
|
||||
@@ -95,7 +97,7 @@ openspec list --specs # List specifications
|
||||
openspec show [item] # Display change or spec
|
||||
openspec diff [change] # Show spec differences
|
||||
openspec validate [item] # Validate changes or specs
|
||||
openspec archive [change] # Archive after deployment
|
||||
openspec archive [change] [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
|
||||
|
||||
# Project management
|
||||
openspec init [path] # Initialize OpenSpec
|
||||
@@ -117,6 +119,7 @@ openspec validate [change] --strict
|
||||
- `--strict` - Comprehensive validation
|
||||
- `--no-interactive` - Disable prompts
|
||||
- `--skip-specs` - Archive without spec updates
|
||||
- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive)
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -447,7 +450,7 @@ openspec list # What's in progress?
|
||||
openspec show [item] # View details
|
||||
openspec diff [change] # What's changing?
|
||||
openspec validate --strict # Is it correct?
|
||||
openspec archive [change] # Mark complete
|
||||
openspec archive [change] [--yes|-y] # Mark complete (add --yes for automation)
|
||||
```
|
||||
|
||||
Remember: Specs are truth. Changes are proposals. Keep them in sync.
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Safety Checks
|
||||
The command SHALL perform safety checks to prevent overwriting existing structures and ensure proper permissions.
|
||||
|
||||
#### Scenario: Detecting existing initialization
|
||||
- **WHEN** the `openspec/` directory already exists
|
||||
- **THEN** inform the user that OpenSpec is already initialized and skip recreating the base structure
|
||||
- **AND** continue to the AI tool selection step so additional tools can be configured
|
||||
- **AND** display the existing-initialization error message only when the user declines to add any AI tools
|
||||
|
||||
## ADDED Requirements
|
||||
### Requirement: Additional AI Tool Initialization
|
||||
`openspec init` SHALL allow users to add configuration files for new AI coding assistants after the initial setup.
|
||||
|
||||
#### Scenario: Configuring an extra tool after initial setup
|
||||
- **GIVEN** an `openspec/` directory already exists and at least one AI tool file is present
|
||||
- **WHEN** the user runs `openspec init` and selects a different supported AI tool
|
||||
- **THEN** generate that tool's configuration files with OpenSpec markers the same way as during first-time initialization
|
||||
- **AND** leave existing tool configuration files unchanged except for managed sections that need refreshing
|
||||
- **AND** exit with code 0 and display a success summary highlighting the newly added tool files
|
||||
@@ -0,0 +1,28 @@
|
||||
# Add AGENTS.md Standard Support To Init/Update
|
||||
|
||||
## Summary
|
||||
- Teach `openspec init` to manage a root-level `AGENTS.md` file using the same marker system as `CLAUDE.md`.
|
||||
- Allow `openspec update` to refresh or scaffold that root `AGENTS.md` so AGENTS-compatible tools always receive current instructions.
|
||||
- Keep the existing `openspec/AGENTS.md` template as the canonical source while ensuring assistants that read `AGENTS.md` opt-in instructions get the latest guidance automatically.
|
||||
|
||||
## Motivation
|
||||
The README now points teams to AGENTS.md-compatible assistants, but the CLI only manages `CLAUDE.md`. Projects must hand-roll a root `AGENTS.md` file to benefit from the standard, and updates will drift unless maintainers remember to copy content manually. Extending `init` and `update` closes that gap so OpenSpec actually delivers on the promise of first-class AGENTS support.
|
||||
|
||||
## Proposal
|
||||
1. Extend the `openspec init` selection flow with an "AGENTS.md standard" option that creates or refreshes a root `AGENTS.md` file wrapped in OpenSpec markers, mirroring the existing CLAUDE integration.
|
||||
2. When generating the file, pull the managed content from the same template used in `openspec/AGENTS.md`, ensuring both locations stay in sync.
|
||||
3. Update `openspec update` so it always refreshes the root `AGENTS.md` (creating it if missing) alongside `openspec/AGENTS.md` and any other configured assistants.
|
||||
4. Document the new behavior in CLI specs and verify marker handling (no duplicates, preserve user content outside the block) with tests for both commands.
|
||||
|
||||
## Out of Scope
|
||||
- Adding additional AGENTS-specific prompts or workflows beyond the shared instructions block.
|
||||
- Non-interactive flags or bulk configuration for multiple standards in one run.
|
||||
- Broader restructuring of how templates are stored or loaded.
|
||||
|
||||
## Risks & Mitigations
|
||||
- **Risk:** Accidentally overwriting user-edited content surrounding the managed block.
|
||||
- **Mitigation:** Reuse the existing marker-update helper shared with `CLAUDE.md`, and add tests that cover files containing custom text before and after the block.
|
||||
- **Risk:** Divergence between `openspec/AGENTS.md` and the root file.
|
||||
- **Mitigation:** Source the root file content from the canonical template rather than duplicating strings inline.
|
||||
- **Risk:** Confusion about when the file is created.
|
||||
- **Mitigation:** Log creation vs update, and ensure help text references the AGENTS option during `init`.
|
||||
@@ -0,0 +1,71 @@
|
||||
## MODIFIED Requirements
|
||||
### Requirement: AI Tool Configuration
|
||||
The command SHALL configure AI coding assistants with OpenSpec instructions based on user selection.
|
||||
|
||||
#### Scenario: Prompting for AI tool selection
|
||||
|
||||
- **WHEN** run
|
||||
- **THEN** prompt user to select AI tools to configure:
|
||||
- Claude Code (✅ OpenSpec custom slash commands available)
|
||||
- Cursor (✅ OpenSpec custom slash commands available)
|
||||
- AGENTS.md (works with Codex, Amp, Copilot, …)
|
||||
|
||||
### Requirement: AI Tool Configuration Details
|
||||
The command SHALL properly configure selected AI tools with OpenSpec-specific instructions using a marker system.
|
||||
|
||||
#### Scenario: Configuring Claude Code
|
||||
|
||||
- **WHEN** Claude Code is selected
|
||||
- **THEN** create or update `CLAUDE.md` in the project root directory (not inside openspec/)
|
||||
|
||||
#### Scenario: Configuring AGENTS standard
|
||||
|
||||
- **WHEN** the AGENTS.md standard is selected
|
||||
- **THEN** create or update `AGENTS.md` in the project root directory (not inside openspec/)
|
||||
|
||||
#### Scenario: Creating new CLAUDE.md
|
||||
|
||||
- **WHEN** CLAUDE.md does not exist
|
||||
- **THEN** create new file with OpenSpec content wrapped in markers:
|
||||
```markdown
|
||||
<!-- OPENSPEC:START -->
|
||||
# OpenSpec Project
|
||||
|
||||
This document provides instructions for AI coding assistants on how to use OpenSpec conventions for spec-driven development. Follow these rules precisely when working on OpenSpec-enabled projects.
|
||||
|
||||
This project uses OpenSpec for spec-driven development. Specifications are the source of truth.
|
||||
|
||||
See @openspec/AGENTS.md for detailed conventions and guidelines.
|
||||
<!-- OPENSPEC:END -->
|
||||
```
|
||||
|
||||
#### Scenario: Creating new AGENTS.md
|
||||
|
||||
- **WHEN** AGENTS.md does not exist in the project root
|
||||
- **THEN** create new file with OpenSpec content wrapped in markers using the same template as CLAUDE.md
|
||||
|
||||
#### Scenario: Updating existing CLAUDE.md
|
||||
|
||||
- **WHEN** CLAUDE.md already exists
|
||||
- **THEN** preserve all existing content
|
||||
- **AND** insert OpenSpec content at the beginning of the file using markers
|
||||
- **AND** ensure markers don't duplicate if they already exist
|
||||
|
||||
#### Scenario: Updating existing AGENTS.md
|
||||
|
||||
- **WHEN** AGENTS.md already exists in the project root
|
||||
- **THEN** preserve all existing content
|
||||
- **AND** ensure the OpenSpec-managed block at the beginning of the file is refreshed without duplicating markers
|
||||
|
||||
#### Scenario: Managing content with markers
|
||||
|
||||
- **WHEN** using the marker system
|
||||
- **THEN** use `<!-- OPENSPEC:START -->` to mark the beginning of managed content
|
||||
- **AND** use `<!-- OPENSPEC:END -->` to mark the end of managed content
|
||||
- **AND** allow OpenSpec to update its content without affecting user customizations
|
||||
- **AND** preserve all content outside the markers intact
|
||||
|
||||
WHY use markers:
|
||||
- Users may have existing CLAUDE.md or AGENTS.md instructions they want to keep
|
||||
- OpenSpec can update its instructions in future versions
|
||||
- Clear boundary between OpenSpec-managed and user-managed content
|
||||
@@ -0,0 +1,41 @@
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Update Behavior
|
||||
The update command SHALL update OpenSpec instruction files to the latest templates in a team-friendly manner.
|
||||
|
||||
#### Scenario: Running update command
|
||||
|
||||
- **WHEN** a user runs `openspec update`
|
||||
- **THEN** the command SHALL:
|
||||
- Check if the `openspec` directory exists
|
||||
- Replace `openspec/AGENTS.md` with the latest template (complete replacement)
|
||||
- Create or refresh a root-level `AGENTS.md` file using the managed marker block (create if missing)
|
||||
- Update **only existing** AI tool configuration files (e.g., CLAUDE.md)
|
||||
- Check each registered AI tool configurator
|
||||
- For each configurator, check if its file exists
|
||||
- Update only files that already exist using their markers
|
||||
- Preserve user content outside markers
|
||||
- Display success message listing updated files
|
||||
|
||||
### Requirement: Tool-Agnostic Updates
|
||||
The update command SHALL handle file updates in a predictable and safe manner while respecting team tool choices.
|
||||
|
||||
#### Scenario: Updating files
|
||||
|
||||
- **WHEN** updating files
|
||||
- **THEN** completely replace `openspec/AGENTS.md` with the latest template
|
||||
- **AND** create or update the root-level `AGENTS.md` using the OpenSpec markers
|
||||
- **AND** update only the OpenSpec-managed blocks in **existing** AI tool files using markers
|
||||
- **AND** use the default directory name `openspec`
|
||||
- **AND** be idempotent (repeated runs have no additional effect)
|
||||
- **AND** respect team members' AI tool choices by not creating additional tool files beyond the root `AGENTS.md`
|
||||
|
||||
### Requirement: Core Files Always Updated
|
||||
The update command SHALL always update the core OpenSpec files and display an ASCII-safe success message.
|
||||
|
||||
#### Scenario: Successful update
|
||||
|
||||
- **WHEN** the update completes successfully
|
||||
- **THEN** replace `openspec/AGENTS.md` with the latest template
|
||||
- **AND** ensure the root-level `AGENTS.md` matches the latest template via the marker block
|
||||
- **AND** update existing AI tool configuration files within markers
|
||||
- **AND** display the message: "Updated OpenSpec instructions"
|
||||
@@ -0,0 +1,17 @@
|
||||
# Implementation Tasks
|
||||
|
||||
## 1. Extend Init Workflow
|
||||
- [x] 1.1 Add an "AGENTS.md standard" option to the `openspec init` tool-selection prompt, respecting the existing UI conventions.
|
||||
- [x] 1.2 Generate or refresh a root-level `AGENTS.md` file using the OpenSpec markers when that option is selected, sourcing content from the canonical template.
|
||||
|
||||
## 2. Enhance Update Command
|
||||
- [x] 2.1 Ensure `openspec update` writes the root `AGENTS.md` from the latest template (creating it if missing) alongside `openspec/AGENTS.md`.
|
||||
- [x] 2.2 Update success messaging and logging to reflect creation vs refresh of the AGENTS standard file.
|
||||
|
||||
## 3. Shared Template Handling
|
||||
- [x] 3.1 Refactor template utilities if necessary so both commands reuse the same content without duplication.
|
||||
- [x] 3.2 Add automated tests covering init/update flows for projects with and without an existing `AGENTS.md`, ensuring markers behave correctly.
|
||||
|
||||
## 4. Documentation
|
||||
- [x] 4.1 Update CLI specs and user-facing docs to describe AGENTS standard support.
|
||||
- [x] 4.2 Run `openspec validate add-agents-md-config --strict` and document any notable behavior changes.
|
||||
@@ -0,0 +1,45 @@
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Safety Checks
|
||||
The command SHALL perform safety checks to prevent overwriting existing structures and ensure proper permissions.
|
||||
|
||||
#### Scenario: Detecting existing initialization
|
||||
- **WHEN** the `openspec/` directory already exists
|
||||
- **THEN** inform the user that OpenSpec is already initialized, skip recreating the base structure, and enter an extend mode
|
||||
- **AND** continue to the AI tool selection step so additional tools can be configured
|
||||
- **AND** display the existing-initialization error message only when the user declines to add any AI tools
|
||||
|
||||
### Requirement: Interactive Mode
|
||||
The command SHALL provide an interactive menu for AI tool selection with clear navigation instructions.
|
||||
|
||||
#### Scenario: Displaying interactive menu
|
||||
- **WHEN** run in fresh or extend mode
|
||||
- **THEN** present a looping select menu that lets users toggle tools with Enter and finish via a "Done" option
|
||||
- **AND** label already configured tools with "(already configured)" while keeping disabled options marked "coming soon"
|
||||
- **AND** change the prompt copy in extend mode to "Which AI tools would you like to add or refresh?"
|
||||
- **AND** display inline instructions clarifying that Enter toggles a tool and selecting "Done" confirms the list
|
||||
|
||||
## ADDED Requirements
|
||||
### Requirement: Additional AI Tool Initialization
|
||||
`openspec init` SHALL allow users to add configuration files for new AI coding assistants after the initial setup.
|
||||
|
||||
#### Scenario: Configuring an extra tool after initial setup
|
||||
- **GIVEN** an `openspec/` directory already exists and at least one AI tool file is present
|
||||
- **WHEN** the user runs `openspec init` and selects a different supported AI tool
|
||||
- **THEN** generate that tool's configuration files with OpenSpec markers the same way as during first-time initialization
|
||||
- **AND** leave existing tool configuration files unchanged except for managed sections that need refreshing
|
||||
- **AND** exit with code 0 and display a success summary highlighting the newly added tool files
|
||||
|
||||
### Requirement: Success Output Enhancements
|
||||
`openspec init` SHALL summarize tool actions when initialization or extend mode completes.
|
||||
|
||||
#### Scenario: Showing tool summary
|
||||
- **WHEN** the command completes successfully
|
||||
- **THEN** display a categorized summary of tools that were created, refreshed, or skipped (including already-configured skips)
|
||||
- **AND** personalize the "Next steps" header using the names of the selected tools, defaulting to a generic label when none remain
|
||||
|
||||
### Requirement: Exit Code Adjustments
|
||||
`openspec init` SHALL treat extend mode with no selected tools as a guarded error.
|
||||
|
||||
#### Scenario: Preventing empty extend runs
|
||||
- **WHEN** OpenSpec is already initialized and the user selects no additional tools
|
||||
- **THEN** exit with code 1 after showing the existing-initialization guidance message
|
||||
+7
-7
@@ -1,16 +1,16 @@
|
||||
# Implementation Tasks
|
||||
|
||||
## 1. Extend Init Guard
|
||||
- [ ] 1.1 Detect existing OpenSpec structures at the start of `openspec init` and enter an extend mode instead of failing.
|
||||
- [ ] 1.2 Log that core scaffolding will be skipped while still protecting against missing write permissions.
|
||||
- [x] 1.1 Detect existing OpenSpec structures at the start of `openspec init` and enter an extend mode instead of failing.
|
||||
- [x] 1.2 Log that core scaffolding will be skipped while still protecting against missing write permissions.
|
||||
|
||||
## 2. Update AI Tool Selection
|
||||
- [ ] 2.1 Present AI tool choices even in extend mode, indicating which tools are already configured.
|
||||
- [ ] 2.2 Ensure disabled "coming soon" tools remain non-selectable.
|
||||
- [x] 2.1 Present AI tool choices even in extend mode, indicating which tools are already configured.
|
||||
- [x] 2.2 Ensure disabled "coming soon" tools remain non-selectable.
|
||||
|
||||
## 3. Generate Additional Tool Files
|
||||
- [ ] 3.1 Create configuration files for newly selected tools while leaving untouched tools unaffected apart from marker-managed sections.
|
||||
- [ ] 3.2 Summarize created, refreshed, and skipped tools before exiting with the appropriate code.
|
||||
- [x] 3.1 Create configuration files for newly selected tools while leaving untouched tools unaffected apart from marker-managed sections.
|
||||
- [x] 3.2 Summarize created, refreshed, and skipped tools before exiting with the appropriate code.
|
||||
|
||||
## 4. Verification
|
||||
- [ ] 4.1 Add tests covering rerunning `openspec init` to add another tool and the scenario where the user declines to add anything.
|
||||
- [x] 4.1 Add tests covering rerunning `openspec init` to add another tool and the scenario where the user declines to add anything.
|
||||
+6
@@ -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
|
||||
+5
@@ -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
|
||||
+4
@@ -14,3 +14,7 @@
|
||||
|
||||
## 4. Verification
|
||||
- [x] 4.1 Add tests verifying slash command files are created and updated correctly.
|
||||
|
||||
## 5. OpenCode Integration
|
||||
- [x] 5.1 Generate `.opencode/commands/{openspec-proposal,openspec-apply,openspec-archive}.md` during `openspec init` using shared templates.
|
||||
- [x] 5.2 Update existing `.opencode/commands/*` files during `openspec update`.
|
||||
@@ -0,0 +1,19 @@
|
||||
## Why
|
||||
Recent cross-shell regressions for `openspec` commands revealed that our existing unit/integration tests do not exercise the packaged CLI or shell-specific behavior. The prior attempt at Vitest spawn tests stalled because it coupled e2e coverage with `pnpm pack` installs, which fail in network-restricted environments. With those findings incorporated, we now need an approved plan to realign the work.
|
||||
|
||||
## What Changes
|
||||
- Adopt a phased strategy that first stabilizes direct spawn testing of the built CLI (`node dist/cli/index.js`) using lightweight fixtures and a shared `runCLI` helper.
|
||||
- Expand coverage once the spawn harness is stable, keeping the initial matrix focused on bash jobs for Linux/macOS and `pwsh` on Windows while exercising both the direct `node dist/cli/index.js` invocation and the bin shim with non-TTY defaults and captured diagnostics.
|
||||
- Treat packaging/install validation as an optional CI safeguard: when a runner has registry access, run a simple pnpm-based pack→install→smoke-test flow; otherwise document it as out of scope while closing remaining hardening items.
|
||||
- Close out the remaining cross-shell hardening items: ensure `.gitattributes` covers packaged assets, enforce executable bits for CLI shims during CI, and finish the pending SIGINT handling improvements.
|
||||
|
||||
## Impact
|
||||
- Tests: add `test/cli-e2e` spawn suite, create the shared `runCLI` helper, and adjust `vitest.setup.ts` as needed.
|
||||
- Tooling: update GitHub Actions workflows with the lightweight matrix above and (optionally) a packaging install check where network is available.
|
||||
- Docs: note phase progress and any limitations inline in this proposal (or the relevant spec) so future phases have clear context.
|
||||
|
||||
### Phase 1 Status
|
||||
- Shared `test/helpers/run-cli.ts` guarantees the CLI bundle exists before spawning and enforces non-TTY defaults for every invocation.
|
||||
- New `test/cli-e2e/basic.test.ts` covers `--help`, `--version`, a successful `validate --all --json`, and an unknown-item error path against the `tmp-init` fixture copy.
|
||||
- Legacy top-level `validate` exec tests now rely on `runCLI`, avoiding manual `execSync` usage while keeping their fixture authoring intact.
|
||||
- CI matrix groundwork is in place (bash on Linux/macOS, pwsh on Windows) so the spawn suite runs the same way the helper does across supported shells.
|
||||
@@ -0,0 +1,9 @@
|
||||
## 1. Phase 1 – Stabilize Local Spawn Coverage
|
||||
- [x] 1.1 Add `test/helpers/run-cli.ts` that ensures the build runs once and executes `node dist/cli/index.js` with non-TTY defaults; update `vitest.setup.ts` to reuse the shared build step.
|
||||
- [x] 1.2 Seed `test/cli-e2e` using the minimal fixture set (`tmp-init` or copy) to cover help/version, a happy-path `validate`, and a representative error flow via the new helper.
|
||||
- [x] 1.3 Migrate the highest-value existing CLI exec tests (e.g., validate) onto `runCLI` and summarize Phase 1 coverage in this proposal for the next phase.
|
||||
|
||||
## 2. Phase 2 – Expand Cross-Shell Validation
|
||||
- [x] 2.1 Exercise both entry points (`node dist/cli/index.js`, `bin/openspec.js`) in the spawn suite and add diagnostics for shell/OS context.
|
||||
- [x] 2.2 Extend GitHub Actions to run the spawn suite on bash jobs for Linux/macOS and a `pwsh` job on Windows; capture shell/OS diagnostics and note follow-ups for additional shells.
|
||||
|
||||
+1
-1
@@ -21,5 +21,5 @@
|
||||
|
||||
## 5. Optional (Not Needed Now)
|
||||
- [x] 5.1 Add optional root param to discovery helpers (default process.cwd())
|
||||
- [ ] 5.2 Consider threading root through command constructors if ever required
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
## Why
|
||||
The current `openspec init` flow assumes a single assistant selection and stops once an OpenSpec structure already exists. That makes onboarding feel rigid: teams cannot configure multiple tools in one pass, they do not learn which files were refreshed, and the success copy always references Claude even when other assistants are involved.
|
||||
|
||||
## What Changes
|
||||
- Allow selecting multiple assistants during `openspec init`, including refreshing existing configurations in a single run.
|
||||
- Provide richer onboarding copy that summarizes which tool files were created or refreshed and guides users on next steps for each assistant.
|
||||
- Align generated AI-instruction content and specs so CLAUDE.md and AGENTS.md share the same OpenSpec guidance.
|
||||
- Update specs and tests to cover the multi-select prompt, improved summaries, and extend-mode coordination.
|
||||
|
||||
## Impact
|
||||
- Specs: `cli-init`
|
||||
- Code: `src/core/init.ts`, `src/core/config.ts`, `src/core/templates/*`, `src/core/configurators/*`
|
||||
- Tests: `test/core/init.test.ts`, `test/core/update.test.ts`
|
||||
@@ -0,0 +1,92 @@
|
||||
## MODIFIED Requirements
|
||||
### Requirement: AI Tool Configuration
|
||||
|
||||
The command SHALL configure AI coding assistants with OpenSpec instructions based on user selection.
|
||||
|
||||
#### Scenario: Prompting for AI tool selection
|
||||
|
||||
- **WHEN** run interactively
|
||||
- **THEN** prompt the user with "Which AI tools do you use?" using a multi-select menu
|
||||
- **AND** list every available tool with a checkbox:
|
||||
- Claude Code (creates or refreshes CLAUDE.md and slash commands)
|
||||
- Cursor (creates or refreshes `.cursor/commands/*` slash commands)
|
||||
- AGENTS.md standard (creates or refreshes AGENTS.md with OpenSpec markers)
|
||||
- **AND** show "(already configured)" beside tools whose managed files exist so users understand selections will refresh content
|
||||
- **AND** treat disabled tools as "coming soon" and keep them unselectable
|
||||
- **AND** allow confirming with Enter after selecting one or more tools
|
||||
|
||||
### Requirement: AI Tool Configuration Details
|
||||
|
||||
The command SHALL properly configure selected AI tools with OpenSpec-specific instructions using a marker system.
|
||||
|
||||
#### Scenario: Configuring Claude Code
|
||||
|
||||
- **WHEN** Claude Code is selected
|
||||
- **THEN** create or update `CLAUDE.md` in the project root directory (not inside openspec/)
|
||||
|
||||
#### Scenario: Creating new CLAUDE.md
|
||||
|
||||
- **WHEN** CLAUDE.md does not exist
|
||||
- **THEN** create new file with OpenSpec content wrapped in markers:
|
||||
```markdown
|
||||
<!-- OPENSPEC:START -->
|
||||
# OpenSpec Instructions
|
||||
|
||||
Instructions for AI coding assistants using OpenSpec for spec-driven development.
|
||||
|
||||
## TL;DR Quick Checklist
|
||||
- Search existing work: `openspec spec list --long`, `openspec list`
|
||||
- Decide scope: new capability vs modify existing capability
|
||||
- Pick a unique `change-id`: verb-led kebab-case (`add-`, `update-`, `remove-`, `refactor-`)
|
||||
- Scaffold: `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas
|
||||
- Validate with `openspec validate [change-id] --strict`
|
||||
- Request approval before implementation
|
||||
<!-- OPENSPEC:END -->
|
||||
```
|
||||
|
||||
#### Scenario: Updating existing CLAUDE.md
|
||||
|
||||
- **WHEN** CLAUDE.md already exists
|
||||
- **THEN** preserve all existing content
|
||||
- **AND** insert OpenSpec content at the beginning of the file using markers
|
||||
- **AND** ensure markers don't duplicate if they already exist
|
||||
|
||||
#### Scenario: Managing content with markers
|
||||
|
||||
- **WHEN** using the marker system
|
||||
- **THEN** use `<!-- OPENSPEC:START -->` to mark the beginning of managed content
|
||||
- **AND** use `<!-- OPENSPEC:END -->` to mark the end of managed content
|
||||
- **AND** allow OpenSpec to update its content without affecting user customizations
|
||||
- **AND** preserve all content outside the markers intact
|
||||
|
||||
### Requirement: Interactive Mode
|
||||
|
||||
The command SHALL provide an interactive menu for AI tool selection with clear navigation instructions.
|
||||
|
||||
#### Scenario: Displaying interactive menu
|
||||
|
||||
- **WHEN** run
|
||||
- **THEN** prompt the user with: "Which AI tools do you use?"
|
||||
- **AND** show a checkbox-based multi-select menu with available tools (Claude Code, Cursor, AGENTS.md standard)
|
||||
- **AND** show disabled options as "coming soon" (not selectable)
|
||||
- **AND** display inline help indicating Space toggles selections and Enter confirms
|
||||
|
||||
#### Scenario: Navigating the menu
|
||||
|
||||
- **WHEN** the user is in the menu
|
||||
- **THEN** allow arrow keys to move between options
|
||||
- **AND** allow Spacebar to toggle the highlighted option
|
||||
- **AND** allow Enter key to confirm all current selections
|
||||
|
||||
### Requirement: Success Output
|
||||
|
||||
The command SHALL provide clear, actionable next steps upon successful initialization.
|
||||
|
||||
#### Scenario: Displaying success message
|
||||
|
||||
- **WHEN** initialization completes successfully
|
||||
- **THEN** display a success banner followed by actionable prompts tailored to the selected tools
|
||||
- **AND** summarize which assistant files were created versus refreshed (e.g., `CLAUDE.md (created)`, `.cursor/commands/openspec-apply.md (refreshed)`)
|
||||
- **AND** include copy-pasteable onboarding prompts for each configured assistant, replacing placeholder text ([YOUR FEATURE HERE]) with real guidance to customize
|
||||
- **AND** reference AGENTS.md-compatible assistants when no tool-specific file exists (e.g., when only AGENTS.md standard is selected)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
## 1. Planning & Spec Updates
|
||||
- [x] 1.1 Confirm overlap with `add-multi-agent-init` and coordinate extend-mode flow
|
||||
- [x] 1.2 Update `openspec/specs/cli-init/spec.md` to capture multi-select onboarding requirements
|
||||
|
||||
## 2. Implementation
|
||||
- [x] 2.1 Add multi-select support to the `openspec init` prompt, including indicators for existing tool configs
|
||||
- [x] 2.2 Enhance success messaging to summarize created/refreshed assets per tool
|
||||
- [x] 2.3 Ensure shared instruction template is applied consistently (CLAUDE.md, AGENTS.md, slash commands)
|
||||
|
||||
## 3. Quality
|
||||
- [x] 3.1 Expand unit tests for init/update flows covering multi-select and summaries
|
||||
- [x] 3.2 Perform `openspec init` smoke test in a temp directory (document output)
|
||||
-4
@@ -26,10 +26,6 @@
|
||||
- Archive documentation
|
||||
- Change proposals
|
||||
|
||||
## 6. Add Deprecation Notice (Optional Phase)
|
||||
- [ ] Consider adding a deprecation warning before full removal
|
||||
- [ ] Provide helpful message directing users to `openspec show` command
|
||||
|
||||
## 7. Testing
|
||||
- [x] Ensure all tests pass after removal
|
||||
- [x] Verify CLI help text no longer shows diff command
|
||||
+4
@@ -25,12 +25,16 @@ The command SHALL generate required template files with appropriate content for
|
||||
|
||||
### Requirement: AI Tool Configuration Details
|
||||
|
||||
The command SHALL properly configure selected AI tools with OpenSpec-specific instructions using a marker system.
|
||||
|
||||
#### Scenario: Creating new CLAUDE.md
|
||||
- **WHEN** CLAUDE.md does not exist
|
||||
- **THEN** create new file with OpenSpec content wrapped in markers including reference to `@openspec/AGENTS.md`
|
||||
|
||||
### Requirement: Success Output
|
||||
|
||||
The command SHALL provide clear, actionable next steps upon successful initialization.
|
||||
|
||||
#### Scenario: Displaying success message
|
||||
- **WHEN** initialization completes successfully
|
||||
- **THEN** include prompt: "Please explain the OpenSpec workflow from openspec/AGENTS.md and how I should work with you on this project"
|
||||
@@ -0,0 +1,19 @@
|
||||
# Update Markdown Parser CRLF Handling
|
||||
|
||||
## Problem
|
||||
Windows users report that `openspec validate` raises “Change must have a Why section” even when the section exists (see GitHub issue #77). The CLI currently splits markdown on `\n` and compares headers without stripping `\r`, so files saved with CRLF line endings keep a trailing carriage return in the header token. As a result the parser fails to detect `## Why`/`## What Changes`, triggering false validation errors and breaking the workflow on Windows-default editors.
|
||||
|
||||
## Solution
|
||||
- Normalize markdown content inside the parser so CRLF and lone-CR inputs are treated as `\n` before section detection, trimming any carriage returns from titles and content comparisons.
|
||||
- Reuse the normalized reader everywhere `MarkdownParser` is constructed to keep behavior consistent for validation, view, spec, and list flows.
|
||||
- Add regression coverage that reproduces the failure (unit test around `parseChange` and a CLI spawn/e2e test that writes a CRLF change then runs `openspec validate`).
|
||||
- Update the `cli-validate` spec to codify the expectation that required sections are recognized regardless of line-ending style.
|
||||
|
||||
## Benefits
|
||||
- Restores correct validation behavior for Windows editors without requiring manual line-ending conversion.
|
||||
- Locks in the fix with targeted tests so future parser refactors keep cross-platform support.
|
||||
- Clarifies the spec so downstream work (e.g., cross-shell e2e plan) understands the non-negotiable behavior.
|
||||
|
||||
## Risks
|
||||
- Low: parser normalization touches shared code paths that parse specs and changes; need to ensure no regressions in other command consumers (mitigated by existing parser tests plus the new CRLF fixtures).
|
||||
|
||||
+9
@@ -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.
|
||||
@@ -0,0 +1,13 @@
|
||||
## Why
|
||||
The project root currently receives a full copy of the OpenSpec agent instructions, duplicating the content that also lives in `openspec/AGENTS.md`. When teams edit one copy but not the other, the files drift and onboarding assistants see conflicting guidance.
|
||||
|
||||
## What Changes
|
||||
- Keep generating the complete template in `openspec/AGENTS.md` during `openspec init` and follow-up updates.
|
||||
- Replace the root-level file (`AGENTS.md` or `CLAUDE.md`, depending on tool selection) with a short hand-off that explains the project uses OpenSpec and points directly to `openspec/AGENTS.md`.
|
||||
- Add a dedicated stub template so both the init and update flows reuse the same minimal copy instructions.
|
||||
- Update CLI tests and documentation to reflect the new root-level messaging and ensure the OpenSpec marker block still protects future updates.
|
||||
|
||||
## Impact
|
||||
- Affected specs: `cli-init`, `cli-update`
|
||||
- Affected code: `src/core/init.ts`, `src/core/update.ts`, `src/core/templates/agents-template.ts`
|
||||
- Update assets/readmes that mention the root `AGENTS.md` contents to reference the new stub message.
|
||||
@@ -0,0 +1,15 @@
|
||||
## 1. Templates
|
||||
- [x] 1.1 Add a shared stub template that renders the root agent instructions hand-off message.
|
||||
- [x] 1.2 Ensure the stub covers both `AGENTS.md` and `CLAUDE.md` variants.
|
||||
|
||||
## 2. Init Flow
|
||||
- [x] 2.1 Update `createInitArtifacts` to write the stub to the project root instead of the full instructions.
|
||||
- [x] 2.2 Preserve the managed block markers so future updates can overwrite the stub safely.
|
||||
|
||||
## 3. Update Flow
|
||||
- [x] 3.1 Make the update command refresh the root stub rather than the full instructions.
|
||||
- [x] 3.2 Confirm the update log output still reflects the files that changed.
|
||||
|
||||
## 4. Tests & Docs
|
||||
- [x] 4.1 Adjust CLI/init tests to match the new root content.
|
||||
- [x] 4.2 Document the stub message in `openspec/specs/cli-init` and `openspec/specs/cli-update` (and any relevant README snippets).
|
||||
@@ -3,9 +3,7 @@
|
||||
## Purpose
|
||||
|
||||
The `openspec init` command SHALL create a complete OpenSpec directory structure in any project, enabling immediate adoption of OpenSpec conventions with support for multiple AI coding assistants.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Progress Indicators
|
||||
|
||||
The command SHALL display progress indicators during initialization to provide clear feedback about each step.
|
||||
@@ -21,11 +19,9 @@ The command SHALL display progress indicators during initialization to provide c
|
||||
- Then success: "✔ AI tools configured"
|
||||
|
||||
### Requirement: Directory Creation
|
||||
|
||||
The command SHALL create the complete OpenSpec directory structure with all required directories and files.
|
||||
|
||||
#### Scenario: Creating OpenSpec structure
|
||||
|
||||
- **WHEN** `openspec init` is executed
|
||||
- **THEN** create the following directory structure:
|
||||
```
|
||||
@@ -38,13 +34,11 @@ openspec/
|
||||
```
|
||||
|
||||
### Requirement: File Generation
|
||||
|
||||
The command SHALL generate required template files with appropriate content for immediate use.
|
||||
|
||||
#### Scenario: Generating template files
|
||||
|
||||
- **WHEN** initializing OpenSpec
|
||||
- **THEN** generate `AGENTS.md` containing complete OpenSpec instructions for AI assistants
|
||||
- **THEN** generate `openspec/AGENTS.md` containing complete OpenSpec instructions for AI assistants
|
||||
- **AND** generate `project.md` with project context template
|
||||
|
||||
### Requirement: AI Tool Configuration
|
||||
@@ -54,10 +48,14 @@ The command SHALL configure AI coding assistants with OpenSpec instructions base
|
||||
#### Scenario: Prompting for AI tool selection
|
||||
|
||||
- **WHEN** run interactively
|
||||
- **THEN** prompt user to select AI tools to configure:
|
||||
- Claude Code (updates/creates CLAUDE.md with OpenSpec markers)
|
||||
- Cursor (future)
|
||||
- Aider (future)
|
||||
- **THEN** prompt the user with "Which AI tools do you use?" using a multi-select menu
|
||||
- **AND** list every available tool with a checkbox:
|
||||
- Claude Code (creates or refreshes CLAUDE.md and slash commands)
|
||||
- Cursor (creates or refreshes `.cursor/commands/*` slash commands)
|
||||
- AGENTS.md standard (creates or refreshes AGENTS.md stub with OpenSpec markers)
|
||||
- **AND** show "(already configured)" beside tools whose managed files exist so users understand selections will refresh content
|
||||
- **AND** treat disabled tools as "coming soon" and keep them unselectable
|
||||
- **AND** allow confirming with Enter after selecting one or more tools
|
||||
|
||||
### Requirement: AI Tool Configuration Details
|
||||
|
||||
@@ -67,112 +65,49 @@ The command SHALL properly configure selected AI tools with OpenSpec-specific in
|
||||
|
||||
- **WHEN** Claude Code is selected
|
||||
- **THEN** create or update `CLAUDE.md` in the project root directory (not inside openspec/)
|
||||
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
|
||||
|
||||
#### Scenario: Creating new CLAUDE.md
|
||||
|
||||
- **WHEN** CLAUDE.md does not exist
|
||||
- **THEN** create new file with OpenSpec content wrapped in markers:
|
||||
- **THEN** create new file with stub instructions wrapped in markers so the full workflow stays in `openspec/AGENTS.md`:
|
||||
```markdown
|
||||
<!-- OPENSPEC:START -->
|
||||
# OpenSpec Project
|
||||
# OpenSpec Instructions
|
||||
|
||||
This document provides instructions for AI coding assistants on how to use OpenSpec conventions for spec-driven development. Follow these rules precisely when working on OpenSpec-enabled projects.
|
||||
This project uses OpenSpec to manage AI assistant workflows.
|
||||
|
||||
This project uses OpenSpec for spec-driven development. Specifications are the source of truth.
|
||||
|
||||
See @openspec/AGENTS.md for detailed conventions and guidelines.
|
||||
- Full guidance lives in '@/openspec/AGENTS.md'.
|
||||
- Keep this managed block so 'openspec update' can refresh the instructions.
|
||||
<!-- OPENSPEC:END -->
|
||||
```
|
||||
|
||||
#### Scenario: Updating existing CLAUDE.md
|
||||
|
||||
- **WHEN** CLAUDE.md already exists
|
||||
- **THEN** preserve all existing content
|
||||
- **AND** insert OpenSpec content at the beginning of the file using markers
|
||||
- **AND** ensure markers don't duplicate if they already exist
|
||||
|
||||
#### Scenario: Managing content with markers
|
||||
|
||||
- **WHEN** using the marker system
|
||||
- **THEN** use `<!-- OPENSPEC:START -->` to mark the beginning of managed content
|
||||
- **AND** use `<!-- OPENSPEC:END -->` to mark the end of managed content
|
||||
- **AND** allow OpenSpec to update its content without affecting user customizations
|
||||
- **AND** preserve all content outside the markers intact
|
||||
|
||||
WHY use markers:
|
||||
- Users may have existing CLAUDE.md instructions they want to keep
|
||||
- OpenSpec can update its instructions in future versions
|
||||
- Clear boundary between OpenSpec-managed and user-managed content
|
||||
|
||||
### Requirement: Interactive Mode
|
||||
|
||||
The command SHALL provide an interactive menu for AI tool selection with clear navigation instructions.
|
||||
|
||||
#### Scenario: Displaying interactive menu
|
||||
|
||||
- **WHEN** run
|
||||
- **THEN** prompt user with: "Which AI tool do you use?"
|
||||
- **AND** show single-select menu with available tools:
|
||||
- Claude Code
|
||||
- **AND** show disabled options as "coming soon" (not selectable):
|
||||
- Cursor (coming soon)
|
||||
- Aider (coming soon)
|
||||
- Continue (coming soon)
|
||||
|
||||
#### Scenario: Navigating the menu
|
||||
|
||||
- **WHEN** user is in the menu
|
||||
- **THEN** allow arrow keys to move between options
|
||||
- **AND** allow Enter key to select the highlighted option
|
||||
- **WHEN** run in fresh or extend mode
|
||||
- **THEN** present a looping select menu that lets users toggle tools with Enter and finish via a "Done" option
|
||||
- **AND** label already configured tools with "(already configured)" while keeping disabled options marked "coming soon"
|
||||
- **AND** change the prompt copy in extend mode to "Which AI tools would you like to add or refresh?"
|
||||
- **AND** display inline instructions clarifying that Enter toggles a tool and selecting "Done" confirms the list
|
||||
|
||||
### Requirement: Safety Checks
|
||||
|
||||
The command SHALL perform safety checks to prevent overwriting existing structures and ensure proper permissions.
|
||||
|
||||
#### Scenario: Detecting existing initialization
|
||||
|
||||
- **WHEN** `openspec/` directory already exists
|
||||
- **THEN** display error with ora fail indicator:
|
||||
- "✖ Error: OpenSpec seems to already be initialized. Use 'openspec update' to update the structure."
|
||||
|
||||
#### Scenario: Checking write permissions
|
||||
|
||||
- **WHEN** checking initialization feasibility
|
||||
- **THEN** verify write permissions in the target directory silently
|
||||
- **AND** only display error if permissions are insufficient
|
||||
- **WHEN** the `openspec/` directory already exists
|
||||
- **THEN** inform the user that OpenSpec is already initialized, skip recreating the base structure, and enter an extend mode
|
||||
- **AND** continue to the AI tool selection step so additional tools can be configured
|
||||
- **AND** display the existing-initialization error message only when the user declines to add any AI tools
|
||||
|
||||
### Requirement: Success Output
|
||||
|
||||
The command SHALL provide clear, actionable next steps upon successful initialization.
|
||||
|
||||
#### Scenario: Displaying success message
|
||||
|
||||
- **WHEN** initialization completes successfully
|
||||
- **THEN** display actionable prompts for AI-driven workflow:
|
||||
```
|
||||
✔ OpenSpec initialized successfully!
|
||||
|
||||
Next steps - Copy these prompts to Claude:
|
||||
|
||||
────────────────────────────────────────────────────────────
|
||||
1. Populate your project context:
|
||||
"Please read openspec/project.md and help me fill it out
|
||||
with details about my project, tech stack, and conventions"
|
||||
|
||||
2. Create your first change proposal:
|
||||
"I want to add [YOUR FEATURE HERE]. Please create an
|
||||
OpenSpec change proposal for this feature"
|
||||
|
||||
3. Learn the OpenSpec workflow:
|
||||
"Please explain the OpenSpec workflow from openspec/AGENTS.md
|
||||
and how I should work with you on this project"
|
||||
────────────────────────────────────────────────────────────
|
||||
```
|
||||
|
||||
The prompts SHALL:
|
||||
- Be copy-pasteable for immediate use with AI tools
|
||||
- Guide users through the AI-driven workflow
|
||||
- Replace placeholder text ([YOUR FEATURE HERE]) with actual features
|
||||
- **THEN** include prompt: "Please explain the OpenSpec workflow from openspec/AGENTS.md and how I should work with you on this project"
|
||||
|
||||
### Requirement: Exit Codes
|
||||
|
||||
@@ -187,10 +122,56 @@ The command SHALL use consistent exit codes to indicate different failure modes.
|
||||
- 2: Insufficient permissions (reserved for future use)
|
||||
- 3: User cancelled operation (reserved for future use)
|
||||
|
||||
### Requirement: Additional AI Tool Initialization
|
||||
`openspec init` SHALL allow users to add configuration files for new AI coding assistants after the initial setup.
|
||||
|
||||
#### Scenario: Configuring an extra tool after initial setup
|
||||
- **GIVEN** an `openspec/` directory already exists and at least one AI tool file is present
|
||||
- **WHEN** the user runs `openspec init` and selects a different supported AI tool
|
||||
- **THEN** generate that tool's configuration files with OpenSpec markers the same way as during first-time initialization
|
||||
- **AND** leave existing tool configuration files unchanged except for managed sections that need refreshing
|
||||
- **AND** exit with code 0 and display a success summary highlighting the newly added tool files
|
||||
|
||||
### Requirement: Success Output Enhancements
|
||||
`openspec init` SHALL summarize tool actions when initialization or extend mode completes.
|
||||
|
||||
#### Scenario: Showing tool summary
|
||||
- **WHEN** the command completes successfully
|
||||
- **THEN** display a categorized summary of tools that were created, refreshed, or skipped (including already-configured skips)
|
||||
- **AND** personalize the "Next steps" header using the names of the selected tools, defaulting to a generic label when none remain
|
||||
|
||||
### Requirement: Exit Code Adjustments
|
||||
`openspec init` SHALL treat extend mode with no selected tools as a guarded error.
|
||||
|
||||
#### Scenario: Preventing empty extend runs
|
||||
- **WHEN** OpenSpec is already initialized and the user selects no additional tools
|
||||
- **THEN** exit with code 1 after showing the existing-initialization guidance message
|
||||
|
||||
### Requirement: Slash Command Configuration
|
||||
The init command SHALL generate slash command files for supported editors using shared templates.
|
||||
|
||||
#### Scenario: Generating slash commands for Claude Code
|
||||
- **WHEN** the user selects Claude Code during initialization
|
||||
- **THEN** create `.claude/commands/openspec/proposal.md`, `.claude/commands/openspec/apply.md`, and `.claude/commands/openspec/archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Cursor
|
||||
- **WHEN** the user selects Cursor during initialization
|
||||
- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for OpenCode
|
||||
- **WHEN** the user selects OpenCode during initialization
|
||||
- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
## Why
|
||||
|
||||
Manual creation of OpenSpec structure is error-prone and creates adoption friction. A standardized init command ensures:
|
||||
- Consistent structure across all projects
|
||||
- Proper AI instruction files are always included
|
||||
- Quick onboarding for new projects
|
||||
- Clear conventions from the start
|
||||
- Clear conventions from the start
|
||||
|
||||
@@ -5,22 +5,12 @@
|
||||
As a developer using OpenSpec, I want to update the OpenSpec instructions in my project when new versions are released, so that I can benefit from improvements to AI agent instructions.
|
||||
## Requirements
|
||||
### Requirement: Update Behavior
|
||||
|
||||
The update command SHALL update OpenSpec instruction files to the latest templates in a team-friendly manner.
|
||||
|
||||
#### Scenario: Running update command
|
||||
|
||||
- **WHEN** a user runs `openspec update`
|
||||
- **THEN** the command SHALL:
|
||||
- Check if the `openspec` directory exists
|
||||
- Replace `openspec/AGENTS.md` with the latest template (complete replacement)
|
||||
- Update **only existing** AI tool configuration files (e.g., CLAUDE.md)
|
||||
- Check each registered AI tool configurator
|
||||
- For each configurator, check if its file exists
|
||||
- Update only files that already exist using their markers
|
||||
- Preserve user content outside markers
|
||||
- **Never create new AI tool configuration files**
|
||||
- Display success message listing updated files
|
||||
- **THEN** replace `openspec/AGENTS.md` with the latest template
|
||||
- **AND** if a root-level stub (`AGENTS.md`/`CLAUDE.md`) exists, refresh it so it points to `@/openspec/AGENTS.md`
|
||||
|
||||
### Requirement: Prerequisites
|
||||
|
||||
@@ -34,39 +24,56 @@ The command SHALL require an existing OpenSpec structure before allowing updates
|
||||
- **AND** exit with code 1
|
||||
|
||||
### Requirement: File Handling
|
||||
|
||||
The update command SHALL handle file updates in a predictable and safe manner.
|
||||
|
||||
#### Scenario: Updating files
|
||||
- **WHEN** updating files
|
||||
- **THEN** completely replace `openspec/AGENTS.md` with the latest template
|
||||
- **AND** if a root-level stub exists, update the managed block content so it keeps directing teammates to `@/openspec/AGENTS.md`
|
||||
|
||||
### Requirement: Tool-Agnostic Updates
|
||||
The update command SHALL handle file updates in a predictable and safe manner while respecting team tool choices.
|
||||
|
||||
#### Scenario: Updating files
|
||||
|
||||
- **WHEN** updating files
|
||||
- **THEN** completely replace `openspec/AGENTS.md` with the latest template
|
||||
- **AND** update the root-level `AGENTS.md` using the OpenSpec markers only when that file already exists, keeping the stub content that links to `@/openspec/AGENTS.md`
|
||||
- **AND** update only the OpenSpec-managed blocks in **existing** AI tool files using markers
|
||||
- **AND** use the default directory name `openspec`
|
||||
- **AND** be idempotent (repeated runs have no additional effect)
|
||||
- **AND** respect team members' AI tool choices by not creating unwanted files
|
||||
|
||||
### Requirement: Tool-Agnostic Updates
|
||||
|
||||
The update command SHALL update only existing AI tool configuration files and SHALL NOT create new ones.
|
||||
|
||||
#### Scenario: Updating existing tool files
|
||||
|
||||
- **WHEN** a user runs `openspec update`
|
||||
- **THEN** update each AI tool configuration file that exists (e.g., CLAUDE.md, COPILOT.md)
|
||||
- **AND** do not create missing tool configuration files
|
||||
- **AND** preserve user content outside OpenSpec markers
|
||||
- **AND** respect team members' AI tool choices by not creating additional tool files beyond the root `AGENTS.md`
|
||||
- **AND** do not create new root-level stub files when none are present
|
||||
|
||||
### Requirement: Core Files Always Updated
|
||||
|
||||
The update command SHALL always update the core OpenSpec files and display an ASCII-safe success message.
|
||||
|
||||
#### Scenario: Successful update
|
||||
|
||||
- **WHEN** the update completes successfully
|
||||
- **THEN** replace `openspec/AGENTS.md` with the latest template
|
||||
- **AND** update existing AI tool configuration files within markers
|
||||
- **AND** display the message: "Updated OpenSpec instructions"
|
||||
- **AND** if a root-level stub exists, refresh it so it still directs contributors to `@/openspec/AGENTS.md`
|
||||
|
||||
### Requirement: Slash Command Updates
|
||||
The update command SHALL refresh existing slash command files for configured tools without creating new ones.
|
||||
|
||||
#### Scenario: Updating slash commands for Claude Code
|
||||
- **WHEN** `.claude/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Updating slash commands for Cursor
|
||||
- **WHEN** `.cursor/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Updating slash commands for OpenCode
|
||||
- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Missing slash command file
|
||||
- **WHEN** a tool lacks a slash command file
|
||||
- **THEN** do not create a new file during update
|
||||
|
||||
## Edge Cases
|
||||
|
||||
@@ -101,4 +108,4 @@ Users SHALL be able to:
|
||||
The update process SHALL be:
|
||||
- Simple and fast (no version checking)
|
||||
- Predictable (same result every time)
|
||||
- Self-contained (no network required)
|
||||
- Self-contained (no network required)
|
||||
|
||||
@@ -199,3 +199,12 @@ The validate command SHALL handle ambiguous names and explicit type overrides to
|
||||
- **THEN** the CLI SHALL not display interactive prompts
|
||||
- **AND** SHALL print non-interactive hints or chosen outputs as appropriate
|
||||
|
||||
### Requirement: Parser SHALL handle cross-platform line endings
|
||||
The markdown parser SHALL correctly identify sections regardless of line ending format (LF, CRLF, CR).
|
||||
|
||||
#### Scenario: Required sections parsed with CRLF line endings
|
||||
- **GIVEN** a change proposal markdown saved with CRLF line endings
|
||||
- **AND** the document contains `## Why` and `## What Changes`
|
||||
- **WHEN** running `openspec validate <change-id>`
|
||||
- **THEN** validation SHALL recognize the sections and NOT raise parsing errors
|
||||
|
||||
|
||||
@@ -36,28 +36,14 @@ The dashboard SHALL display a summary section with key project metrics.
|
||||
- **THEN** summary shows zero counts for all metrics
|
||||
|
||||
### Requirement: Active Changes Display
|
||||
|
||||
The dashboard SHALL show active changes with visual progress indicators.
|
||||
|
||||
#### Scenario: Active changes with progress bars
|
||||
|
||||
- **WHEN** there are in-progress changes with tasks
|
||||
- **THEN** system displays each change with change name left-aligned
|
||||
- **AND** visual progress bar using Unicode characters
|
||||
- **AND** percentage completion on the right
|
||||
|
||||
#### Scenario: Active changes ordered by completion percentage
|
||||
|
||||
- **WHEN** multiple active changes are displayed with progress information
|
||||
- **THEN** list them sorted by completion percentage ascending so 0% items appear first
|
||||
- **AND** treat missing progress values as 0% for ordering
|
||||
- **AND** break ties by change identifier in ascending alphabetical order to keep output deterministic
|
||||
|
||||
#### Scenario: No active changes
|
||||
|
||||
- **WHEN** all changes are completed or no changes exist
|
||||
- **THEN** active changes section is omitted from display
|
||||
|
||||
### Requirement: Completed Changes Display
|
||||
|
||||
The dashboard SHALL list completed changes in a separate section.
|
||||
|
||||
@@ -14,11 +14,9 @@ OpenSpec conventions SHALL mandate a structured spec format with clear requireme
|
||||
- **THEN** authors SHALL use `### Requirement: ...` followed by at least one `#### Scenario: ...` section
|
||||
|
||||
### Requirement: Project Structure
|
||||
|
||||
An OpenSpec project SHALL maintain a consistent directory structure for specifications and changes.
|
||||
|
||||
#### Scenario: Initializing project structure
|
||||
|
||||
- **WHEN** an OpenSpec project is initialized
|
||||
- **THEN** it SHALL have this structure:
|
||||
```
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fission-ai/openspec",
|
||||
"version": "0.2.0",
|
||||
"version": "0.5.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": {
|
||||
".": {
|
||||
@@ -60,6 +59,7 @@
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inquirer/core": "^10.2.2",
|
||||
"@inquirer/prompts": "^7.8.0",
|
||||
"chalk": "^5.5.0",
|
||||
"commander": "^14.0.0",
|
||||
|
||||
Generated
+23
-14
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@inquirer/core':
|
||||
specifier: ^10.2.2
|
||||
version: 10.2.2(@types/node@24.2.0)
|
||||
'@inquirer/prompts':
|
||||
specifier: ^7.8.0
|
||||
version: 7.8.0(@types/node@24.2.0)
|
||||
@@ -257,6 +260,10 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@inquirer/ansi@1.0.0':
|
||||
resolution: {integrity: sha512-JWaTfCxI1eTmJ1BIv86vUfjVatOdxwD0DAVKYevY8SazeUUZtW+tNbsdejVO1GYE0GXJW1N1ahmiC3TFd+7wZA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@inquirer/checkbox@4.2.0':
|
||||
resolution: {integrity: sha512-fdSw07FLJEU5vbpOPzXo5c6xmMGDzbZE2+niuDHX5N6mc6V0Ebso/q3xiHra4D73+PMsC8MJmcaZKuAAoaQsSA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -275,8 +282,8 @@ packages:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/core@10.1.15':
|
||||
resolution: {integrity: sha512-8xrp836RZvKkpNbVvgWUlxjT4CraKk2q+I3Ksy+seI2zkcE+y6wNs1BVhgcv8VyImFecUhdQrYLdW32pAjwBdA==}
|
||||
'@inquirer/core@10.2.2':
|
||||
resolution: {integrity: sha512-yXq/4QUnk4sHMtmbd7irwiepjB8jXU0kkFRL4nr/aDBA2mDz13cMakEWdDwX3eSCTkk03kwcndD1zfRAIlELxA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@types/node': '>=18'
|
||||
@@ -1440,9 +1447,11 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.25.8':
|
||||
optional: true
|
||||
|
||||
'@inquirer/ansi@1.0.0': {}
|
||||
|
||||
'@inquirer/checkbox@4.2.0(@types/node@24.2.0)':
|
||||
dependencies:
|
||||
'@inquirer/core': 10.1.15(@types/node@24.2.0)
|
||||
'@inquirer/core': 10.2.2(@types/node@24.2.0)
|
||||
'@inquirer/figures': 1.0.13
|
||||
'@inquirer/type': 3.0.8(@types/node@24.2.0)
|
||||
ansi-escapes: 4.3.2
|
||||
@@ -1452,16 +1461,16 @@ snapshots:
|
||||
|
||||
'@inquirer/confirm@5.1.14(@types/node@24.2.0)':
|
||||
dependencies:
|
||||
'@inquirer/core': 10.1.15(@types/node@24.2.0)
|
||||
'@inquirer/core': 10.2.2(@types/node@24.2.0)
|
||||
'@inquirer/type': 3.0.8(@types/node@24.2.0)
|
||||
optionalDependencies:
|
||||
'@types/node': 24.2.0
|
||||
|
||||
'@inquirer/core@10.1.15(@types/node@24.2.0)':
|
||||
'@inquirer/core@10.2.2(@types/node@24.2.0)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 1.0.0
|
||||
'@inquirer/figures': 1.0.13
|
||||
'@inquirer/type': 3.0.8(@types/node@24.2.0)
|
||||
ansi-escapes: 4.3.2
|
||||
cli-width: 4.1.0
|
||||
mute-stream: 2.0.0
|
||||
signal-exit: 4.1.0
|
||||
@@ -1472,7 +1481,7 @@ snapshots:
|
||||
|
||||
'@inquirer/editor@4.2.15(@types/node@24.2.0)':
|
||||
dependencies:
|
||||
'@inquirer/core': 10.1.15(@types/node@24.2.0)
|
||||
'@inquirer/core': 10.2.2(@types/node@24.2.0)
|
||||
'@inquirer/type': 3.0.8(@types/node@24.2.0)
|
||||
external-editor: 3.1.0
|
||||
optionalDependencies:
|
||||
@@ -1480,7 +1489,7 @@ snapshots:
|
||||
|
||||
'@inquirer/expand@4.0.17(@types/node@24.2.0)':
|
||||
dependencies:
|
||||
'@inquirer/core': 10.1.15(@types/node@24.2.0)
|
||||
'@inquirer/core': 10.2.2(@types/node@24.2.0)
|
||||
'@inquirer/type': 3.0.8(@types/node@24.2.0)
|
||||
yoctocolors-cjs: 2.1.2
|
||||
optionalDependencies:
|
||||
@@ -1497,21 +1506,21 @@ snapshots:
|
||||
|
||||
'@inquirer/input@4.2.1(@types/node@24.2.0)':
|
||||
dependencies:
|
||||
'@inquirer/core': 10.1.15(@types/node@24.2.0)
|
||||
'@inquirer/core': 10.2.2(@types/node@24.2.0)
|
||||
'@inquirer/type': 3.0.8(@types/node@24.2.0)
|
||||
optionalDependencies:
|
||||
'@types/node': 24.2.0
|
||||
|
||||
'@inquirer/number@3.0.17(@types/node@24.2.0)':
|
||||
dependencies:
|
||||
'@inquirer/core': 10.1.15(@types/node@24.2.0)
|
||||
'@inquirer/core': 10.2.2(@types/node@24.2.0)
|
||||
'@inquirer/type': 3.0.8(@types/node@24.2.0)
|
||||
optionalDependencies:
|
||||
'@types/node': 24.2.0
|
||||
|
||||
'@inquirer/password@4.0.17(@types/node@24.2.0)':
|
||||
dependencies:
|
||||
'@inquirer/core': 10.1.15(@types/node@24.2.0)
|
||||
'@inquirer/core': 10.2.2(@types/node@24.2.0)
|
||||
'@inquirer/type': 3.0.8(@types/node@24.2.0)
|
||||
ansi-escapes: 4.3.2
|
||||
optionalDependencies:
|
||||
@@ -1534,7 +1543,7 @@ snapshots:
|
||||
|
||||
'@inquirer/rawlist@4.1.5(@types/node@24.2.0)':
|
||||
dependencies:
|
||||
'@inquirer/core': 10.1.15(@types/node@24.2.0)
|
||||
'@inquirer/core': 10.2.2(@types/node@24.2.0)
|
||||
'@inquirer/type': 3.0.8(@types/node@24.2.0)
|
||||
yoctocolors-cjs: 2.1.2
|
||||
optionalDependencies:
|
||||
@@ -1542,7 +1551,7 @@ snapshots:
|
||||
|
||||
'@inquirer/search@3.1.0(@types/node@24.2.0)':
|
||||
dependencies:
|
||||
'@inquirer/core': 10.1.15(@types/node@24.2.0)
|
||||
'@inquirer/core': 10.2.2(@types/node@24.2.0)
|
||||
'@inquirer/figures': 1.0.13
|
||||
'@inquirer/type': 3.0.8(@types/node@24.2.0)
|
||||
yoctocolors-cjs: 2.1.2
|
||||
@@ -1551,7 +1560,7 @@ snapshots:
|
||||
|
||||
'@inquirer/select@4.3.1(@types/node@24.2.0)':
|
||||
dependencies:
|
||||
'@inquirer/core': 10.1.15(@types/node@24.2.0)
|
||||
'@inquirer/core': 10.2.2(@types/node@24.2.0)
|
||||
'@inquirer/figures': 1.0.13
|
||||
'@inquirer/type': 3.0.8(@types/node@24.2.0)
|
||||
ansi-escapes: 4.3.2
|
||||
|
||||
+16
-9
@@ -1,17 +1,24 @@
|
||||
export const OPENSPEC_DIR_NAME = 'openspec';
|
||||
|
||||
export interface OpenSpecConfig {
|
||||
aiTools: string[];
|
||||
}
|
||||
|
||||
export const OPENSPEC_MARKERS = {
|
||||
start: '<!-- OPENSPEC:START -->',
|
||||
end: '<!-- OPENSPEC:END -->'
|
||||
};
|
||||
|
||||
export const AI_TOOLS = [
|
||||
{ name: 'Claude Code', value: 'claude', available: true },
|
||||
{ name: 'Cursor', value: 'cursor', available: true },
|
||||
{ name: 'Aider', value: 'aider', available: false },
|
||||
{ name: 'Continue', value: 'continue', available: false }
|
||||
export interface OpenSpecConfig {
|
||||
aiTools: string[];
|
||||
}
|
||||
|
||||
export interface AIToolOption {
|
||||
name: string;
|
||||
value: string;
|
||||
available: boolean;
|
||||
successLabel?: string;
|
||||
}
|
||||
|
||||
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' }
|
||||
];
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import path from 'path';
|
||||
import { ToolConfigurator } from './base.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { TemplateManager } from '../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../config.js';
|
||||
|
||||
export class AgentsStandardConfigurator implements ToolConfigurator {
|
||||
name = 'AGENTS.md standard';
|
||||
configFileName = 'AGENTS.md';
|
||||
isAvailable = true;
|
||||
|
||||
async configure(projectPath: string, _openspecDir: string): Promise<void> {
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
const content = TemplateManager.getAgentsStandardTemplate();
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import { ToolConfigurator } from './base.js';
|
||||
import { ClaudeConfigurator } from './claude.js';
|
||||
import { AgentsStandardConfigurator } from './agents.js';
|
||||
|
||||
export class ToolRegistry {
|
||||
private static tools: Map<string, ToolConfigurator> = new Map();
|
||||
|
||||
static {
|
||||
const claudeConfigurator = new ClaudeConfigurator();
|
||||
const agentsConfigurator = new AgentsStandardConfigurator();
|
||||
// Register with the ID that matches the checkbox value
|
||||
this.tools.set('claude', claudeConfigurator);
|
||||
this.tools.set('agents', agentsConfigurator);
|
||||
}
|
||||
|
||||
static register(tool: ToolConfigurator): void {
|
||||
@@ -25,4 +28,4 @@ export class ToolRegistry {
|
||||
static getAvailable(): ToolConfigurator[] {
|
||||
return this.getAll().filter(tool => tool.isAvailable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+566
-70
@@ -1,73 +1,440 @@
|
||||
import path from 'path';
|
||||
import { select } from '@inquirer/prompts';
|
||||
import {
|
||||
createPrompt,
|
||||
isBackspaceKey,
|
||||
isDownKey,
|
||||
isEnterKey,
|
||||
isSpaceKey,
|
||||
isUpKey,
|
||||
useKeypress,
|
||||
usePagination,
|
||||
useState,
|
||||
} from '@inquirer/core';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
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 } from './config.js';
|
||||
import {
|
||||
OpenSpecConfig,
|
||||
AI_TOOLS,
|
||||
OPENSPEC_DIR_NAME,
|
||||
AIToolOption,
|
||||
} from './config.js';
|
||||
import { PALETTE } from './styles/palette.js';
|
||||
|
||||
const PROGRESS_SPINNER = {
|
||||
interval: 80,
|
||||
frames: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓'],
|
||||
};
|
||||
|
||||
const LETTER_MAP: Record<string, string[]> = {
|
||||
O: [' ████ ', '██ ██', '██ ██', '██ ██', ' ████ '],
|
||||
P: ['█████ ', '██ ██', '█████ ', '██ ', '██ '],
|
||||
E: ['██████', '██ ', '█████ ', '██ ', '██████'],
|
||||
N: ['██ ██', '███ ██', '██ ███', '██ ██', '██ ██'],
|
||||
S: [' █████', '██ ', ' ████ ', ' ██', '█████ '],
|
||||
C: [' █████', '██ ', '██ ', '██ ', ' █████'],
|
||||
' ': [' ', ' ', ' ', ' ', ' '],
|
||||
};
|
||||
|
||||
type ToolLabel = {
|
||||
primary: string;
|
||||
annotation?: string;
|
||||
};
|
||||
|
||||
const sanitizeToolLabel = (raw: string): string =>
|
||||
raw.replace(/✅/gu, '✔').trim();
|
||||
|
||||
const parseToolLabel = (raw: string): ToolLabel => {
|
||||
const sanitized = sanitizeToolLabel(raw);
|
||||
const match = sanitized.match(/^(.*?)\s*\((.+)\)$/u);
|
||||
if (!match) {
|
||||
return { primary: sanitized };
|
||||
}
|
||||
return {
|
||||
primary: match[1].trim(),
|
||||
annotation: match[2].trim(),
|
||||
};
|
||||
};
|
||||
|
||||
type ToolWizardChoice = {
|
||||
value: string;
|
||||
label: ToolLabel;
|
||||
configured: boolean;
|
||||
};
|
||||
|
||||
type ToolWizardConfig = {
|
||||
extendMode: boolean;
|
||||
baseMessage: string;
|
||||
choices: ToolWizardChoice[];
|
||||
initialSelected?: string[];
|
||||
};
|
||||
|
||||
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 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 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);
|
||||
}
|
||||
|
||||
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 (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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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)}`);
|
||||
});
|
||||
}
|
||||
} 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)];
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
);
|
||||
|
||||
type InitCommandOptions = {
|
||||
prompt?: ToolSelectionPrompt;
|
||||
};
|
||||
|
||||
export class InitCommand {
|
||||
private readonly prompt: ToolSelectionPrompt;
|
||||
|
||||
constructor(options: InitCommandOptions = {}) {
|
||||
this.prompt = options.prompt ?? ((config) => toolSelectionWizard(config));
|
||||
}
|
||||
|
||||
async execute(targetPath: string): Promise<void> {
|
||||
const projectPath = path.resolve(targetPath);
|
||||
const openspecDir = OPENSPEC_DIR_NAME;
|
||||
const openspecPath = path.join(projectPath, openspecDir);
|
||||
|
||||
// Validation happens silently in the background
|
||||
await this.validate(projectPath, openspecPath);
|
||||
const extendMode = await this.validate(projectPath, openspecPath);
|
||||
const existingToolStates = await this.getExistingToolStates(projectPath);
|
||||
|
||||
this.renderBanner(extendMode);
|
||||
|
||||
// Get configuration (after validation to avoid prompts if validation fails)
|
||||
const config = await this.getConfiguration();
|
||||
const config = await this.getConfiguration(existingToolStates, extendMode);
|
||||
|
||||
if (config.aiTools.length === 0) {
|
||||
if (extendMode) {
|
||||
throw new Error(
|
||||
`OpenSpec seems to already be initialized at ${openspecPath}.\n` +
|
||||
`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 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]
|
||||
);
|
||||
|
||||
// Step 1: Create directory structure
|
||||
const structureSpinner = ora({ text: 'Creating OpenSpec structure...', stream: process.stdout }).start();
|
||||
await this.createDirectoryStructure(openspecPath);
|
||||
await this.generateFiles(openspecPath, config);
|
||||
structureSpinner.succeed('OpenSpec structure created');
|
||||
|
||||
// Step 2: Configure AI tools
|
||||
const toolSpinner = ora({ text: 'Configuring AI tools...', stream: process.stdout }).start();
|
||||
await this.configureAITools(projectPath, openspecDir, config.aiTools);
|
||||
toolSpinner.succeed('AI tools configured');
|
||||
|
||||
// Success message
|
||||
this.displaySuccessMessage(openspecDir, config);
|
||||
}
|
||||
|
||||
private async validate(projectPath: string, openspecPath: string): Promise<void> {
|
||||
// Check if OpenSpec already exists
|
||||
if (await FileSystemUtils.directoryExists(openspecPath)) {
|
||||
throw new Error(
|
||||
`OpenSpec seems to already be initialized at ${openspecPath}.\n` +
|
||||
`Use 'openspec update' to update the structure.`
|
||||
if (!extendMode) {
|
||||
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'),
|
||||
});
|
||||
} else {
|
||||
ora({ stream: process.stdout }).info(
|
||||
PALETTE.midGray(
|
||||
'ℹ OpenSpec already initialized. Skipping base scaffolding.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check write permissions
|
||||
if (!await FileSystemUtils.ensureWritePermissions(projectPath)) {
|
||||
throw new Error(`Insufficient permissions to write to ${projectPath}`);
|
||||
}
|
||||
// Step 2: Configure AI tools
|
||||
const toolSpinner = this.startSpinner('Configuring AI tools...');
|
||||
await this.configureAITools(projectPath, openspecDir, config.aiTools);
|
||||
toolSpinner.stopAndPersist({
|
||||
symbol: PALETTE.white('▌'),
|
||||
text: PALETTE.white('AI tools configured'),
|
||||
});
|
||||
|
||||
// Success message
|
||||
this.displaySuccessMessage(
|
||||
selectedTools,
|
||||
created,
|
||||
refreshed,
|
||||
skippedExisting,
|
||||
skipped,
|
||||
extendMode
|
||||
);
|
||||
}
|
||||
|
||||
private async getConfiguration(): Promise<OpenSpecConfig> {
|
||||
const config: OpenSpecConfig = {
|
||||
aiTools: []
|
||||
};
|
||||
private async validate(
|
||||
projectPath: string,
|
||||
_openspecPath: string
|
||||
): Promise<boolean> {
|
||||
const extendMode = await FileSystemUtils.directoryExists(_openspecPath);
|
||||
|
||||
// Single-select for better UX
|
||||
const selectedTool = await select({
|
||||
message: 'Which AI tool do you use?',
|
||||
choices: AI_TOOLS.map(tool => ({
|
||||
name: tool.available ? tool.name : `${tool.name} (coming soon)`,
|
||||
// Check write permissions
|
||||
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
|
||||
);
|
||||
return { aiTools: selectedTools };
|
||||
}
|
||||
|
||||
private async promptForAITools(
|
||||
existingTools: Record<string, boolean>,
|
||||
extendMode: boolean
|
||||
): Promise<string[]> {
|
||||
const availableTools = AI_TOOLS.filter((tool) => tool.available);
|
||||
|
||||
if (availableTools.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const baseMessage = extendMode
|
||||
? '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)
|
||||
: [];
|
||||
|
||||
return this.prompt({
|
||||
extendMode,
|
||||
baseMessage,
|
||||
choices: availableTools.map((tool) => ({
|
||||
value: tool.value,
|
||||
disabled: !tool.available
|
||||
}))
|
||||
label: parseToolLabel(tool.name),
|
||||
configured: Boolean(existingTools[tool.value]),
|
||||
})),
|
||||
initialSelected,
|
||||
});
|
||||
|
||||
config.aiTools = [selectedTool as string];
|
||||
}
|
||||
|
||||
return config;
|
||||
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);
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async createDirectoryStructure(openspecPath: string): Promise<void> {
|
||||
@@ -75,7 +442,7 @@ export class InitCommand {
|
||||
openspecPath,
|
||||
path.join(openspecPath, 'specs'),
|
||||
path.join(openspecPath, 'changes'),
|
||||
path.join(openspecPath, 'changes', 'archive')
|
||||
path.join(openspecPath, 'changes', 'archive'),
|
||||
];
|
||||
|
||||
for (const dir of directories) {
|
||||
@@ -83,24 +450,32 @@ export class InitCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private async generateFiles(openspecPath: string, config: OpenSpecConfig): Promise<void> {
|
||||
private async generateFiles(
|
||||
openspecPath: string,
|
||||
config: OpenSpecConfig
|
||||
): Promise<void> {
|
||||
const context: ProjectContext = {
|
||||
// Could be enhanced with prompts for project details
|
||||
};
|
||||
|
||||
const templates = TemplateManager.getTemplates(context);
|
||||
|
||||
|
||||
for (const template of templates) {
|
||||
const filePath = path.join(openspecPath, template.path);
|
||||
const content = typeof template.content === 'function'
|
||||
? template.content(context)
|
||||
: template.content;
|
||||
|
||||
const content =
|
||||
typeof template.content === 'function'
|
||||
? template.content(context)
|
||||
: template.content;
|
||||
|
||||
await FileSystemUtils.writeFile(filePath, content);
|
||||
}
|
||||
}
|
||||
|
||||
private async configureAITools(projectPath: string, openspecDir: string, toolIds: string[]): Promise<void> {
|
||||
private async configureAITools(
|
||||
projectPath: string,
|
||||
openspecDir: string,
|
||||
toolIds: string[]
|
||||
): Promise<void> {
|
||||
for (const toolId of toolIds) {
|
||||
const configurator = ToolRegistry.get(toolId);
|
||||
if (configurator && configurator.isAvailable) {
|
||||
@@ -114,26 +489,147 @@ export class InitCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private displaySuccessMessage(openspecDir: string, config: OpenSpecConfig): void {
|
||||
private displaySuccessMessage(
|
||||
selectedTools: AIToolOption[],
|
||||
created: AIToolOption[],
|
||||
refreshed: AIToolOption[],
|
||||
skippedExisting: AIToolOption[],
|
||||
skipped: AIToolOption[],
|
||||
extendMode: boolean
|
||||
): void {
|
||||
console.log(); // Empty line for spacing
|
||||
ora().succeed('OpenSpec initialized successfully!');
|
||||
|
||||
// Get the selected tool name for display
|
||||
const selectedToolId = config.aiTools[0];
|
||||
const selectedTool = AI_TOOLS.find(t => t.value === selectedToolId);
|
||||
const toolName = selectedTool ? selectedTool.name : 'your AI assistant';
|
||||
|
||||
console.log(`\nNext steps - Copy these prompts to ${toolName}:\n`);
|
||||
console.log('────────────────────────────────────────────────────────────');
|
||||
console.log('1. Populate your project context:');
|
||||
console.log(' "Please read openspec/project.md and help me fill it out');
|
||||
console.log(' with details about my project, tech stack, and conventions"\n');
|
||||
console.log('2. Create your first change proposal:');
|
||||
console.log(' "I want to add [YOUR FEATURE HERE]. Please create an');
|
||||
console.log(' OpenSpec change proposal for this feature"\n');
|
||||
console.log('3. Learn the OpenSpec workflow:');
|
||||
console.log(' "Please explain the OpenSpec workflow from openspec/AGENTS.md');
|
||||
console.log(' and how I should work with you on this project"');
|
||||
console.log('────────────────────────────────────────────────────────────\n');
|
||||
const successHeadline = extendMode
|
||||
? 'OpenSpec tool configuration updated!'
|
||||
: 'OpenSpec initialized successfully!';
|
||||
ora().succeed(PALETTE.white(successHeadline));
|
||||
|
||||
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,
|
||||
].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.'
|
||||
)
|
||||
);
|
||||
|
||||
// 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(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.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.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'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private formatToolNames(tools: AIToolOption[]): string {
|
||||
const names = tools
|
||||
.map((tool) => tool.successLabel ?? tool.name)
|
||||
.filter((name): name is string => Boolean(name));
|
||||
|
||||
if (names.length === 0) return PALETTE.lightGray('your AI assistant');
|
||||
if (names.length === 1) return PALETTE.white(names[0]);
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
private renderBanner(_extendMode: boolean): void {
|
||||
const rows = ['', '', '', '', ''];
|
||||
for (const char of 'OPENSPEC') {
|
||||
const glyph = LETTER_MAP[char] ?? LETTER_MAP[' '];
|
||||
for (let i = 0; i < rows.length; i += 1) {
|
||||
rows[i] += `${glyph[i]} `;
|
||||
}
|
||||
}
|
||||
|
||||
const rowStyles = [
|
||||
PALETTE.white,
|
||||
PALETTE.lightGray,
|
||||
PALETTE.midGray,
|
||||
PALETTE.lightGray,
|
||||
PALETTE.white,
|
||||
];
|
||||
|
||||
console.log();
|
||||
rows.forEach((row, index) => {
|
||||
console.log(rowStyles[index](row.replace(/\s+$/u, '')));
|
||||
});
|
||||
console.log();
|
||||
console.log(PALETTE.white('Welcome to OpenSpec!'));
|
||||
console.log();
|
||||
}
|
||||
|
||||
private startSpinner(text: string) {
|
||||
return ora({
|
||||
text,
|
||||
stream: process.stdout,
|
||||
color: 'gray',
|
||||
spinner: PROGRESS_SPINNER,
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -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*$/);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
export const PALETTE = {
|
||||
white: chalk.hex('#f4f4f4'),
|
||||
lightGray: chalk.hex('#c8c8c8'),
|
||||
midGray: chalk.hex('#8a8a8a'),
|
||||
darkGray: chalk.hex('#4a4a4a')
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
export const agentsRootStubTemplate = `# OpenSpec Instructions
|
||||
|
||||
These instructions are for AI assistants working in this project.
|
||||
|
||||
Always open \`@/openspec/AGENTS.md\` when the request:
|
||||
- Mentions planning or proposals (words like proposal, spec, change, plan)
|
||||
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
|
||||
- Sounds ambiguous and you need the authoritative spec before coding
|
||||
|
||||
Use \`@/openspec/AGENTS.md\` to learn:
|
||||
- How to create and apply change proposals
|
||||
- Spec format and conventions
|
||||
- Project structure and guidelines
|
||||
|
||||
Keep this managed block so 'openspec update' can refresh the instructions.
|
||||
`;
|
||||
@@ -47,18 +47,20 @@ Skip proposal for:
|
||||
4. Run \`openspec validate <id> --strict\` and resolve any issues before sharing the proposal.
|
||||
|
||||
### Stage 2: Implementing Changes
|
||||
Track these steps as TODOs and complete them one by one.
|
||||
1. **Read proposal.md** - Understand what's being built
|
||||
2. **Read design.md** (if exists) - Review technical decisions
|
||||
3. **Read tasks.md** - Get implementation checklist
|
||||
4. **Implement tasks sequentially** - Complete in order
|
||||
5. **Mark complete immediately** - Update \`- [x]\` after each task
|
||||
6. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
|
||||
5. **Confirm completion** - Ensure every item in \`tasks.md\` is finished before updating statuses
|
||||
6. **Update checklist** - After all work is done, set every task to \`- [x]\` so the list reflects reality
|
||||
7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
|
||||
|
||||
### Stage 3: Archiving Changes
|
||||
After deployment, create separate PR to:
|
||||
- Move \`changes/[name]/\` → \`changes/archive/YYYY-MM-DD-[name]/\`
|
||||
- Update \`specs/\` if capabilities changed
|
||||
- Use \`openspec archive [change] --skip-specs\` for tooling-only changes
|
||||
- Use \`openspec archive [change] --skip-specs --yes\` for tooling-only changes
|
||||
- Run \`openspec validate --strict\` to confirm the archived change passes checks
|
||||
|
||||
## Before Any Task
|
||||
@@ -95,7 +97,7 @@ openspec list --specs # List specifications
|
||||
openspec show [item] # Display change or spec
|
||||
openspec diff [change] # Show spec differences
|
||||
openspec validate [item] # Validate changes or specs
|
||||
openspec archive [change] # Archive after deployment
|
||||
openspec archive [change] [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
|
||||
|
||||
# Project management
|
||||
openspec init [path] # Initialize OpenSpec
|
||||
@@ -117,6 +119,7 @@ openspec validate [change] --strict
|
||||
- \`--strict\` - Comprehensive validation
|
||||
- \`--no-interactive\` - Disable prompts
|
||||
- \`--skip-specs\` - Archive without spec updates
|
||||
- \`--yes\`/\`-y\` - Skip confirmation prompts (non-interactive archive)
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -447,7 +450,7 @@ openspec list # What's in progress?
|
||||
openspec show [item] # View details
|
||||
openspec diff [change] # What's changing?
|
||||
openspec validate --strict # Is it correct?
|
||||
openspec archive [change] # Mark complete
|
||||
openspec archive [change] [--yes|-y] # Mark complete (add --yes for automation)
|
||||
\`\`\`
|
||||
|
||||
Remember: Specs are truth. Changes are proposals. Keep them in sync.
|
||||
|
||||
@@ -1,105 +1 @@
|
||||
export const claudeTemplate = `# OpenSpec Project
|
||||
|
||||
This project uses OpenSpec for spec-driven development. Specifications are the source of truth.
|
||||
|
||||
See @openspec/AGENTS.md for detailed conventions and guidelines.
|
||||
|
||||
## Three-Stage Workflow
|
||||
|
||||
### Stage 1: Creating Changes
|
||||
Create proposal for: features, breaking changes, architecture changes
|
||||
Skip proposal for: bug fixes, typos, non-breaking updates
|
||||
|
||||
### Stage 2: Implementing Changes
|
||||
1. Read proposal.md to understand the change
|
||||
2. Read design.md if it exists for technical context
|
||||
3. Read tasks.md for implementation checklist
|
||||
4. Complete tasks one by one
|
||||
5. Mark each task complete immediately: \`- [x]\`
|
||||
6. Validate strictly: \`openspec validate [change] --strict\`
|
||||
7. Approval gate: Do not start implementation until the proposal is approved
|
||||
|
||||
### Stage 3: Archiving
|
||||
After deployment, use \`openspec archive [change]\` (add \`--skip-specs\` for tooling-only changes)
|
||||
|
||||
## Before Any Task
|
||||
|
||||
**Always:**
|
||||
- Check existing specs: \`openspec list --specs\`
|
||||
- Check active changes: \`openspec list\`
|
||||
- Read relevant specs before creating new ones
|
||||
- Prefer modifying existing specs over creating duplicates
|
||||
|
||||
## CLI Quick Reference
|
||||
|
||||
\`\`\`bash
|
||||
# Essential
|
||||
openspec list # Active changes
|
||||
openspec list --specs # Existing specifications
|
||||
openspec show [item] # View details
|
||||
openspec validate --strict # Validate thoroughly
|
||||
openspec archive [change] # Archive after deployment
|
||||
|
||||
# Interactive
|
||||
openspec show # Prompts for selection
|
||||
openspec validate # Bulk validation
|
||||
|
||||
# Debugging
|
||||
openspec show [change] --json --deltas-only
|
||||
\`\`\`
|
||||
|
||||
## Creating Changes
|
||||
|
||||
1. **Directory:** \`changes/[change-id]/\`
|
||||
- Change ID naming: kebab-case, verb-led (\`add-\`, \`update-\`, \`remove-\`, \`refactor-\`), unique (append \`-2\`, \`-3\` if needed)
|
||||
2. **Files:**
|
||||
- \`proposal.md\` - Why, what, impact
|
||||
- \`tasks.md\` - Implementation checklist
|
||||
- \`design.md\` - Only if needed (cross-cutting, new deps/data model, security/perf/migration complexity, or high ambiguity)
|
||||
- \`specs/[capability]/spec.md\` - Delta changes (ADDED/MODIFIED/REMOVED). For multiple capabilities, include multiple files.
|
||||
3. **If ambiguous:** ask 1–2 clarifying questions before scaffolding
|
||||
|
||||
## Search Guidance
|
||||
- Enumerate specs: \`openspec spec list --long\` (or \`--json\`)
|
||||
- Enumerate changes: \`openspec list\`
|
||||
- Show details: \`openspec show <spec-id> --type spec\`, \`openspec show <change-id> --json --deltas-only\`
|
||||
- Full-text search (use ripgrep): \`rg -n "Requirement:|Scenario:" openspec/specs\`
|
||||
|
||||
## Critical: Scenario Format
|
||||
|
||||
**CORRECT:**
|
||||
\`\`\`markdown
|
||||
#### Scenario: User login
|
||||
- **WHEN** valid credentials
|
||||
- **THEN** return token
|
||||
\`\`\`
|
||||
|
||||
**WRONG:** Using bullets (- **Scenario**), bold (**Scenario:**), or ### headers
|
||||
|
||||
Every requirement MUST have scenarios using \`#### Scenario:\` format.
|
||||
|
||||
## Complexity Management
|
||||
|
||||
**Default to minimal:**
|
||||
- <100 lines of new code
|
||||
- Single-file implementations
|
||||
- No frameworks without justification
|
||||
- Boring, proven patterns
|
||||
|
||||
**Only add complexity with:**
|
||||
- Performance data showing need
|
||||
- Concrete scale requirements (>1000 users)
|
||||
- Multiple proven use cases
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Change must have at least one delta"**
|
||||
- Check \`changes/[name]/specs/\` exists
|
||||
- Verify operation prefixes (## ADDED Requirements)
|
||||
|
||||
**"Requirement must have at least one scenario"**
|
||||
- Use \`#### Scenario:\` format (4 hashtags)
|
||||
- Don't use bullets or bold
|
||||
|
||||
**Debug:** \`openspec show [change] --json --deltas-only\`
|
||||
`;
|
||||
export { agentsRootStubTemplate as claudeTemplate } from './agents-root-stub.js';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { agentsTemplate } from './agents-template.js';
|
||||
import { projectTemplate, ProjectContext } from './project-template.js';
|
||||
import { claudeTemplate } from './claude-template.js';
|
||||
import { agentsRootStubTemplate } from './agents-root-stub.js';
|
||||
import { getSlashCommandBody, SlashCommandId } from './slash-command-templates.js';
|
||||
|
||||
export interface Template {
|
||||
@@ -26,6 +27,10 @@ export class TemplateManager {
|
||||
return claudeTemplate;
|
||||
}
|
||||
|
||||
static getAgentsStandardTemplate(): string {
|
||||
return agentsRootStubTemplate;
|
||||
}
|
||||
|
||||
static getSlashCommandBody(id: SlashCommandId): string {
|
||||
return getSlashCommandBody(id);
|
||||
}
|
||||
|
||||
@@ -22,17 +22,19 @@ const proposalReferences = `**Reference**
|
||||
- Explore the codebase with \`rg <keyword>\`, \`ls\`, or direct file reads so proposals align with current implementation realities.`;
|
||||
|
||||
const applySteps = `**Steps**
|
||||
Track these steps as TODOs and complete them one by one.
|
||||
1. Read \`changes/<id>/proposal.md\`, \`design.md\` (if present), and \`tasks.md\` to confirm scope and acceptance criteria.
|
||||
2. Work through tasks sequentially, keeping edits minimal and focused on the requested change.
|
||||
3. Mark each task \`- [x]\` immediately after completing it to keep the checklist in sync.
|
||||
4. Reference \`openspec list\` or \`openspec show <item>\` when additional context is required.`;
|
||||
3. Confirm completion before updating statuses—make sure every item in \`tasks.md\` is finished.
|
||||
4. Update the checklist after all work is done so each task is marked \`- [x]\` and reflects reality.
|
||||
5. Reference \`openspec list\` or \`openspec show <item>\` when additional context is required.`;
|
||||
|
||||
const applyReferences = `**Reference**
|
||||
- Use \`openspec show <id> --json --deltas-only\` if you need additional context from the proposal while implementing.`;
|
||||
|
||||
const archiveSteps = `**Steps**
|
||||
1. Identify the requested change ID (via the prompt or \`openspec list\`).
|
||||
2. Run \`openspec archive <id>\` to let the CLI move the change and apply spec updates (use \`--skip-specs\` only for tooling-only work).
|
||||
2. Run \`openspec archive <id> --yes\` to let the CLI move the change and apply spec updates without prompts (use \`--skip-specs\` only for tooling-only work).
|
||||
3. Review the command output to confirm the target specs were updated and the change landed in \`changes/archive/\`.
|
||||
4. Validate with \`openspec validate --strict\` and inspect with \`openspec show <id>\` if anything looks off.`;
|
||||
|
||||
|
||||
+77
-36
@@ -1,9 +1,9 @@
|
||||
import path from 'path';
|
||||
import { FileSystemUtils } from '../utils/file-system.js';
|
||||
import { OPENSPEC_DIR_NAME } from './config.js';
|
||||
import { agentsTemplate } from './templates/agents-template.js';
|
||||
import { ToolRegistry } from './configurators/registry.js';
|
||||
import { SlashCommandRegistry } from './configurators/slash/registry.js';
|
||||
import { agentsTemplate } from './templates/agents-template.js';
|
||||
|
||||
export class UpdateCommand {
|
||||
async execute(projectPath: string): Promise<void> {
|
||||
@@ -18,31 +18,51 @@ export class UpdateCommand {
|
||||
|
||||
// 2. Update AGENTS.md (full replacement)
|
||||
const agentsPath = path.join(openspecPath, 'AGENTS.md');
|
||||
|
||||
await FileSystemUtils.writeFile(agentsPath, agentsTemplate);
|
||||
|
||||
// 3. Update existing AI tool configuration files only
|
||||
const configurators = ToolRegistry.getAll();
|
||||
const slashConfigurators = SlashCommandRegistry.getAll();
|
||||
let updatedFiles: string[] = [];
|
||||
let failedFiles: string[] = [];
|
||||
let updatedSlashFiles: string[] = [];
|
||||
let failedSlashTools: string[] = [];
|
||||
|
||||
const updatedFiles: string[] = [];
|
||||
const createdFiles: string[] = [];
|
||||
const failedFiles: string[] = [];
|
||||
const updatedSlashFiles: string[] = [];
|
||||
const failedSlashTools: string[] = [];
|
||||
|
||||
for (const configurator of configurators) {
|
||||
const configFilePath = path.join(resolvedProjectPath, configurator.configFileName);
|
||||
|
||||
// Only update if the file already exists
|
||||
if (await FileSystemUtils.fileExists(configFilePath)) {
|
||||
try {
|
||||
if (!await FileSystemUtils.canWriteFile(configFilePath)) {
|
||||
throw new Error(`Insufficient permissions to modify ${configurator.configFileName}`);
|
||||
}
|
||||
await configurator.configure(resolvedProjectPath, openspecPath);
|
||||
updatedFiles.push(configurator.configFileName);
|
||||
} catch (error) {
|
||||
failedFiles.push(configurator.configFileName);
|
||||
console.error(`Failed to update ${configurator.configFileName}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
const configFilePath = path.join(
|
||||
resolvedProjectPath,
|
||||
configurator.configFileName
|
||||
);
|
||||
const fileExists = await FileSystemUtils.fileExists(configFilePath);
|
||||
const shouldConfigure =
|
||||
fileExists || configurator.configFileName === 'AGENTS.md';
|
||||
|
||||
if (!shouldConfigure) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (fileExists && !await FileSystemUtils.canWriteFile(configFilePath)) {
|
||||
throw new Error(
|
||||
`Insufficient permissions to modify ${configurator.configFileName}`
|
||||
);
|
||||
}
|
||||
|
||||
await configurator.configure(resolvedProjectPath, openspecPath);
|
||||
updatedFiles.push(configurator.configFileName);
|
||||
|
||||
if (!fileExists) {
|
||||
createdFiles.push(configurator.configFileName);
|
||||
}
|
||||
} catch (error) {
|
||||
failedFiles.push(configurator.configFileName);
|
||||
console.error(
|
||||
`Failed to update ${configurator.configFileName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,35 +72,56 @@ export class UpdateCommand {
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await slashConfigurator.updateExisting(resolvedProjectPath, openspecPath);
|
||||
updatedSlashFiles = updatedSlashFiles.concat(updated);
|
||||
const updated = await slashConfigurator.updateExisting(
|
||||
resolvedProjectPath,
|
||||
openspecPath
|
||||
);
|
||||
updatedSlashFiles.push(...updated);
|
||||
} catch (error) {
|
||||
failedSlashTools.push(slashConfigurator.toolId);
|
||||
console.error(
|
||||
`Failed to update slash commands for ${slashConfigurator.toolId}: ${error instanceof Error ? error.message : String(error)}`
|
||||
`Failed to update slash commands for ${slashConfigurator.toolId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Success message (ASCII-safe)
|
||||
const messages: string[] = ['Updated OpenSpec instructions (AGENTS.md)'];
|
||||
|
||||
if (updatedFiles.length > 0) {
|
||||
messages.push(`Updated AI tool files: ${updatedFiles.join(', ')}`);
|
||||
const summaryParts: string[] = [];
|
||||
const instructionFiles: string[] = ['openspec/AGENTS.md'];
|
||||
|
||||
if (updatedFiles.includes('AGENTS.md')) {
|
||||
instructionFiles.push(
|
||||
createdFiles.includes('AGENTS.md') ? 'AGENTS.md (created)' : 'AGENTS.md'
|
||||
);
|
||||
}
|
||||
|
||||
summaryParts.push(
|
||||
`Updated OpenSpec instructions (${instructionFiles.join(', ')})`
|
||||
);
|
||||
|
||||
const aiToolFiles = updatedFiles.filter((file) => file !== 'AGENTS.md');
|
||||
if (aiToolFiles.length > 0) {
|
||||
summaryParts.push(`Updated AI tool files: ${aiToolFiles.join(', ')}`);
|
||||
}
|
||||
|
||||
if (updatedSlashFiles.length > 0) {
|
||||
messages.push(`Updated slash commands: ${updatedSlashFiles.join(', ')}`);
|
||||
}
|
||||
|
||||
if (failedFiles.length > 0) {
|
||||
messages.push(`Failed to update: ${failedFiles.join(', ')}`);
|
||||
summaryParts.push(
|
||||
`Updated slash commands: ${updatedSlashFiles.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (failedSlashTools.length > 0) {
|
||||
messages.push(`Failed slash command updates: ${failedSlashTools.join(', ')}`);
|
||||
const failedItems = [
|
||||
...failedFiles,
|
||||
...failedSlashTools.map(
|
||||
(toolId) => `slash command refresh (${toolId})`
|
||||
),
|
||||
];
|
||||
|
||||
if (failedItems.length > 0) {
|
||||
summaryParts.push(`Failed to update: ${failedItems.join(', ')}`);
|
||||
}
|
||||
|
||||
console.log(messages.join('\n'));
|
||||
|
||||
console.log(summaryParts.join(' | '));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,46 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
function isMarkerOnOwnLine(content: string, markerIndex: number, markerLength: number): boolean {
|
||||
let leftIndex = markerIndex - 1;
|
||||
while (leftIndex >= 0 && content[leftIndex] !== '\n') {
|
||||
const char = content[leftIndex];
|
||||
if (char !== ' ' && char !== '\t' && char !== '\r') {
|
||||
return false;
|
||||
}
|
||||
leftIndex--;
|
||||
}
|
||||
|
||||
let rightIndex = markerIndex + markerLength;
|
||||
while (rightIndex < content.length && content[rightIndex] !== '\n') {
|
||||
const char = content[rightIndex];
|
||||
if (char !== ' ' && char !== '\t' && char !== '\r') {
|
||||
return false;
|
||||
}
|
||||
rightIndex++;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function findMarkerIndex(
|
||||
content: string,
|
||||
marker: string,
|
||||
fromIndex = 0
|
||||
): number {
|
||||
let currentIndex = content.indexOf(marker, fromIndex);
|
||||
|
||||
while (currentIndex !== -1) {
|
||||
if (isMarkerOnOwnLine(content, currentIndex, marker.length)) {
|
||||
return currentIndex;
|
||||
}
|
||||
|
||||
currentIndex = content.indexOf(marker, currentIndex + marker.length);
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
export class FileSystemUtils {
|
||||
static async createDirectory(dirPath: string): Promise<void> {
|
||||
await fs.mkdir(dirPath, { recursive: true });
|
||||
@@ -70,10 +110,18 @@ export class FileSystemUtils {
|
||||
if (await this.fileExists(filePath)) {
|
||||
existingContent = await this.readFile(filePath);
|
||||
|
||||
const startIndex = existingContent.indexOf(startMarker);
|
||||
const endIndex = existingContent.indexOf(endMarker);
|
||||
|
||||
const startIndex = findMarkerIndex(existingContent, startMarker);
|
||||
const endIndex = startIndex !== -1
|
||||
? findMarkerIndex(existingContent, endMarker, startIndex + startMarker.length)
|
||||
: findMarkerIndex(existingContent, endMarker);
|
||||
|
||||
if (startIndex !== -1 && endIndex !== -1) {
|
||||
if (endIndex < startIndex) {
|
||||
throw new Error(
|
||||
`Invalid marker state in ${filePath}. End marker appears before start marker.`
|
||||
);
|
||||
}
|
||||
|
||||
const before = existingContent.substring(0, startIndex);
|
||||
const after = existingContent.substring(endIndex + endMarker.length);
|
||||
existingContent = before + startMarker + '\n' + content + '\n' + endMarker + after;
|
||||
@@ -109,4 +157,4 @@ export class FileSystemUtils {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
+229
-64
@@ -3,11 +3,35 @@ import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { InitCommand } from '../../src/core/init.js';
|
||||
import * as prompts from '@inquirer/prompts';
|
||||
|
||||
vi.mock('@inquirer/prompts', () => ({
|
||||
select: vi.fn()
|
||||
}));
|
||||
const DONE = '__done__';
|
||||
|
||||
type SelectionQueue = string[][];
|
||||
|
||||
let selectionQueue: SelectionQueue = [];
|
||||
|
||||
const mockPrompt = vi.fn(async () => {
|
||||
if (selectionQueue.length === 0) {
|
||||
throw new Error('No queued selections provided to init prompt.');
|
||||
}
|
||||
return selectionQueue.shift() ?? [];
|
||||
});
|
||||
|
||||
function queueSelections(...values: string[]) {
|
||||
let current: string[] = [];
|
||||
values.forEach((value) => {
|
||||
if (value === DONE) {
|
||||
selectionQueue.push(current);
|
||||
current = [];
|
||||
} else {
|
||||
current.push(value);
|
||||
}
|
||||
});
|
||||
|
||||
if (current.length > 0) {
|
||||
selectionQueue.push(current);
|
||||
}
|
||||
}
|
||||
|
||||
describe('InitCommand', () => {
|
||||
let testDir: string;
|
||||
@@ -16,8 +40,10 @@ describe('InitCommand', () => {
|
||||
beforeEach(async () => {
|
||||
testDir = path.join(os.tmpdir(), `openspec-init-test-${Date.now()}`);
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
initCommand = new InitCommand();
|
||||
|
||||
selectionQueue = [];
|
||||
mockPrompt.mockReset();
|
||||
initCommand = new InitCommand({ prompt: mockPrompt });
|
||||
|
||||
// Mock console.log to suppress output during tests
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
@@ -29,71 +55,115 @@ describe('InitCommand', () => {
|
||||
|
||||
describe('execute', () => {
|
||||
it('should create OpenSpec directory structure', async () => {
|
||||
vi.mocked(prompts.select).mockResolvedValue('claude');
|
||||
|
||||
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 () => {
|
||||
vi.mocked(prompts.select).mockResolvedValue('claude');
|
||||
queueSelections('claude', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
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 () => {
|
||||
vi.mocked(prompts.select).mockResolvedValue('claude');
|
||||
|
||||
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 Project');
|
||||
expect(content).toContain("@/openspec/AGENTS.md");
|
||||
expect(content).toContain('openspec update');
|
||||
expect(content).toContain('<!-- OPENSPEC:END -->');
|
||||
});
|
||||
|
||||
it('should update existing CLAUDE.md with markers', async () => {
|
||||
vi.mocked(prompts.select).mockResolvedValue('claude');
|
||||
|
||||
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 Project');
|
||||
expect(updatedContent).toContain("@/openspec/AGENTS.md");
|
||||
expect(updatedContent).toContain('openspec update');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).toContain('Custom instructions here');
|
||||
});
|
||||
|
||||
it('should create Claude slash command files with templates', async () => {
|
||||
vi.mocked(prompts.select).mockResolvedValue('claude');
|
||||
it('should create AGENTS.md in project root when AGENTS standard is selected', async () => {
|
||||
queueSelections('agents', DONE);
|
||||
|
||||
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 rootAgentsPath = path.join(testDir, 'AGENTS.md');
|
||||
expect(await fileExists(rootAgentsPath)).toBe(true);
|
||||
|
||||
const content = await fs.readFile(rootAgentsPath, 'utf-8');
|
||||
expect(content).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(content).toContain("@/openspec/AGENTS.md");
|
||||
expect(content).toContain('openspec update');
|
||||
expect(content).toContain('<!-- OPENSPEC:END -->');
|
||||
|
||||
const claudeExists = await fileExists(path.join(testDir, 'CLAUDE.md'));
|
||||
expect(claudeExists).toBe(false);
|
||||
});
|
||||
|
||||
it('should create Claude slash command files with templates', async () => {
|
||||
queueSelections('claude', DONE);
|
||||
|
||||
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'
|
||||
);
|
||||
|
||||
expect(await fileExists(claudeProposal)).toBe(true);
|
||||
expect(await fileExists(claudeApply)).toBe(true);
|
||||
@@ -111,17 +181,28 @@ 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 () => {
|
||||
vi.mocked(prompts.select).mockResolvedValue('cursor');
|
||||
queueSelections('cursor', DONE);
|
||||
|
||||
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);
|
||||
@@ -140,60 +221,138 @@ describe('InitCommand', () => {
|
||||
expect(archiveContent).toContain('openspec list --specs');
|
||||
});
|
||||
|
||||
it('should throw error if OpenSpec already exists', async () => {
|
||||
const openspecPath = path.join(testDir, 'openspec');
|
||||
await fs.mkdir(openspecPath, { recursive: true });
|
||||
|
||||
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'
|
||||
);
|
||||
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/
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle non-existent target directory', async () => {
|
||||
vi.mocked(prompts.select).mockResolvedValue('claude');
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('should display success message with selected tool name', async () => {
|
||||
vi.mocked(prompts.select).mockResolvedValue('claude');
|
||||
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');
|
||||
});
|
||||
|
||||
it('should reference AGENTS compatible assistants in success message', async () => {
|
||||
queueSelections('agents', 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 your AGENTS.md-compatible assistant'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI tool selection', () => {
|
||||
it('should prompt for AI tool selection', async () => {
|
||||
const selectMock = vi.mocked(prompts.select);
|
||||
selectMock.mockResolvedValue('claude');
|
||||
|
||||
queueSelections('claude', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
expect(selectMock).toHaveBeenCalledWith(
|
||||
|
||||
expect(mockPrompt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Which AI tool do you use?'
|
||||
baseMessage: expect.stringContaining('Which AI tools do you use?'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle different AI tool selections', async () => {
|
||||
// For now, only Claude is available, but test the structure
|
||||
vi.mocked(prompts.select).mockResolvedValue('claude');
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('should mark existing tools as already configured during extend mode', async () => {
|
||||
queueSelections('claude', DONE, 'cursor', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const secondRunArgs = mockPrompt.mock.calls[1][0];
|
||||
const claudeChoice = secondRunArgs.choices.find(
|
||||
(choice: any) => choice.value === 'claude'
|
||||
);
|
||||
expect(claudeChoice.configured).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
@@ -201,16 +360,22 @@ 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/
|
||||
);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
+168
-36
@@ -5,6 +5,7 @@ import { ToolRegistry } from '../../src/core/configurators/registry.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
describe('UpdateCommand', () => {
|
||||
let testDir: string;
|
||||
@@ -12,13 +13,13 @@ describe('UpdateCommand', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a temporary test directory
|
||||
testDir = path.join(os.tmpdir(), `openspec-test-${Date.now()}`);
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -42,7 +43,7 @@ More content after.`;
|
||||
await fs.writeFile(claudePath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
|
||||
// Execute update command
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
@@ -50,19 +51,26 @@ More content after.`;
|
||||
const updatedContent = await fs.readFile(claudePath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).toContain('This project uses OpenSpec');
|
||||
expect(updatedContent).toContain("@/openspec/AGENTS.md");
|
||||
expect(updatedContent).toContain('openspec update');
|
||||
expect(updatedContent).toContain('Some existing content here');
|
||||
expect(updatedContent).toContain('More content after');
|
||||
|
||||
|
||||
// Check console output
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Updated OpenSpec instructions (AGENTS.md)\nUpdated AI tool files: CLAUDE.md'
|
||||
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 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
|
||||
@@ -82,11 +90,18 @@ 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');
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Updated OpenSpec instructions (AGENTS.md)\nUpdated slash commands: .claude/commands/openspec/proposal.md'
|
||||
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: .claude/commands/openspec/proposal.md'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
@@ -95,7 +110,7 @@ Old slash content
|
||||
it('should not create CLAUDE.md if it does not exist', async () => {
|
||||
// Ensure CLAUDE.md does not exist
|
||||
const claudePath = path.join(testDir, 'CLAUDE.md');
|
||||
|
||||
|
||||
// Execute update command
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
@@ -127,8 +142,51 @@ Old body
|
||||
expect(updated).toContain('Work through tasks sequentially');
|
||||
expect(updated).not.toContain('Old body');
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Updated OpenSpec instructions (AGENTS.md)\nUpdated slash commands: .cursor/commands/openspec-apply.md'
|
||||
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: .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();
|
||||
@@ -140,7 +198,11 @@ Old body
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
// Should only update OpenSpec instructions
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Updated OpenSpec instructions (AGENTS.md)');
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -151,22 +213,33 @@ Old body
|
||||
// For now, we test with just CLAUDE.md.
|
||||
const claudePath = path.join(testDir, 'CLAUDE.md');
|
||||
await fs.mkdir(path.dirname(claudePath), { recursive: true });
|
||||
await fs.writeFile(claudePath, '<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->');
|
||||
await fs.writeFile(
|
||||
claudePath,
|
||||
'<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->'
|
||||
);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
// Should report updating with new format
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Updated OpenSpec instructions (AGENTS.md)\nUpdated AI tool files: CLAUDE.md'
|
||||
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 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
|
||||
@@ -174,12 +247,17 @@ tags: [openspec, change]
|
||||
---
|
||||
<!-- OPENSPEC:START -->
|
||||
Old content
|
||||
<!-- OPENSPEC:END -->`);
|
||||
<!-- OPENSPEC:END -->`
|
||||
);
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const applyExists = await FileSystemUtils.fileExists(path.join(testDir, '.claude/commands/openspec/apply.md'));
|
||||
const archiveExists = await FileSystemUtils.fileExists(path.join(testDir, '.claude/commands/openspec/archive.md'));
|
||||
const applyExists = await FileSystemUtils.fileExists(
|
||||
path.join(testDir, '.claude/commands/openspec/apply.md')
|
||||
);
|
||||
const archiveExists = await FileSystemUtils.fileExists(
|
||||
path.join(testDir, '.claude/commands/openspec/archive.md')
|
||||
);
|
||||
|
||||
expect(applyExists).toBe(false);
|
||||
expect(archiveExists).toBe(false);
|
||||
@@ -188,7 +266,7 @@ Old content
|
||||
it('should never create new AI tool files', async () => {
|
||||
// Get all configurators
|
||||
const configurators = ToolRegistry.getAll();
|
||||
|
||||
|
||||
// Execute update command
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
@@ -196,7 +274,11 @@ Old content
|
||||
for (const configurator of configurators) {
|
||||
const configPath = path.join(testDir, configurator.configFileName);
|
||||
const fileExists = await FileSystemUtils.fileExists(configPath);
|
||||
expect(fileExists).toBe(false);
|
||||
if (configurator.configFileName === 'AGENTS.md') {
|
||||
expect(fileExists).toBe(true);
|
||||
} else {
|
||||
expect(fileExists).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -213,9 +295,51 @@ Old content
|
||||
expect(content).toContain('# OpenSpec Instructions');
|
||||
});
|
||||
|
||||
it('should create root AGENTS.md with managed block when missing', async () => {
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const rootAgentsPath = path.join(testDir, 'AGENTS.md');
|
||||
const exists = await FileSystemUtils.fileExists(rootAgentsPath);
|
||||
expect(exists).toBe(true);
|
||||
|
||||
const content = await fs.readFile(rootAgentsPath, 'utf-8');
|
||||
expect(content).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(content).toContain("@/openspec/AGENTS.md");
|
||||
expect(content).toContain('openspec update');
|
||||
expect(content).toContain('<!-- OPENSPEC:END -->');
|
||||
});
|
||||
|
||||
it('should refresh root AGENTS.md while preserving surrounding content', async () => {
|
||||
const rootAgentsPath = path.join(testDir, 'AGENTS.md');
|
||||
const original = `# Custom intro\n\n<!-- OPENSPEC:START -->\nOld content\n<!-- OPENSPEC:END -->\n\n# Footnotes`;
|
||||
await fs.writeFile(rootAgentsPath, original);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updated = await fs.readFile(rootAgentsPath, 'utf-8');
|
||||
expect(updated).toContain('# Custom intro');
|
||||
expect(updated).toContain('# Footnotes');
|
||||
expect(updated).toContain("@/openspec/AGENTS.md");
|
||||
expect(updated).toContain('openspec update');
|
||||
expect(updated).not.toContain('Old content');
|
||||
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md, AGENTS.md)'
|
||||
);
|
||||
expect(logMessage).not.toContain('AGENTS.md (created)');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
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(
|
||||
@@ -226,28 +350,36 @@ 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);
|
||||
|
||||
// Should report the failure
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'Updated OpenSpec instructions (AGENTS.md)\nFailed to update: CLAUDE.md'
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain('Failed to update: CLAUDE.md');
|
||||
|
||||
// Restore permissions for cleanup
|
||||
await fs.chmod(claudePath, 0o644);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -2,13 +2,14 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { FileSystemUtils } from '../../src/utils/file-system.js';
|
||||
|
||||
describe('FileSystemUtils', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = path.join(os.tmpdir(), `openspec-test-${Date.now()}`);
|
||||
testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
@@ -159,4 +160,4 @@ describe('FileSystemUtils', () => {
|
||||
expect(hasPermission).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -248,5 +248,40 @@ Line 5 with gap`;
|
||||
const result = await fs.readFile(filePath, 'utf-8');
|
||||
expect(result).toContain(content);
|
||||
});
|
||||
|
||||
it('should ignore inline mentions of markers when updating content', async () => {
|
||||
const filePath = path.join(testDir, 'inline-mentions.md');
|
||||
const existingFile = `Intro referencing markers like ${START_MARKER} and ${END_MARKER} inside text.
|
||||
|
||||
${START_MARKER}
|
||||
Original content
|
||||
${END_MARKER}
|
||||
`;
|
||||
|
||||
await fs.writeFile(filePath, existingFile);
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
'Updated content',
|
||||
START_MARKER,
|
||||
END_MARKER
|
||||
);
|
||||
|
||||
const firstResult = await fs.readFile(filePath, 'utf-8');
|
||||
expect(firstResult).toContain('Intro referencing markers like');
|
||||
expect(firstResult).toContain('Updated content');
|
||||
expect(firstResult.match(new RegExp(START_MARKER, 'g'))?.length).toBe(2);
|
||||
expect(firstResult.match(new RegExp(END_MARKER, 'g'))?.length).toBe(2);
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
'Updated content',
|
||||
START_MARKER,
|
||||
END_MARKER
|
||||
);
|
||||
|
||||
const secondResult = await fs.readFile(filePath, 'utf-8');
|
||||
expect(secondResult).toBe(firstResult);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+4
-19
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user