Compare commits

..
Author SHA1 Message Date
Tabish Bidiwale acdce5b2ba fix: validate change name format to prevent path traversal
Add validation in validateChangeExists() to ensure --change parameter
is a valid kebab-case ID before constructing file paths. This prevents
path traversal attacks like --change "../foo" or --change "/etc/passwd".

- Reuses existing validateChangeName() from change-utils.ts
- Adds 3 tests for path traversal, absolute paths, and slashes
2025-12-29 16:49:09 +11:00
Tabish Bidiwale 4f47bd2f37 feat: unify change state model for scaffolded changes
- Update artifact workflow commands to work with scaffolded changes
- Add draft changes section to dashboard view
- Fix completed changes to require tasks.total > 0
- Archive unify-change-state-model change
2025-12-29 16:29:47 +11:00
Tabish Bidiwale 9eb22410a9 chore: archive add-artifact-workflow-cli change
- Move change to archive/2025-12-28-add-artifact-workflow-cli
- Create cli-artifact-workflow spec
2025-12-29 01:30:57 +11:00
Tabish Bidiwale f8747203a7 test: update test to match new specs glob pattern 2025-12-29 01:05:17 +11:00
Tabish Bidiwale 17016d5eaa fix: update specs glob to match nested directory structure
The schema used specs/*.md but specs are stored as specs/<capability>/spec.md.
Updated to specs/**/*.md so openspec status/next correctly detect spec completion.
2025-12-29 00:43:55 +11:00
Tabish Bidiwale a91adbdbd7 feat: implement artifact workflow CLI commands (Slice 4)
Add experimental CLI commands for artifact-based workflow management:
- `openspec status --change <id>` - display artifact completion status
- `openspec next --change <id>` - show artifacts ready to create
- `openspec instructions <artifact> --change <id>` - output enriched template
- `openspec templates [--schema <name>]` - show resolved template paths
- `openspec new change <name>` - create new change directory

Features:
- JSON output support (--json flag) for all commands
- Color-coded status indicators (green/yellow/red)
- Progress spinners during loading
- --no-color and NO_COLOR env support
- --schema option for custom schema selection
- Comprehensive error handling with helpful messages

All commands are isolated in src/commands/artifact-workflow.ts for easy
removal if the feature doesn't work out. Help text marks them as experimental.
2025-12-29 00:04:27 +11:00
Tabish Bidiwale ac8f6d281c rename: cli-workflow -> cli-artifact-workflow
More specific capability name that clarifies which workflow the CLI
commands are for.
2025-12-28 23:22:01 +11:00
Tabish Bidiwale 44ca42fbe1 fix: remove --change from templates command
Templates are schema-level, not change-level. The command now uses
--schema instead of --change for consistency with how templates
are actually resolved.
2025-12-28 20:21:30 +11:00
Tabish Bidiwale 39645d9284 proposal: add artifact workflow CLI commands (Slice 4)
Add CLI commands for artifact workflow operations:
- `openspec status --change <id>` - Show artifact completion state
- `openspec next --change <id>` - Show ready artifacts
- `openspec instructions <artifact> --change <id>` - Get enriched template
- `openspec templates --change <id>` - Show template paths
- `openspec new change <name>` - Create new change

Commands are top-level for fluid UX and implemented in isolation
for easy removal (experimental feature).
2025-12-28 20:07:20 +11:00
102 changed files with 952 additions and 12930 deletions
-31
View File
@@ -1,36 +1,5 @@
# @fission-ai/openspec
## 0.18.0
### Minor Changes
- 8dfd824: Add OPSX experimental workflow commands and enhanced artifact system
**New Commands:**
- `/opsx:ff` - Fast-forward through artifact creation, generating all needed artifacts in one go
- `/opsx:sync` - Sync delta specs from a change to main specs
- `/opsx:archive` - Archive completed changes with smart sync check
**Artifact Workflow Enhancements:**
- Schema-aware apply instructions with inline guidance and XML output
- Agent schema selection for experimental artifact workflow
- Per-change schema metadata via `.openspec.yaml` files
- Agent Skills for experimental artifact workflow
- Instruction loader for template loading and change context
- Restructured schemas as directories with templates
**Improvements:**
- Enhanced list command with last modified timestamps and sorting
- Change creation utilities for better workflow support
**Fixes:**
- Normalize paths for cross-platform glob compatibility
- Allow REMOVED requirements when creating new spec files
## 0.17.2
### Patch Changes
-926
View File
@@ -1,926 +0,0 @@
# OpenSpec Experimental Release Plan
This document outlines the plan to release the experimental artifact workflow system for user testing.
## Overview
The goal is to allow users to test the new artifact-driven workflow system alongside the existing OpenSpec commands. This experimental system (`opsx`) provides a more granular, step-by-step approach to creating change artifacts.
## Three Workflow Modes
### 1. Old Workflow (Current Production)
- **Commands**: `/openspec:proposal`, `/openspec:apply`, `/openspec:archive`
- **Behavior**: Hardcoded slash commands that generate all artifacts in one command
- **Status**: Production, unchanged
### 2. New Artifact System - Batch Mode (Future)
- **Commands**: Refactored `/openspec:proposal` using schemas
- **Behavior**: Schema-driven but generates all artifacts at once (like legacy)
- **Status**: Not in scope for this experimental release
- **Note**: This is a future refactor to unify the old system with schemas
### 3. New Artifact System - Granular Mode (Experimental)
- **Commands**: `/opsx:new`, `/opsx:continue`
- **Behavior**: One artifact at a time, dependency-driven, iterative
- **Status**: Target for this experimental release
---
## Work Items
### 1. Rename AWF to OPSX
**Current State:**
- Commands: `/awf:start`, `/awf:continue`
- Files: `.claude/commands/awf/start.md`, `.claude/commands/awf/continue.md`
**Target State:**
- Commands: `/opsx:new`, `/opsx:continue`
- Files: `.claude/commands/opsx/new.md`, `.claude/commands/opsx/continue.md`
**Tasks:**
- [x] Create `.claude/commands/opsx/` directory
- [x] Rename `start.md` → `new.md` and update content
- [x] Copy `continue.md` with updated references
- [x] Update all references from "awf" to "opsx" in command content
- [x] Update frontmatter (name, description) to use "opsx" naming
- [x] Remove `.claude/commands/awf/` directory
**CLI Commands:**
The underlying CLI commands (`openspec status`, `openspec instructions`, etc.) remain unchanged. Only the slash command names change.
---
### 2. Remove WF Skill Files
**Current State:**
- `.claude/commands/wf/start.md` - References non-existent `openspec wf` commands
- `.claude/commands/wf/continue.md` - References non-existent `openspec wf` commands
**Target State:**
- Directory and files removed
**Tasks:**
- [x] Delete `.claude/commands/wf/start.md`
- [x] Delete `.claude/commands/wf/continue.md`
- [x] Delete `.claude/commands/wf/` directory
---
### 3. Add Agent Skills for Experimental Workflow
**Purpose:**
Generate experimental workflow skills using the [Agent Skills](https://agentskills.io/specification) open standard.
**Why Skills Instead of Slash Commands:**
- **Cross-editor compatibility**: Skills work in Claude Code, Cursor, Windsurf, and other compatible editors automatically
- **Simpler implementation**: Single directory (`.claude/skills/`) instead of 18+ editor-specific configurators
- **Standard format**: Open standard with simple YAML frontmatter + markdown
- **User invocation**: Users explicitly invoke skills when they want to use them
**Behavior:**
1. Create `.claude/skills/` directory if it doesn't exist
2. Generate two skills using the Agent Skills specification:
- `openspec-new-change/SKILL.md` - Start a new change with artifact workflow
- `openspec-continue-change/SKILL.md` - Continue working on a change (create next artifact)
3. Skills are added **alongside** existing `/openspec:*` commands (not replacing)
**Supported Editors:**
- Claude Code (native support)
- Cursor (native support via Settings → Rules → Import Settings)
- Windsurf (imports `.claude` configs)
- Cline, Codex, and other Agent Skills-compatible editors
**Tasks:**
- [x] Create skill template content for `openspec-new-change` (based on current opsx:new)
- [x] Create skill template content for `openspec-continue-change` (based on current opsx:continue)
- [x] Add temporary `artifact-experimental-setup` command to CLI
- [x] Implement skill file generation (YAML frontmatter + markdown body)
- [x] Add success message with usage instructions
**Note:** The `artifact-experimental-setup` command is temporary and will be merged into `openspec init` once the experimental workflow is promoted to stable.
**Skill Format:**
Each skill is a directory with a `SKILL.md` file:
```
.claude/skills/
├── openspec-new-change/
│ └── SKILL.md # name, description, instructions
├── openspec-continue-change/
│ └── SKILL.md # name, description, instructions
└── openspec-apply-change/
└── SKILL.md # name, description, instructions
```
**CLI Interface:**
```bash
openspec artifact-experimental-setup
# Output:
# 🧪 Experimental Artifact Workflow Skills Created
#
# ✓ .claude/skills/openspec-new-change/SKILL.md
# ✓ .claude/skills/openspec-continue-change/SKILL.md
# ✓ .claude/skills/openspec-apply-change/SKILL.md
#
# 📖 Usage:
#
# Skills work automatically in compatible editors:
# • Claude Code - Auto-detected, ready to use
# • Cursor - Enable in Settings → Rules → Import Settings
# • Windsurf - Auto-imports from .claude directory
#
# Ask Claude naturally:
# • "I want to start a new OpenSpec change to add <feature>"
# • "Continue working on this change"
#
# Claude will automatically use the appropriate skill.
#
# 💡 This is an experimental feature.
# Feedback welcome at: https://github.com/Fission-AI/OpenSpec/issues
```
**Implementation Notes:**
- Simple file writing: Create directories and write templated `SKILL.md` files (no complex logic)
- Use existing `FileSystemUtils.writeFile()` pattern like slash command configurators
- Template structure: YAML frontmatter + markdown body
- Keep existing `/opsx:*` slash commands for now (manual cleanup later)
- Skills use invocation model (user explicitly asks Claude to use them)
- Skill `description` field guides when Claude suggests using the skill
- Each `SKILL.md` has required fields: `name` (matches directory) and `description`
---
### 4. Update `/opsx:new` Command Content
**Current Behavior (awf:start):**
1. Ask user what they want to build (if no input)
2. Create change directory
3. Show artifact status
4. Show what's ready
5. Get instructions for proposal
6. STOP and wait
**New Behavior (opsx:new):**
Same flow but with updated naming:
- References to "awf" → "opsx"
- References to `/awf:continue` → `/opsx:continue`
- Update frontmatter name/description
**Tasks:**
- [x] Update all "awf" references to "opsx"
- [x] Update command references in prompt text
- [x] Verify CLI commands still work (they use `openspec`, not `awf`)
---
### 5. Update `/opsx:continue` Command Content
**Current Behavior (awf:continue):**
1. Prompt for change selection (if not provided)
2. Check current status
3. Create ONE artifact based on what's ready
4. Show progress and what's unlocked
5. STOP
**New Behavior (opsx:continue):**
Same flow with updated naming.
**Tasks:**
- [x] Update all "awf" references to "opsx"
- [x] Update command references in prompt text
---
### 6. End-to-End Testing
**Objective:**
Run through a complete workflow with Claude using the new skills to create a real feature, validating the entire flow works.
**Test Scenario:**
Use a real OpenSpec feature as the test case (dog-fooding).
**Test Flow:**
1. Run `openspec artifact-experimental-setup` to create skills
2. Verify `.claude/skills/openspec-new-change/SKILL.md` created
3. Verify `.claude/skills/openspec-continue-change/SKILL.md` created
4. Verify `.claude/skills/openspec-apply-change/SKILL.md` created
5. Ask Claude: "I want to start a new OpenSpec change to add feature X"
6. Verify Claude invokes the `openspec-new-change` skill
7. Verify change directory created at `openspec/changes/add-feature-x/`
8. Verify proposal template shown
9. Ask Claude: "Continue working on this change"
10. Verify Claude invokes the `openspec-continue-change` skill
11. Verify `proposal.md` created with content
12. Ask Claude: "Continue" (create specs)
13. Verify `specs/*.md` created
14. Ask Claude: "Continue" (create design)
15. Verify `design.md` created
16. Ask Claude: "Continue" (create tasks)
17. Verify `tasks.md` created
18. Verify status shows 4/4 complete
19. Implement the feature based on tasks
20. Run `/openspec:archive` to archive the change
**Validation Checklist:**
- [ ] `openspec artifact-experimental-setup` creates correct directory structure
- [ ] Skills are auto-detected in Claude Code
- [ ] Skill descriptions trigger appropriate invocations
- [ ] Skills create change directory and show proposal template
- [ ] Skills correctly identify ready artifacts
- [ ] Skills create artifacts with meaningful content
- [ ] Dependency detection works (specs requires proposal, etc.)
- [ ] Progress tracking is accurate
- [ ] Template content is useful and well-structured
- [ ] Error handling works (invalid names, missing changes, etc.)
- [ ] Works with different schemas (spec-driven, tdd)
- [ ] Test in Cursor (Settings → Rules → Import Settings)
**Document Results:**
- Create test log documenting what worked and what didn't
- Note any friction points or confusing UX
- Identify bugs or improvements needed before user release
---
### 7. Documentation for Users
**Create user-facing documentation explaining:**
1. **What is the experimental workflow?**
- A new way to create OpenSpec changes step-by-step using Agent Skills
- One artifact at a time with dependency tracking
- More interactive and iterative than the batch approach
- Works across Claude Code, Cursor, Windsurf, and other compatible editors
2. **How to set up experimental workflow**
```bash
openspec artifact-experimental-setup
```
Note: This is a temporary command that will be integrated into `openspec init` once promoted to stable.
3. **Available skills**
- `openspec-new-change` - Start a new change with artifact workflow
- `openspec-continue-change` - Continue working (create next artifact)
4. **How to use**
- **Claude Code**: Skills are auto-detected, just ask Claude naturally
- "I want to start a new OpenSpec change to add X"
- "Continue working on this change"
- **Cursor**: Enable in Settings → Rules → Import Settings
- **Windsurf**: Auto-imports `.claude` directory
5. **Example workflow**
- Step-by-step walkthrough with natural language interactions
- Show how Claude invokes skills based on user requests
6. **Feedback mechanism**
- GitHub issue template for feedback
- What to report (bugs, UX issues, suggestions)
**Tasks:**
- [ ] Create `docs/experimental-workflow.md` user guide
- [ ] Add GitHub issue template for experimental feedback
- [ ] Update README with mention of experimental features
---
## Dependency Graph
```
1. Remove WF skill files
└── (no dependencies)
2. Rename AWF to OPSX
└── (no dependencies)
3. Add Agent Skills
└── Depends on: Rename AWF to OPSX (uses opsx content as templates)
4. Update opsx:new content
└── Depends on: Rename AWF to OPSX
5. Update opsx:continue content
└── Depends on: Rename AWF to OPSX
6. E2E Testing
└── Depends on: Add Agent Skills (tests the skills workflow)
7. User Documentation
└── Depends on: E2E Testing (need to know final behavior)
```
---
## Out of Scope
The following are explicitly NOT part of this experimental release:
1. **Batch mode refactor** - Making legacy `/openspec:proposal` use schemas
2. **New schemas** - Only shipping with existing `spec-driven` and `tdd`
3. **Schema customization UI** - No `openspec schema list` or similar
4. **Multiple editor support in CLI** - Skills work cross-editor automatically via `.claude/skills/`
5. **Replacing existing commands** - Skills are additive, not replacing `/openspec:*` or `/opsx:*`
---
## Success Criteria
The experimental release is ready when:
1. `openspec-new-change`, `openspec-continue-change`, and `openspec-apply-change` skills work end-to-end
2. `openspec artifact-experimental-setup` creates skills in `.claude/skills/`
3. Skills work in Claude Code and are compatible with Cursor/Windsurf
4. At least one complete workflow has been tested manually
5. User documentation exists explaining how to generate and use skills
6. Feedback mechanism is in place
7. WF skill files are removed
8. No references to "awf" remain in user-facing content
---
## Open Questions
1. **Schema selection** - Should `opsx:new` allow selecting a schema, or always use `spec-driven`?
- Current: Always uses `spec-driven` as default
- Consider: Add `--schema tdd` option or prompt
2. **Namespace in CLI** - Should experimental CLI commands be namespaced?
- Current: `openspec status`, `openspec instructions` (no namespace)
- Alternative: `openspec opsx status` (explicit experimental namespace)
- Recommendation: Keep current, less typing for users
3. **Deprecation path** - If opsx becomes the default, how do we migrate?
- Not needed for experimental release
- Document that command names may change
---
## Estimated Work Breakdown
| Item | Complexity | Notes |
|------|------------|-------|
| Remove WF files | Trivial | Just delete 2 files + directory |
| Rename AWF → OPSX | Low | File renames + content updates |
| Add Agent Skills | **Low** | **Simple: 3-4 files, single output directory, standard format** |
| Update opsx:new content | Low | Text replacements |
| Update opsx:continue content | Low | Text replacements |
| E2E Testing | Medium | Manual testing, documenting results |
| User Documentation | Medium | New docs, issue template |
**Key Improvement:** Switching to Agent Skills reduces complexity significantly:
- **Before:** 20+ files (type definitions, 18+ editor configurators, editor selection UI)
- **After:** 3-4 files (skill templates, simple CLI command)
- **Cross-editor:** Works automatically in Claude Code, Cursor, Windsurf without extra code
---
## User Feedback from E2E Testing
### What Worked Well
1. **Clear dependency graph** ⭐ HIGH PRIORITY - KEEP
- The status command showing blocked/unblocked artifacts was intuitive:
```
[x] proposal
[ ] design
[-] tasks (blocked by: design, specs)
```
- Users always knew what they could work on next
- **Relevance**: Core UX strength to preserve
2. **Structured instructions output** ⭐ HIGH PRIORITY - KEEP
- `openspec instructions <artifact>` gave templates, output paths, and context in one call
- Very helpful for understanding what to create
- **Relevance**: Essential for agent-driven workflow
3. **Simple scaffolding** ✅ WORKS WELL
- `openspec new change "name"` just worked - created directory structure without fuss
- **Relevance**: Good baseline, room for improvement (see pain points)
---
### Pain Points & Confusion
1. **Redundant CLI calls** ⚠️ MEDIUM PRIORITY
- Users called both `status` AND `next` every time, but they overlap significantly
- `status` already shows what's blocked
- **Recommendation**: Consider merging or making `next` give actionable guidance beyond just listing names
- **Relevance**: Reduces friction in iterative workflow
2. **Specs directory structure was ambiguous** 🔥 HIGH PRIORITY - FIX
- Instructions said: `Write to: .../specs/**/*.md`
- Users had to guess: `specs/spec.md`? `specs/game/spec.md`? `specs/tic-tac-toe/spec.md`?
- Users ended up doing manual `mkdir -p .../specs/tic-tac-toe` then writing `spec.md` inside
- **Recommendation**: CLI should scaffold this directory structure automatically
- **Relevance**: Critical agent UX - ambiguous paths cause workflow friction
3. **Repetitive --change flag** ⚠️ MEDIUM PRIORITY
- Every command needed `--change "tic-tac-toe-game"`
- After 10+ calls, this felt verbose
- **Recommendation**: `openspec use "tic-tac-toe-game"` to set context, then subsequent commands assume that change
- **Relevance**: Quality of life improvement for iterative sessions
4. **No validation feedback** 🔥 HIGH PRIORITY - ADD
- After writing each artifact, users just ran `status` hoping it would show `[x]`
- Questions raised:
- How did it know the artifact was "done"? File existence?
- What if spec format was wrong (e.g., wrong heading levels)?
- **Recommendation**: Add `openspec validate --change "name"` to check content quality
- **Relevance**: Critical for user confidence and catching errors early
5. **Query-heavy, action-light CLI** 🔥 HIGH PRIORITY - ENHANCE
- Most commands retrieve info. The only "action" is `new change`
- Artifact creation is manual Write to guessed paths
- **Recommendation**: `openspec create proposal --change "name"` could scaffold the file with template pre-filled, then user just edits
- **Relevance**: Directly impacts agent productivity - reduce manual file writing
6. **Instructions output was verbose** ⚠️ LOW PRIORITY
- XML-style output (`<artifact>`, `<template>`, `<instruction>`) was parseable but long
- Key info (output path, template) was buried in ~50 lines
- **Recommendation**: Add compact mode or structured JSON output for agents
- **Relevance**: Nice-to-have for agent parsing efficiency
---
### Workflow Friction
1. **Mandatory "STOP and wait" after showing proposal template** ⚠️ MEDIUM PRIORITY
- The skill said "STOP and wait" after showing the proposal template
- This felt overly cautious when user had already provided enough context (e.g., "tic tac toe, single player vs AI, minimal aesthetics")
- **Recommendation**: Make the pause optional or conditional based on context clarity
- **Relevance**: Reduces unnecessary round-trips in agent conversations
2. **No connection to implementation** 🔥 HIGH PRIORITY - ROADMAP ITEM
- After 4/4 artifacts complete, then what? The workflow ends at planning
- No `openspec apply` or guidance on how to execute the tasks
- User asked "would you like me to implement?" but that's outside OpenSpec's scope currently
- **Recommendation**: Add implementation bridge - either:
- `openspec apply` command to start execution phase
- Clear handoff to existing `/openspec:apply` workflow
- Documentation on next steps after planning completes
- **Relevance**: Critical missing piece - users expect end-to-end workflow
---
### Priority Summary
**MUST FIX (High Priority):**
1. Specs directory structure ambiguity (#2)
2. Add validation feedback (#4)
3. Make CLI more action-oriented (#5)
4. Bridge to implementation phase (#2 in Workflow Friction)
5. Keep clear dependency graph (#1 in What Worked)
6. Keep structured instructions (#2 in What Worked)
**SHOULD FIX (Medium Priority):**
1. Reduce redundant CLI calls (#1)
2. Repetitive `--change` flag (#3)
3. Mandatory STOP behavior (#1 in Workflow Friction)
**NICE TO HAVE (Low Priority):**
1. Compact instructions output mode (#6)
---
## Design Decisions (from E2E Testing Feedback)
Based on dev testing and analysis of agent workflow friction, we identified three blockers for experimental release and made the following decisions.
### Blockers Identified
From the pain points in E2E testing, three issues are blocking the experimental release:
1. **Specs directory ambiguity** - Agents don't know where to write spec files or how to name capabilities
2. **CLI is query-heavy** - Most commands retrieve info, artifact creation is manual
3. **Apply integration missing** - After 4/4 artifacts complete, no guidance on implementation phase
### Decision 1: Capability Discovery in Proposal (RESOLVED)
**Problem:** The specs artifact instruction says "Create one spec file per capability in `specs/<name>/spec.md`" but:
- Agent doesn't know what `<name>` should be
- Capability identification requires research (existing specs, codebase)
- Proposal template asks for "Affected specs" but doesn't structure it
- Research happens implicitly, output isn't captured
**Decision:** Enrich the proposal template to explicitly capture capability discovery.
**Current proposal template:**
```markdown
## Why
## What Changes
## Impact
- Affected specs: List capabilities... ← vague, easy to skip
- Affected code: ...
```
**New proposal template:**
```markdown
## Why
## What Changes
## Capabilities
### New Capabilities
<!-- Capabilities being introduced (will create new specs/<name>/spec.md) -->
- `<name>`: <brief description of what this capability covers>
### Modified Capabilities
<!-- Existing capabilities being changed (will update existing specs) -->
- `<existing-name>`: <what's changing>
## Impact
<!-- Affected code, APIs, dependencies, systems -->
```
**Rationale:**
- Proposal already asks for capabilities (just poorly) - this makes it explicit
- Captured output is reviewable (vs implicit research that can't be verified)
- Creates clear contract between proposal and specs phases
- Distinguishes NEW vs MODIFIED upfront (critical for specs phase)
- Agent can't skip research - it's part of the deliverable
**Implementation:**
- Update `schemas/spec-driven/templates/proposal.md`
- Update proposal instruction in `schemas/spec-driven/schema.yaml`
- Update skill instructions to guide capability discovery
### Decision 2: CLI Action Commands (IN PROGRESS)
**Problem:** CLI is mostly query-oriented. Agents run `openspec status`, `openspec next`, `openspec instructions` but then must manually write files.
#### Decision 2a: Remove `openspec next` command (RESOLVED)
**Problem:** The `next` command is redundant. It only shows which artifacts are ready, but `status` already shows this information (artifacts with status "ready" vs "blocked" vs "done").
**Current behavior:**
```bash
openspec status --change "X" # Shows: proposal (done), specs (ready), design (blocked), tasks (blocked)
openspec next --change "X" # Shows: ["specs"] ← redundant
```
**Decision:** Remove the `next` command. Agents should use `status` which provides the same info plus more context.
**Implementation:**
- Remove `next` command from CLI
- Update skill instructions to use `status` instead of `next`
- Update AGENTS.md references
#### Decision 2b: CLI Scaffolding (RESOLVED - NO)
**Problem:** After getting instructions, agents manually write files. Should CLI scaffold artifacts instead?
**Options considered:**
- Add `openspec create <artifact>` commands that scaffold files with templates
- Keep current approach where agent writes files directly from instructions
- Hybrid: CLI can scaffold, agent can also write directly
**Decision:** Keep current flow. No scaffolding commands.
**Rationale (from agent ergonomics perspective):**
- One Write is better than multiple Edits - agent composes full content atomically
- `instructions` already provides template in context - scaffolding just moves it to a file
- Fewer tool calls: `instructions` + Write (2) vs `create` + `instructions` + Read + Edit×N (4+)
- Scaffolding doesn't solve the real problem (not knowing WHAT to write)
- Real problem solved by proposal template change (capability discovery)
**For multi-file artifacts (specs):** Scaffolding can't help because CLI doesn't know capability names until proposal is complete. The capability discovery in proposal solves this.
### Decision 3: Apply Integration (RESOLVED)
**Original problem:** After planning completes (4/4 artifacts), the experimental workflow ends. No guidance on implementation.
**Key insight: No phases, just actions.**
Through discussion, we realized phases (planning → implementation → archive) are an artificial constraint. Work is fluid:
- You might start implementing, realize the design is wrong → update design.md
- You're halfway through tasks, discover a new requirement → update specs
- You bounce between "planning" and "implementing" constantly
**The better model: Actions on a Change**
A change is a thing (with artifacts). Actions are verbs you perform on a change. Actions aren't phases - they're fluid operations you can perform anytime.
| Action | What it does | Skill | CLI Command |
|--------|--------------|-------|-------------|
| `new` | Create a change (scaffold directory) | `opsx:new` | `openspec new change` |
| `continue` | Create next artifact (dependency-aware) | `opsx:continue` | `openspec instructions` |
| `apply` | Implement tasks (execute, check off) | `opsx:apply` (NEW) | TBD |
| `update` | Refresh/update artifacts based on learnings | `opsx:update` (NEW) | TBD |
| `explore` | Research, ask questions, understand | `opsx:explore` (NEW) | TBD |
| `validate` | Check artifacts are correct/complete | TBD | `openspec validate` |
| `archive` | Finalize and move to archive | existing | `openspec archive` |
**Key principles:**
- Actions are modeled as skills (primary interface for agents)
- Some skills have matching CLI commands for convenience
- Skills and CLI commands are decoupled - not everything needs both
- Actions can be performed in any order (with soft prerequisites)
- No linear phase gates
**What the schema defines:**
- Artifacts (what they are, where they go)
- Dependencies (what must exist first)
- Required vs optional
- Templates + instructions
**What the schema does NOT define:**
- Phases
- When you can modify things
- Linear workflow
**Progress tracking:**
- tasks.md checkboxes = implementation progress
- Artifact existence = planning progress
- Archive readiness = user decides (or all tasks done)
**For experimental release:**
- Create `opsx:apply` skill (guidance for implementing tasks)
- Document the "actions on a change" model
- Other actions (update, explore) can come later
---
### Design: `openspec-apply-change` Skill
#### Overview
The apply skill guides agents through implementing tasks from a completed (or in-progress) change. Unlike the old `/openspec:apply` command, this skill:
- Is **fluid** - can be invoked anytime, not just after all artifacts are done
- Allows **artifact updates** - if implementation reveals issues, update design/specs
- Works **until done** - keeps going through tasks until complete or blocked
- Tracks **progress via checkboxes** - tasks.md is the source of truth
#### Skill Metadata
```yaml
name: openspec-apply-change
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
```
#### When to Invoke
The skill should be invoked when:
- User says "implement this change" or "start implementing"
- User says "work on the tasks" or "do the next task"
- User says "apply this change"
- All artifacts are complete and user wants to proceed
- User wants to continue implementation after a break
#### Input
- Optionally: change name
- Optionally: specific task number to work on
- If omitted: prompt for change selection (same pattern as continue-change)
#### Steps
```markdown
**Steps**
1. **If no change name provided, prompt for selection**
Run `openspec list --json` to get available changes. Use **AskUserQuestion** to let user select.
Show changes that have tasks.md (implementation-ready).
Mark changes with incomplete tasks as "(In Progress)".
2. **Get apply instructions**
```bash
openspec instructions apply --change "<name>" --json
```
This returns:
- Context file paths (proposal, specs, design, tasks)
- Progress (total, complete, remaining)
- Task list with status
- Dynamic instruction based on current state
**Handle states:**
- If blocked (missing artifacts): show message, suggest `openspec-continue-change`
- If all done: congratulate, suggest archive
- Otherwise: proceed to implementation
3. **Read context files**
Read the files listed in the instructions:
- `proposal.md` - why and what
- `specs/*.md` - requirements and scenarios
- `design.md` - technical approach (if exists)
- `tasks.md` - the implementation checklist
4. **Show current progress**
Display:
- Progress: "N/M tasks complete"
- Remaining tasks overview
- Dynamic instruction from CLI
5. **Implement tasks (loop until done or blocked)**
For each pending task:
- Show which task is being worked on
- Make the code changes required
- Keep changes minimal and focused
- Mark task complete in tasks.md: `- [ ]` → `- [x]`
- Continue to next task
**Pause if:**
- Task is unclear → ask for clarification
- Implementation reveals a design issue → suggest updating artifacts
- Error or blocker encountered → report and wait for guidance
- User interrupts
6. **On completion or pause, show status**
Display:
- Tasks completed this session
- Overall progress: "N/M tasks complete"
- If all done: suggest archive
- If paused: explain why and wait for guidance
```
#### Output Format
**During implementation:**
```
## Implementing: add-user-auth
Working on task 3/7: Create UserAuth service class
[...implementation happening...]
✓ Task complete
Working on task 4/7: Add login endpoint to AuthController
[...implementation happening...]
✓ Task complete
Working on task 5/7: Add JWT token generation
[...implementation happening...]
```
**On completion:**
```
## Implementation Complete
**Change:** add-user-auth
**Progress:** 7/7 tasks complete ✓
### Completed This Session
- [x] Create UserAuth service class
- [x] Add login endpoint to AuthController
- [x] Add JWT token generation
- [x] Add logout endpoint
- [x] Add auth middleware
- [x] Write unit tests
- [x] Update API documentation
All tasks complete! Ready to archive this change.
```
**On pause (issue encountered):**
```
## Implementation Paused
**Change:** add-user-auth
**Progress:** 4/7 tasks complete
### Issue Encountered
Task 5 "Add JWT token generation" - the design specifies using RS256 but
the existing auth library only supports HS256.
**Options:**
1. Update design.md to use HS256 instead
2. Add a new JWT library that supports RS256
3. Other approach
What would you like to do?
```
#### Guardrails
- Keep going through tasks until done or blocked
- Always read context before starting (specs, design)
- If task is ambiguous, pause and ask before implementing
- If implementation reveals issues, pause and suggest artifact updates
- Keep code changes minimal and scoped to each task
- Update task checkbox immediately after completing each task
- Pause on errors, blockers, or unclear requirements - don't guess
#### Fluid Workflow Integration
The apply skill supports the "actions on a change" model:
**Can be invoked anytime:**
- Before all artifacts are done (if tasks.md exists)
- After partial implementation
- Interleaved with other actions (update, continue)
**Allows artifact updates:**
- If implementation reveals design issues → suggest `opsx:update` or manual edit
- If requirements need clarification → suggest updating specs
- Not phase-locked - work fluidly
**Example fluid workflow:**
```
User: "Implement add-user-auth"
→ openspec-apply-change: implements tasks 1, 2, 3, 4...
→ Pauses at task 5: "Design says RS256 but library only supports HS256"
User: "Let's use HS256 instead, update the design"
→ User edits design.md (or uses opsx:update in future)
User: "Continue implementing"
→ openspec-apply-change: implements tasks 5, 6, 7
→ "All tasks complete! Ready to archive."
```
#### CLI Commands Used
```bash
openspec list --json # List changes for selection
openspec status --change "<name>" # Check artifact completion
openspec instructions apply --change "<name>" # Get apply instructions (NEW)
# File reads via Read tool for proposal, specs, design, tasks
# File edits via Edit tool for checking off tasks
```
#### New CLI Command: `openspec instructions apply`
For consistency with artifact instructions.
**Usage:**
```bash
openspec instructions apply --change "<name>" [--json]
```
**Output (Markdown format):**
```markdown
## Apply: add-user-auth
### Context Files
- proposal: openspec/changes/add-user-auth/proposal.md
- specs: openspec/changes/add-user-auth/specs/**/*.md
- design: openspec/changes/add-user-auth/design.md
- tasks: openspec/changes/add-user-auth/tasks.md
### Progress
2/7 complete
### Tasks
- [x] Create UserAuth service class
- [x] Add login endpoint
- [ ] Add JWT token generation
- [ ] Add logout endpoint
- [ ] Add auth middleware
- [ ] Write unit tests
- [ ] Update API documentation
### Instruction
Read context files, work through pending tasks, mark complete as you go.
Pause if you hit blockers or need clarification.
```
**Benefits of CLI command:**
- **Consistency** - same pattern as `openspec instructions <artifact>`
- **Structured output** - progress, tasks, context paths in one call
- **Clean format** - markdown is readable and compact (vs verbose XML)
- **Extensibility** - can add more sections later if needed
- **JSON option** - `--json` flag available for programmatic use
#### Differences from Old `/openspec:apply`
| Aspect | Old `/openspec:apply` | New `openspec-apply-change` |
|--------|----------------------|----------------------------|
| Invocation | After all artifacts done | Anytime (if tasks.md exists) |
| Granularity | All tasks at once | All tasks, but pauses on issues |
| Artifact updates | Not mentioned | Encouraged when needed |
| Progress tracking | Update all at end | Update after each task |
| Flow control | Push through everything | Pause on blockers, resume after |
| Context loading | Read once at start | Read context, reference as needed |
| Issue handling | Not specified | Pause, present options, wait for guidance |
#### Implementation Notes
1. **Add CLI command**: Add `openspec instructions apply` to artifact-workflow.ts
- Parse tasks.md for progress (count done/pending)
- Return context paths, progress, task list, simple instruction
2. **Add to skill-templates.ts**: Create `getApplyChangeSkillTemplate()` function
3. **Update artifact-experimental-setup**: Generate this skill alongside new/continue
4. **Update skills list**: Add to `.claude/skills/` directory
5. **Test the flow**: Verify it works with existing changes that have tasks.md
---
## Next Steps
1. ~~Review this plan and confirm scope~~ (Done - blockers identified)
2. ~~Design decisions~~ (Done - all 3 blockers resolved)
3. ~~Design apply skill~~ (Done - documented above)
4. ~~Implement proposal template change (Decision 1 - capability discovery)~~ (Done)
5. ~~Remove `openspec next` command (Decision 2a)~~ (Done)
6. ~~Add `openspec instructions apply` CLI command~~ (Done)
7. ~~Create `openspec-apply-change` skill~~ (Done)
8. Conduct E2E testing with updated workflow
9. Write user docs (document "actions on a change" model)
10. Release to test users
-107
View File
@@ -1,107 +0,0 @@
# Experimental Workflow (OPSX)
> **Status:** Experimental. Things might break. Feedback welcome on [Discord](https://discord.gg/BYjPaKbqMt).
>
> **Compatibility:** Claude Code only (for now)
## What Is It?
OPSX is a new way to work with OpenSpec changes. Instead of one big proposal, you build **artifacts** step-by-step:
```
proposal → specs → design → tasks → implementation → archive
```
Each artifact has dependencies. Can't write tasks until you have specs. Can't implement until you have tasks. The system tracks what's ready and what's blocked.
## Setup
```bash
# 1. Make sure you have openspec installed and initialized
openspec init
# 2. Generate the experimental skills
openspec artifact-experimental-setup
```
This creates skills in `.claude/skills/` that Claude Code auto-detects.
## Commands
| Command | What it does |
|---------|--------------|
| `/opsx:new` | Start a new change |
| `/opsx:continue` | Create the next artifact |
| `/opsx:ff` | Fast-forward (create all artifacts at once) |
| `/opsx:apply` | Implement the tasks |
| `/opsx:sync` | Sync delta specs to main specs |
| `/opsx:archive` | Archive when done |
## Usage
### Start a new change
```
/opsx:new
```
You'll be asked what you want to build and which workflow schema to use.
### Build artifacts step-by-step
```
/opsx:continue
```
Creates one artifact at a time. Good for reviewing each step.
### Or fast-forward
```
/opsx:ff add-dark-mode
```
Creates all artifacts in one go. Good when you know what you want.
### Implement
```
/opsx:apply
```
Works through tasks, checking them off as you go.
### Sync specs and archive
```
/opsx:sync # Update main specs with your delta specs
/opsx:archive # Move to archive when done
```
## What's Different?
**Standard workflow** (`/openspec:proposal`):
- One big proposal document
- Linear phases: plan → implement → archive
- All-or-nothing artifact creation
**Experimental workflow** (`/opsx:*`):
- Discrete artifacts with dependencies
- Fluid actions (not phases) - update artifacts anytime
- Step-by-step or fast-forward
- Schema-driven (can customize the workflow)
The key insight: work isn't linear. You implement, realize the design is wrong, update it, continue. OPSX supports this.
## Schemas
Schemas define what artifacts exist and their dependencies. Currently available:
- **spec-driven** (default): proposal → specs → design → tasks
- **tdd**: tests → implementation → docs
Run `openspec schemas` to see available schemas.
## Tips
- Use `/opsx:ff` when you have a clear idea, `/opsx:continue` when exploring
- Tasks track progress via checkboxes in `tasks.md`
- Delta specs (in `specs/`) get synced to main specs with `/opsx:sync`
- If you get stuck, the status command shows what's blocked: `openspec status --change "name"`
## Feedback
This is rough. That's intentional - we're learning what works.
Found a bug? Have ideas? Join us on [Discord](https://discord.gg/BYjPaKbqMt) or open an issue on [GitHub](https://github.com/Fission-AI/openspec/issues).
-211
View File
@@ -1,211 +0,0 @@
# Schema Customization
This document describes how users can customize OpenSpec schemas and templates, the current manual process, and the gap that needs to be addressed.
---
## Overview
OpenSpec uses a 2-level schema resolution system following the XDG Base Directory Specification:
1. **User override**: `${XDG_DATA_HOME}/openspec/schemas/<name>/`
2. **Package built-in**: `<npm-package>/schemas/<name>/`
When a schema is requested (e.g., `spec-driven`), the resolver checks the user directory first. If found, that entire schema directory is used. Otherwise, it falls back to the package's built-in schema.
---
## Current Manual Process
To override the default `spec-driven` schema, a user must:
### 1. Determine the correct directory path
| Platform | Path |
|----------|------|
| macOS/Linux | `~/.local/share/openspec/schemas/` |
| Windows | `%LOCALAPPDATA%\openspec\schemas\` |
| All (if set) | `$XDG_DATA_HOME/openspec/schemas/` |
### 2. Create the directory structure
```bash
# macOS/Linux example
mkdir -p ~/.local/share/openspec/schemas/spec-driven/templates
```
### 3. Find and copy the default schema files
The user must locate the installed npm package to copy the defaults:
```bash
# Find the package location (varies by install method)
npm list -g openspec --parseable
# or
which openspec && readlink -f $(which openspec)
# Copy files from the package's schemas/ directory
cp <package-path>/schemas/spec-driven/schema.yaml ~/.local/share/openspec/schemas/spec-driven/
cp <package-path>/schemas/spec-driven/templates/*.md ~/.local/share/openspec/schemas/spec-driven/templates/
```
### 4. Modify the copied files
Edit `schema.yaml` to change the workflow structure:
```yaml
name: spec-driven
version: 1
description: My custom workflow
artifacts:
- id: proposal
generates: proposal.md
description: Initial proposal
template: proposal.md
requires: []
# Add, remove, or modify artifacts...
```
Edit templates in `templates/` to customize the content guidance.
### 5. Verify the override is active
Currently there's no command to verify which schema is being used. Users must trust that the file exists in the right location.
---
## Gap Analysis
The current process has several friction points:
| Issue | Impact |
|-------|--------|
| **Path discovery** | Users must know XDG conventions and platform-specific paths |
| **Package location** | Finding the npm package path varies by install method (global, local, pnpm, yarn, volta, etc.) |
| **No scaffolding** | Users must manually create directories and copy files |
| **No verification** | No way to confirm which schema is actually being resolved |
| **No diffing** | When upgrading openspec, users can't see what changed in built-in templates |
| **Full copy required** | Must copy entire schema even to change one template |
### User Stories Not Currently Supported
1. *"I want to add a `research` artifact before `proposal`"* — requires manual copy and edit
2. *"I want to customize just the proposal template"* — must copy entire schema
3. *"I want to see what the default schema looks like"* — must find package path
4. *"I want to revert to defaults"* — must delete files and hope paths are correct
5. *"I upgraded openspec, did the templates change?"* — no way to diff
---
## Proposed Solution: Schema Configurator
A CLI command (or set of commands) that handles path resolution and file operations for users.
### Option A: Single `openspec schema` command
```bash
# List available schemas (built-in and user overrides)
openspec schema list
# Show where a schema resolves from
openspec schema which spec-driven
# Output: /Users/me/.local/share/openspec/schemas/spec-driven/ (user override)
# Output: /usr/local/lib/node_modules/openspec/schemas/spec-driven/ (built-in)
# Copy a built-in schema to user directory for customization
openspec schema copy spec-driven
# Creates ~/.local/share/openspec/schemas/spec-driven/ with all files
# Show diff between user override and built-in
openspec schema diff spec-driven
# Remove user override (revert to built-in)
openspec schema reset spec-driven
# Validate a schema
openspec schema validate spec-driven
```
### Option B: Dedicated `openspec customize` command
```bash
# Interactive schema customization
openspec customize
# Prompts: Which schema? What do you want to change? etc.
# Copy and open for editing
openspec customize spec-driven
# Copies to user dir, prints path, optionally opens in $EDITOR
```
### Option C: Init-time schema selection
```bash
# During project init, offer schema customization
openspec init
# ? Select a workflow schema:
# > spec-driven (default)
# tdd
# minimal
# custom (copy and edit)
```
### Recommended Approach
**Option A** provides the most flexibility and follows Unix conventions (subcommands for discrete operations). Key commands in priority order:
1. `openspec schema list` — see what's available
2. `openspec schema which <name>` — debug resolution
3. `openspec schema copy <name>` — scaffold customization
4. `openspec schema diff <name>` — compare with built-in
5. `openspec schema reset <name>` — revert to defaults
---
## Implementation Considerations
### Path Resolution
The resolver already exists in `src/core/artifact-graph/resolver.ts`:
```typescript
export function getPackageSchemasDir(): string { ... }
export function getUserSchemasDir(): string { ... }
export function getSchemaDir(name: string): string | null { ... }
export function listSchemas(): string[] { ... }
```
New commands would leverage these existing functions.
### File Operations
- Copy should preserve file permissions
- Copy should not overwrite existing user files without `--force`
- Reset should prompt for confirmation
### Template-Only Overrides
A future enhancement could support overriding individual templates without copying the entire schema. This would require changes to the resolution logic:
```
Current: schema dir (user) OR schema dir (built-in)
Future: schema.yaml from user OR built-in
+ each template from user OR built-in (independent fallback)
```
This adds complexity but enables the "I just want to change one template" use case.
---
## Related Documents
- [Schema Workflow Gaps](./schema-workflow-gaps.md) — End-to-end workflow analysis and phased implementation plan
## Related Files
| File | Purpose |
|------|---------|
| `src/core/artifact-graph/resolver.ts` | Schema resolution logic |
| `src/core/artifact-graph/instruction-loader.ts` | Template loading |
| `src/core/global-config.ts` | XDG path helpers |
| `schemas/spec-driven/` | Default schema and templates |
-378
View File
@@ -1,378 +0,0 @@
# Schema Workflow: End-to-End Analysis
This document analyzes the complete user journey for working with schemas in OpenSpec, identifies gaps, and proposes a phased solution.
---
## Current State
### What Exists
| Component | Status |
|-----------|--------|
| Schema resolution (XDG) | 2-level: user override → package built-in |
| Built-in schemas | `spec-driven`, `tdd` |
| Artifact workflow commands | `status`, `next`, `instructions`, `templates` with `--schema` flag |
| Change creation | `openspec new change <name>` — no schema binding |
### What's Missing
| Component | Status |
|-----------|--------|
| Schema bound to change | Not stored — must pass `--schema` every time |
| Project-local schemas | Not supported — can't version control with repo |
| Schema management CLI | None — manual path discovery required |
| Project default schema | None — hardcoded to `spec-driven` |
---
## User Journey Analysis
### Scenario 1: Using a Non-Default Schema
**Goal:** User wants to use TDD workflow for a new feature.
**Today's experience:**
```bash
openspec new change add-auth
# Creates directory, no schema info stored
openspec status --change add-auth
# Shows spec-driven artifacts (WRONG - user wanted TDD)
# User realizes mistake...
openspec status --change add-auth --schema tdd
# Correct, but must remember --schema every time
# 6 months later...
openspec status --change add-auth
# Wrong again - nobody remembers this was TDD
```
**Problems:**
- Schema is a runtime argument, not persisted
- Easy to forget `--schema` and get wrong results
- No record of intended schema for future reference
---
### Scenario 2: Customizing a Schema
**Goal:** User wants to add a "research" artifact before "proposal".
**Today's experience:**
```bash
# Step 1: Figure out where to put overrides
# Must know XDG conventions:
# macOS/Linux: ~/.local/share/openspec/schemas/
# Windows: %LOCALAPPDATA%\openspec\schemas/
# Step 2: Create directory structure
mkdir -p ~/.local/share/openspec/schemas/my-workflow/templates
# Step 3: Find the npm package to copy defaults
npm list -g openspec --parseable
# Output varies by package manager:
# npm: /usr/local/lib/node_modules/openspec
# pnpm: ~/.local/share/pnpm/global/5/node_modules/openspec
# volta: ~/.volta/tools/image/packages/openspec/...
# yarn: ~/.config/yarn/global/node_modules/openspec
# Step 4: Copy files
cp -r <package-path>/schemas/spec-driven/* \
~/.local/share/openspec/schemas/my-workflow/
# Step 5: Edit schema.yaml and templates
# No way to verify override is active
# No way to diff against original
```
**Problems:**
- Must know XDG path conventions
- Finding npm package path varies by install method
- No tooling to scaffold or verify
- No diff capability when upgrading openspec
---
### Scenario 3: Team Sharing Custom Workflow
**Goal:** Team wants everyone to use the same custom schema.
**Today's options:**
1. Everyone manually sets up XDG override — error-prone, drift risk
2. Document setup in README — still manual, easy to miss
3. Publish separate npm package — overkill for most teams
4. Check schema into repo — **not supported** (no project-local resolution)
**Problems:**
- No project-local schema resolution
- Can't version control custom schemas with the codebase
- No single source of truth for team workflow
---
## Gap Summary
| Gap | Impact | Workaround |
|-----|--------|------------|
| Schema not bound to change | Wrong results, forgotten context | Remember to pass `--schema` |
| No project-local schemas | Can't share via repo | Manual XDG setup per machine |
| No schema management CLI | Manual path hunting | Know XDG + find npm package |
| No project default schema | Must specify every time | Always pass `--schema` |
| No init-time schema selection | Missed setup opportunity | Manual config |
---
## Proposed Architecture
### New File Structure
```
openspec/
├── config.yaml # Project config (NEW)
├── schemas/ # Project-local schemas (NEW)
│ └── my-workflow/
│ ├── schema.yaml
│ └── templates/
│ ├── research.md
│ ├── proposal.md
│ └── ...
└── changes/
└── add-auth/
├── change.yaml # Change metadata (NEW)
├── proposal.md
└── ...
```
### config.yaml (Project Config)
```yaml
# openspec/config.yaml
defaultSchema: spec-driven
```
Sets the project-wide default schema. Used when:
- Creating new changes without `--schema`
- Running commands on changes without `change.yaml`
### change.yaml (Change Metadata)
```yaml
# openspec/changes/add-auth/change.yaml
schema: tdd
created: 2025-01-15T10:30:00Z
description: Add user authentication system
```
Binds a specific schema to a change. Created automatically by `openspec new change`.
### Schema Resolution Order
```
1. ./openspec/schemas/<name>/ # Project-local
2. ~/.local/share/openspec/schemas/<name>/ # User global (XDG)
3. <npm-package>/schemas/<name>/ # Built-in
```
Project-local takes priority, enabling version-controlled custom schemas.
### Schema Selection Order (Per Command)
```
1. --schema CLI flag # Explicit override
2. change.yaml in change directory # Change-specific binding
3. openspec/config.yaml defaultSchema # Project default
4. "spec-driven" # Hardcoded fallback
```
---
## Ideal User Experience
### Creating a Change
```bash
# Uses project default (from config.yaml, or spec-driven)
openspec new change add-auth
# Creates openspec/changes/add-auth/change.yaml:
# schema: spec-driven
# created: 2025-01-15T10:30:00Z
# Explicit schema for this change
openspec new change add-auth --schema tdd
# Creates change.yaml with schema: tdd
```
### Working with Changes
```bash
# Auto-reads schema from change.yaml — no --schema needed
openspec status --change add-auth
# Output: "Change: add-auth (schema: tdd)"
# Shows which artifacts are ready/blocked/done
# Explicit override still works (with informational message)
openspec status --change add-auth --schema spec-driven
# "Note: change.yaml specifies 'tdd', using 'spec-driven' per --schema flag"
```
### Customizing Schemas
```bash
# See what's available
openspec schema list
# Built-in:
# spec-driven proposal → specs → design → tasks
# tdd spec → tests → implementation → docs
# Project: (none)
# User: (none)
# Copy to project for customization
openspec schema copy spec-driven my-workflow
# Created ./openspec/schemas/my-workflow/
# Edit schema.yaml and templates/ to customize
# Copy to global (user-level override)
openspec schema copy spec-driven --global
# Created ~/.local/share/openspec/schemas/spec-driven/
# See where a schema resolves from
openspec schema which spec-driven
# ./openspec/schemas/spec-driven/ (project)
# or: ~/.local/share/openspec/schemas/spec-driven/ (user)
# or: /usr/local/lib/node_modules/openspec/schemas/spec-driven/ (built-in)
# Compare override with built-in
openspec schema diff spec-driven
# Shows diff between user/project version and package built-in
# Remove override, revert to built-in
openspec schema reset spec-driven
# Removes ./openspec/schemas/spec-driven/ (or --global for user dir)
```
### Project Setup
```bash
openspec init
# ? Select default workflow schema:
# > spec-driven (proposal → specs → design → tasks)
# tdd (spec → tests → implementation → docs)
# (custom schemas if detected)
#
# Writes to openspec/config.yaml:
# defaultSchema: spec-driven
```
---
## Implementation Phases
### Phase 1: Change Metadata (change.yaml)
**Priority:** High
**Solves:** "Forgot --schema", lost context, wrong results
**Scope:**
- Create `change.yaml` when running `openspec new change`
- Store `schema`, `created` timestamp
- Modify workflow commands to read schema from `change.yaml`
- `--schema` flag overrides (with informational message)
- Backwards compatible: missing `change.yaml` → use default
**change.yaml format:**
```yaml
schema: tdd
created: 2025-01-15T10:30:00Z
```
**Migration:**
- Existing changes without `change.yaml` continue to work
- Default to `spec-driven` (current behavior)
- Optional: `openspec migrate` to add `change.yaml` to existing changes
---
### Phase 2: Project-Local Schemas
**Priority:** High
**Solves:** Team sharing, version control, no XDG knowledge needed
**Scope:**
- Add `./openspec/schemas/` to resolution order (first priority)
- `openspec schema copy <name> [new-name]` creates in project by default
- `--global` flag for user-level XDG directory
- Teams can commit `openspec/schemas/` to repo
**Resolution order:**
```
1. ./openspec/schemas/<name>/ # Project-local (NEW)
2. ~/.local/share/openspec/schemas/<name>/ # User global
3. <npm-package>/schemas/<name>/ # Built-in
```
---
### Phase 3: Schema Management CLI
**Priority:** Medium
**Solves:** Path discovery, scaffolding, debugging
**Commands:**
```bash
openspec schema list # Show available schemas with sources
openspec schema which <name> # Show resolution path
openspec schema copy <name> [to] # Copy for customization
openspec schema diff <name> # Compare with built-in
openspec schema reset <name> # Remove override
openspec schema validate <name> # Validate schema.yaml structure
```
---
### Phase 4: Project Config + Init Enhancement
**Priority:** Low
**Solves:** Project-wide defaults, streamlined setup
**Scope:**
- Add `openspec/config.yaml` with `defaultSchema` field
- `openspec init` prompts for schema selection
- Store selection in `config.yaml`
- Commands use as fallback when no `change.yaml` exists
**config.yaml format:**
```yaml
defaultSchema: spec-driven
```
---
## Backwards Compatibility
| Scenario | Behavior |
|----------|----------|
| Existing change without `change.yaml` | Uses `--schema` flag or project default or `spec-driven` |
| Existing project without `config.yaml` | Falls back to `spec-driven` |
| `--schema` flag provided | Overrides `change.yaml` (with info message) |
| No project-local schemas dir | Skipped in resolution, checks user/built-in |
All existing functionality continues to work. New features are additive.
---
## Related Documents
- [Schema Customization](./schema-customization.md) — Details on manual override process and CLI gaps
- [Artifact POC](./artifact_poc.md) — Core artifact graph architecture
## Related Code
| File | Purpose |
|------|---------|
| `src/core/artifact-graph/resolver.ts` | Schema resolution logic |
| `src/core/artifact-graph/instruction-loader.ts` | Template loading |
| `src/core/global-config.ts` | XDG path helpers |
| `src/commands/artifact-workflow.ts` | CLI commands |
| `src/utils/change-utils.ts` | Change creation utilities |
@@ -0,0 +1,9 @@
## MODIFIED Requirements
### Requirement: Slash Command Configuration
The init command SHALL generate slash command files for supported editors using shared templates.
#### Scenario: Generating slash commands for Antigravity
- **WHEN** the user selects Antigravity during initialization
- **THEN** create `.agent/workflows/openspec-proposal.md`, `.agent/workflows/openspec-apply.md`, and `.agent/workflows/openspec-archive.md`
- **AND** ensure each file begins with YAML frontmatter that contains only a `description: <stage summary>` field followed by the shared OpenSpec workflow instructions wrapped in managed markers
- **AND** populate the workflow body with the same proposal/apply/archive guidance used for other tools so Antigravity behaves like Windsurf while pointing to the `.agent/workflows/` directory
@@ -0,0 +1,8 @@
## MODIFIED Requirements
### Requirement: Slash Command Updates
The update command SHALL refresh existing slash command files for configured tools without creating new ones, and ensure the OpenCode archive command accepts change ID arguments.
#### Scenario: Updating slash commands for Antigravity
- **WHEN** `.agent/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh the OpenSpec-managed portion of each file so the workflow copy matches other tools while preserving the existing single-field `description` frontmatter
- **AND** skip creating any missing workflow files during update, mirroring the behavior for Windsurf and other IDEs
@@ -0,0 +1,11 @@
## Why
Manual setup for new changes leads to formatting mistakes in spec deltas and slows agents who must recreate the same file skeletons for every proposal. A built-in scaffold command will generate compliant templates so assistants can focus on the change content instead of structure.
## What Changes
- Add an `openspec scaffold <change-id>` CLI command that creates a change directory (if it does not already exist) with validated `proposal.md`, `tasks.md`, and spec delta templates.
- Update CLI documentation and quick-reference guidance so agents discover the scaffold workflow before drafting files manually, including reminders on when to create spec deltas.
- Add automated coverage (unit/integ tests) to ensure the command respects naming rules, copies templates correctly, fails for existing directories, and produces output that passes `openspec validate --strict` untouched.
## Impact
- Affected specs: `specs/cli-scaffold`
- Affected code: `src/cli/index.ts`, `src/commands`, `docs/`
@@ -0,0 +1,36 @@
## ADDED Requirements
### Requirement: Scaffolding Command Registration
The CLI SHALL expose an `openspec scaffold <change-id>` command that validates the change identifier before generating files.
#### Scenario: Registering scaffold command
- **WHEN** a user runs `openspec scaffold add-user-notifications`
- **THEN** the CLI SHALL reject invalid identifiers (non kebab-case) before proceeding
- **AND** display usage documentation via `openspec scaffold --help`
- **AND** exit with code 0 after successful scaffolding
### Requirement: Change Directory Structure
The scaffold command SHALL create the standard change workspace (if it does not already exist) with proposal, tasks, optional design, and `specs/` directories laid out according to OpenSpec conventions.
#### Scenario: Generating change workspace
- **WHEN** scaffolding a new change with id `add-user-notifications`
- **THEN** create `openspec/changes/add-user-notifications/` if it does not exist
- **AND** copy the default template bundle (proposal, tasks, design placeholders) into that directory in a single operation
- **AND** create an empty `openspec/changes/add-user-notifications/specs/` directory ready for capability-specific deltas that will be authored later
### Requirement: Template Content Guidance
The scaffold command SHALL populate generated Markdown files with OpenSpec-compliant templates so authors can copy, edit, and pass validation without reformatting.
#### Scenario: Populating proposal and tasks templates
- **WHEN** the scaffold command writes `proposal.md`
- **THEN** include the `## Why`, `## What Changes`, and `## Impact` headings with placeholder guidance text
- **AND** ensure `tasks.md` starts with `## 1. Implementation` and numbered checklist items using `- [ ]` syntax
- **AND** annotate optional sections (like `design.md`) with inline TODO comments so users understand when to keep or delete them
- **AND** include a short reminder inside `specs/README.md` (or similar) instructing authors to add deltas once they know the affected capability
### Requirement: Idempotent Execution
The scaffold command SHALL be safe to rerun, preserving user edits while filling in any missing managed sections.
#### Scenario: Rerunning scaffold on existing change
- **WHEN** the command is executed again for an existing change directory containing user-edited files
- **THEN** leave existing content untouched except for managed placeholder regions or missing files that need creation
- **AND** update the filesystem summary to highlight which files were skipped, created, or refreshed
@@ -0,0 +1,12 @@
## 1. CLI scaffolding command
- [ ] 1.1 Register an `openspec scaffold` command in the CLI entrypoint with `change-id` argument validation.
- [ ] 1.2 Implement generator logic that copies the default change template bundle (`proposal.md`, `tasks.md`, optional `design.md`, `specs/README.md`) into `openspec/changes/<id>/`, creating the directory tree in a single pass.
- [ ] 1.3 Detect when `openspec/changes/<id>/` already exists and exit with a clear error instead of overwriting user files.
## 2. Templates and documentation
- [ ] 2.1 Update `openspec/AGENTS.md` quick reference so agents see `openspec scaffold` before drafting files manually.
- [ ] 2.2 Refresh CLI docs/README/help text to mention the scaffold workflow, template bundle contents, and when to add spec deltas manually.
## 3. Test coverage
- [ ] 3.1 Add unit tests covering name validation, template copying, and existing-directory failures.
- [ ] 3.2 Add integration coverage ensuring a freshly scaffolded change (without deltas) passes `openspec validate --strict` until the author customizes it.
@@ -1,15 +0,0 @@
# Change Proposal: Extend Shell Completions
## Why
Zsh completions provide an excellent developer experience, but many developers use bash, fish, or PowerShell. Extending completion support to these shells removes friction for the majority of developers who don't use Zsh.
## What Changes
This change adds bash, fish, and PowerShell completion support following the same architectural patterns, documentation methodology, and testing rigor established for Zsh completions.
## Deltas
- **Spec:** `cli-completion`
- **Operation:** MODIFIED
- **Description:** Extend completion generation, installation, and testing requirements to support bash, fish, and PowerShell while maintaining the existing Zsh implementation and architectural patterns
@@ -1,328 +0,0 @@
# cli-completion Spec Delta
## MODIFIED Requirements
### Requirement: Native Shell Behavior Integration
The completion system SHALL respect and integrate with each supported shell's native completion patterns and user interaction model.
#### Scenario: Zsh native completion
- **WHEN** generating Zsh completion scripts
- **THEN** use Zsh completion system with `_arguments`, `_describe`, and `compadd`
- **AND** completions SHALL trigger on single TAB (standard Zsh behavior)
- **AND** display as an interactive menu that users navigate with TAB/arrow keys
- **AND** support Oh My Zsh's enhanced menu styling automatically
#### Scenario: Bash native completion
- **WHEN** generating Bash completion scripts
- **THEN** use Bash completion with `complete` builtin and `COMPREPLY` array
- **AND** completions SHALL trigger on double TAB (standard Bash behavior)
- **AND** display as space-separated list or column format
- **AND** support both bash-completion v1 and v2 patterns
#### Scenario: Fish native completion
- **WHEN** generating Fish completion scripts
- **THEN** use Fish's `complete` command with conditions
- **AND** completions SHALL trigger on single TAB with auto-suggestion preview
- **AND** display with Fish's native coloring and description alignment
- **AND** leverage Fish's built-in caching automatically
#### Scenario: PowerShell native completion
- **WHEN** generating PowerShell completion scripts
- **THEN** use `Register-ArgumentCompleter` with scriptblock
- **AND** completions SHALL trigger on TAB with cycling behavior
- **AND** display with PowerShell's native completion UI
- **AND** support both Windows PowerShell 5.1 and PowerShell Core 7+
#### Scenario: No custom UX patterns
- **WHEN** implementing completion for any shell
- **THEN** do NOT attempt to customize completion trigger behavior
- **AND** do NOT override shell-specific navigation patterns
- **AND** ensure completions feel native to experienced users of that shell
### Requirement: Shell Detection
The completion system SHALL automatically detect the user's current shell environment.
#### Scenario: Detecting Zsh from environment
- **WHEN** no shell is explicitly specified
- **THEN** read the `$SHELL` environment variable
- **AND** extract the shell name from the path (e.g., `/bin/zsh` → `zsh`)
- **AND** validate the shell is one of: `zsh`, `bash`, `fish`, `powershell`
- **AND** throw an error if the shell is not supported
#### Scenario: Detecting Bash from environment
- **WHEN** `$SHELL` contains `bash` in the path
- **THEN** detect shell as `bash`
- **AND** proceed with bash-specific completion logic
#### Scenario: Detecting Fish from environment
- **WHEN** `$SHELL` contains `fish` in the path
- **THEN** detect shell as `fish`
- **AND** proceed with fish-specific completion logic
#### Scenario: Detecting PowerShell from environment
- **WHEN** `$PSModulePath` environment variable is present
- **THEN** detect shell as `powershell`
- **AND** proceed with PowerShell-specific completion logic
#### Scenario: Unsupported shell detection
- **WHEN** shell path indicates an unsupported shell
- **THEN** throw error: "Shell '<name>' is not supported. Supported shells: zsh, bash, fish, powershell"
### Requirement: Completion Generation
The completion command SHALL generate completion scripts for all supported shells on demand.
#### Scenario: Generating Zsh completion
- **WHEN** user executes `openspec completion generate zsh`
- **THEN** output a complete Zsh completion script to stdout
- **AND** include completions for all commands: init, list, show, validate, archive, view, update, change, spec, completion
- **AND** include all command-specific flags and options
- **AND** use Zsh's `_arguments` and `_describe` built-in functions
- **AND** support dynamic completion for change and spec IDs
#### Scenario: Generating Bash completion
- **WHEN** user executes `openspec completion generate bash`
- **THEN** output a complete Bash completion script to stdout
- **AND** include completions for all commands and subcommands
- **AND** use `complete -F` with custom completion function
- **AND** populate `COMPREPLY` with appropriate suggestions
- **AND** support dynamic completion for change and spec IDs via `openspec __complete`
#### Scenario: Generating Fish completion
- **WHEN** user executes `openspec completion generate fish`
- **THEN** output a complete Fish completion script to stdout
- **AND** use `complete -c openspec` with conditions
- **AND** include command-specific completions with `--condition` predicates
- **AND** support dynamic completion for change and spec IDs via `openspec __complete`
- **AND** include descriptions for each completion option
#### Scenario: Generating PowerShell completion
- **WHEN** user executes `openspec completion generate powershell`
- **THEN** output a complete PowerShell completion script to stdout
- **AND** use `Register-ArgumentCompleter -CommandName openspec`
- **AND** implement scriptblock that handles command context
- **AND** support dynamic completion for change and spec IDs via `openspec __complete`
- **AND** return `[System.Management.Automation.CompletionResult]` objects
### Requirement: Installation Automation
The completion command SHALL automatically install completion scripts into shell configuration files for all supported shells.
#### Scenario: Installing for Oh My Zsh
- **WHEN** user executes `openspec completion install zsh`
- **THEN** detect if Oh My Zsh is installed by checking for `$ZSH` environment variable or `~/.oh-my-zsh/` directory
- **AND** create custom completions directory at `~/.oh-my-zsh/custom/completions/` if it doesn't exist
- **AND** write completion script to `~/.oh-my-zsh/custom/completions/_openspec`
- **AND** ensure `~/.oh-my-zsh/custom/completions` is in `$fpath` by updating `~/.zshrc` if needed
- **AND** display success message with instruction to run `exec zsh` or restart terminal
#### Scenario: Installing for standard Zsh
- **WHEN** user executes `openspec completion install zsh` and Oh My Zsh is not detected
- **THEN** create completions directory at `~/.zsh/completions/` if it doesn't exist
- **AND** write completion script to `~/.zsh/completions/_openspec`
- **AND** add `fpath=(~/.zsh/completions $fpath)` to `~/.zshrc` if not already present
- **AND** add `autoload -Uz compinit && compinit` to `~/.zshrc` if not already present
- **AND** display success message with instruction to run `exec zsh` or restart terminal
#### Scenario: Installing for Bash with bash-completion
- **WHEN** user executes `openspec completion install bash`
- **THEN** detect if bash-completion is installed by checking for `/usr/share/bash-completion` or `/etc/bash_completion.d`
- **AND** if bash-completion is available, write to `/etc/bash_completion.d/openspec` (with sudo) or `~/.local/share/bash-completion/completions/openspec`
- **AND** if bash-completion is not available, write to `~/.bash_completion.d/openspec` and source it from `~/.bashrc`
- **AND** add sourcing line to `~/.bashrc` using marker-based updates if needed
- **AND** display success message with instruction to run `exec bash` or restart terminal
#### Scenario: Installing for Fish
- **WHEN** user executes `openspec completion install fish`
- **THEN** create Fish completions directory at `~/.config/fish/completions/` if it doesn't exist
- **AND** write completion script to `~/.config/fish/completions/openspec.fish`
- **AND** Fish automatically loads completions from this directory (no config file modification needed)
- **AND** display success message indicating completions are immediately available
#### Scenario: Installing for PowerShell
- **WHEN** user executes `openspec completion install powershell`
- **THEN** detect PowerShell profile location via `$PROFILE` environment variable or default paths
- **AND** create profile directory if it doesn't exist
- **AND** add completion script import to profile using marker-based updates
- **AND** write completion script to PowerShell modules directory or alongside profile
- **AND** display success message with instruction to restart PowerShell or run `. $PROFILE`
#### Scenario: Auto-detecting shell for installation
- **WHEN** user executes `openspec completion install` without specifying a shell
- **THEN** detect current shell using shell detection logic
- **AND** install completion for the detected shell (zsh, bash, fish, or powershell)
- **AND** display which shell was detected
#### Scenario: Already installed
- **WHEN** completion is already installed for the target shell
- **THEN** display message indicating completion is already installed
- **AND** offer to reinstall/update by overwriting existing files
- **AND** exit with code 0
### Requirement: Uninstallation
The completion command SHALL remove installed completion scripts and configuration for all supported shells.
#### Scenario: Uninstalling Zsh completion
- **WHEN** user executes `openspec completion uninstall zsh`
- **THEN** prompt for confirmation before proceeding (unless `--yes` flag provided)
- **AND** if user declines, cancel uninstall and display "Uninstall cancelled."
- **AND** if user confirms, remove `~/.oh-my-zsh/custom/completions/_openspec` if Oh My Zsh is detected
- **AND** remove `~/.zsh/completions/_openspec` if standard Zsh setup is detected
- **AND** remove fpath modifications from `~/.zshrc` using marker-based removal
- **AND** display success message
#### Scenario: Uninstalling Bash completion
- **WHEN** user executes `openspec completion uninstall bash`
- **THEN** prompt for confirmation (unless `--yes` flag provided)
- **AND** if user confirms, remove completion file from bash-completion directory or `~/.bash_completion.d/`
- **AND** remove sourcing lines from `~/.bashrc` using marker-based removal
- **AND** display success message
#### Scenario: Uninstalling Fish completion
- **WHEN** user executes `openspec completion uninstall fish`
- **THEN** prompt for confirmation (unless `--yes` flag provided)
- **AND** if user confirms, remove `~/.config/fish/completions/openspec.fish`
- **AND** display success message (no config file modification needed)
#### Scenario: Uninstalling PowerShell completion
- **WHEN** user executes `openspec completion uninstall powershell`
- **THEN** prompt for confirmation (unless `--yes` flag provided)
- **AND** if user confirms, remove completion import from PowerShell profile using marker-based removal
- **AND** remove completion script file
- **AND** display success message
#### Scenario: Auto-detecting shell for uninstallation
- **WHEN** user executes `openspec completion uninstall` without specifying a shell
- **THEN** detect current shell and uninstall completion for that shell
#### Scenario: Not installed
- **WHEN** attempting to uninstall completion that isn't installed
- **THEN** display error message indicating completion is not installed
- **AND** exit with code 1
### Requirement: Architecture Patterns
The completion implementation SHALL follow clean architecture principles with TypeScript best practices, supporting multiple shells through a plugin-based pattern.
#### Scenario: Shell-specific generators
- **WHEN** implementing completion generators
- **THEN** create generator classes for each shell: `ZshGenerator`, `BashGenerator`, `FishGenerator`, `PowerShellGenerator`
- **AND** implement a common `CompletionGenerator` interface with method:
- `generate(commands: CommandDefinition[]): string` - Returns complete shell script
- **AND** each generator handles shell-specific syntax, escaping, and patterns
- **AND** all generators consume the same `CommandDefinition[]` from the command registry
#### Scenario: Shell-specific installers
- **WHEN** implementing completion installers
- **THEN** create installer classes for each shell: `ZshInstaller`, `BashInstaller`, `FishInstaller`, `PowerShellInstaller`
- **AND** implement a common `CompletionInstaller` interface with methods:
- `install(script: string): Promise<InstallationResult>` - Installs completion script
- `uninstall(): Promise<{ success: boolean; message: string }>` - Removes completion
- **AND** each installer handles shell-specific paths, config files, and installation patterns
#### Scenario: Factory pattern for shell selection
- **WHEN** selecting shell-specific implementation
- **THEN** use `CompletionFactory` class with static methods:
- `createGenerator(shell: SupportedShell): CompletionGenerator`
- `createInstaller(shell: SupportedShell): CompletionInstaller`
- **AND** factory uses switch statements with TypeScript exhaustiveness checking
- **AND** adding new shell requires updating `SupportedShell` type and factory cases
#### Scenario: Dynamic completion providers
- **WHEN** implementing dynamic completions
- **THEN** create a `CompletionProvider` class that encapsulates project discovery logic
- **AND** implement methods:
- `getChangeIds(): Promise<string[]>` - Discovers active change IDs
- `getSpecIds(): Promise<string[]>` - Discovers spec IDs
- `isOpenSpecProject(): boolean` - Checks if current directory is OpenSpec-enabled
- **AND** implement caching with 2-second TTL using class properties
#### Scenario: Command registry
- **WHEN** defining completable commands
- **THEN** create a centralized `CommandDefinition` type with properties:
- `name: string` - Command name
- `description: string` - Help text
- `flags: FlagDefinition[]` - Available flags
- `acceptsPositional: boolean` - Whether command takes positional arguments
- `positionalType: string` - Type of positional (change-id, spec-id, path, shell)
- `subcommands?: CommandDefinition[]` - Nested subcommands
- **AND** export a `COMMAND_REGISTRY` constant with all command definitions
- **AND** all generators consume this registry to ensure consistency across shells
#### Scenario: Type-safe shell detection
- **WHEN** implementing shell detection
- **THEN** define a `SupportedShell` type as literal type: `'zsh' | 'bash' | 'fish' | 'powershell'`
- **AND** implement `detectShell()` function in `src/utils/shell-detection.ts`
- **AND** return detected shell or throw error with supported shells list
### Requirement: Testing Support
The completion implementation SHALL be testable with unit and integration tests for all supported shells.
#### Scenario: Mock shell environment
- **WHEN** writing tests for shell detection
- **THEN** allow overriding `$SHELL` and `$PSModulePath` environment variables
- **AND** use dependency injection for file system operations
- **AND** test detection for all four shells independently
#### Scenario: Generator output verification
- **WHEN** testing completion generators
- **THEN** create test suite for each shell generator (zsh, bash, fish, powershell)
- **AND** verify generated scripts contain expected patterns for that shell
- **AND** test that command registry is properly consumed
- **AND** ensure dynamic completion placeholders are present
- **AND** verify shell-specific syntax and escaping
#### Scenario: Installer simulation
- **WHEN** testing installation logic
- **THEN** create test suite for each shell installer
- **AND** use temporary test directories instead of actual home directories
- **AND** verify file creation without modifying real shell configurations
- **AND** test path resolution logic independently
- **AND** mock file system operations to avoid side effects
#### Scenario: Cross-shell consistency
- **WHEN** testing completion behavior
- **THEN** verify all shells support the same commands and flags
- **AND** verify dynamic completions work consistently across shells
- **AND** ensure error messages are consistent across shells
@@ -1,49 +0,0 @@
# Implementation Tasks
## Phase 1: Foundation and Bash Support
- [x] Update `SupportedShell` type in `src/utils/shell-detection.ts` to include `'bash' | 'fish' | 'powershell'`
- [x] Extend shell detection logic to recognize bash, fish, and PowerShell from environment variables
- [x] Create `src/core/completions/generators/bash-generator.ts` implementing `CompletionGenerator` interface
- [x] Create `src/core/completions/installers/bash-installer.ts` implementing `CompletionInstaller` interface
- [x] Update `CompletionFactory.createGenerator()` to support bash
- [x] Update `CompletionFactory.createInstaller()` to support bash
- [x] Create test file `test/core/completions/generators/bash-generator.test.ts` mirroring zsh test structure
- [x] Create test file `test/core/completions/installers/bash-installer.test.ts` mirroring zsh test structure
- [x] Verify bash completions work manually: `openspec completion install bash && exec bash`
## Phase 2: Fish Support
- [x] Create `src/core/completions/generators/fish-generator.ts` implementing `CompletionGenerator` interface
- [x] Create `src/core/completions/installers/fish-installer.ts` implementing `CompletionInstaller` interface
- [x] Update `CompletionFactory.createGenerator()` to support fish
- [x] Update `CompletionFactory.createInstaller()` to support fish
- [x] Create test file `test/core/completions/generators/fish-generator.test.ts`
- [x] Create test file `test/core/completions/installers/fish-installer.test.ts`
- [x] Verify fish completions work manually: `openspec completion install fish`
## Phase 3: PowerShell Support
- [x] Create `src/core/completions/generators/powershell-generator.ts` implementing `CompletionGenerator` interface
- [x] Create `src/core/completions/installers/powershell-installer.ts` implementing `CompletionInstaller` interface
- [x] Update `CompletionFactory.createGenerator()` to support powershell
- [x] Update `CompletionFactory.createInstaller()` to support powershell
- [x] Create test file `test/core/completions/generators/powershell-generator.test.ts`
- [x] Create test file `test/core/completions/installers/powershell-installer.test.ts`
- [x] Verify PowerShell completions work manually on Windows or macOS PowerShell
## Phase 4: Documentation and Testing
- [x] Update `CLAUDE.md` or relevant documentation to mention all four supported shells
- [x] Add cross-shell consistency test verifying all shells support same commands
- [x] Run `pnpm test` to ensure all tests pass
- [x] Run `pnpm run build` to verify TypeScript compilation
- [x] Test all shells on different platforms (Linux for bash/fish/zsh, Windows/macOS for PowerShell)
## Phase 5: Validation and Cleanup
- [x] Run `openspec validate extend-shell-completions --strict` and resolve all issues
- [x] Update error messages to list all four supported shells
- [x] Verify `openspec completion --help` documentation is current
- [x] Test auto-detection works for all shells
- [x] Ensure uninstall works cleanly for all shells
@@ -1,105 +0,0 @@
# Delta for CLI Init
## MODIFIED Requirements
### Requirement: Slash Command Configuration
The init command SHALL generate slash command files for supported editors using shared templates.
#### Scenario: Generating slash commands for Antigravity
- **WHEN** the user selects Antigravity during initialization
- **THEN** create `.agent/workflows/openspec-proposal.md`, `.agent/workflows/openspec-apply.md`, and `.agent/workflows/openspec-archive.md`
- **AND** ensure each file begins with YAML frontmatter that contains only a `description: <stage summary>` field followed by the shared OpenSpec workflow instructions wrapped in managed markers
- **AND** populate the workflow body with the same proposal/apply/archive guidance used for other tools so Antigravity behaves like Windsurf while pointing to the `.agent/workflows/` directory
#### 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 CodeBuddy Code
- **WHEN** the user selects CodeBuddy Code during initialization
- **THEN** create `.codebuddy/commands/openspec/proposal.md`, `.codebuddy/commands/openspec/apply.md`, and `.codebuddy/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 Cline
- **WHEN** the user selects Cline during initialization
- **THEN** create `.clinerules/openspec-proposal.md`, `.clinerules/openspec-apply.md`, and `.clinerules/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** include Cline-specific Markdown heading frontmatter
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Crush
- **WHEN** the user selects Crush during initialization
- **THEN** create `.crush/commands/openspec/proposal.md`, `.crush/commands/openspec/apply.md`, and `.crush/commands/openspec/archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** include Crush-specific frontmatter with OpenSpec category and tags
- **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 Factory Droid
- **WHEN** the user selects Factory Droid during initialization
- **THEN** create `.factory/commands/openspec-proposal.md`, `.factory/commands/openspec-apply.md`, and `.factory/commands/openspec-archive.md`
- **AND** populate each file from shared templates that include Factory-compatible YAML frontmatter for the `description` and `argument-hint` fields
- **AND** include the `$ARGUMENTS` placeholder in the template body so droid receives any user-supplied input
- **AND** wrap the generated content in OpenSpec managed markers so `openspec update` can safely refresh the commands
#### 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
#### Scenario: Generating slash commands for Windsurf
- **WHEN** the user selects Windsurf during initialization
- **THEN** create `.windsurf/workflows/openspec-proposal.md`, `.windsurf/workflows/openspec-apply.md`, and `.windsurf/workflows/openspec-archive.md`
- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Kilo Code
- **WHEN** the user selects Kilo Code during initialization
- **THEN** create `.kilocode/workflows/openspec-proposal.md`, `.kilocode/workflows/openspec-apply.md`, and `.kilocode/workflows/openspec-archive.md`
- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Codex
- **WHEN** the user selects Codex during initialization
- **THEN** create global prompt files at `~/.codex/prompts/openspec-proposal.md`, `~/.codex/prompts/openspec-apply.md`, and `~/.codex/prompts/openspec-archive.md` (or under `$CODEX_HOME/prompts` if set)
- **AND** populate each file from shared templates that map the first numbered placeholder (`$1`) to the primary user input (e.g., change identifier or question text)
- **AND** wrap the generated content in OpenSpec markers so `openspec update` can refresh the prompts without touching surrounding custom notes
#### Scenario: Generating slash commands for GitHub Copilot
- **WHEN** the user selects GitHub Copilot during initialization
- **THEN** create `.github/prompts/openspec-proposal.prompt.md`, `.github/prompts/openspec-apply.prompt.md`, and `.github/prompts/openspec-archive.prompt.md`
- **AND** populate each file with YAML frontmatter containing a `description` field that summarizes the workflow stage
- **AND** include `$ARGUMENTS` placeholder to capture user input
- **AND** wrap the shared template body with OpenSpec markers so `openspec update` can refresh the content
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Gemini CLI
- **WHEN** the user selects Gemini CLI during initialization
- **THEN** create `.gemini/commands/openspec/proposal.toml`, `.gemini/commands/openspec/apply.toml`, and `.gemini/commands/openspec/archive.toml`
- **AND** populate each file as TOML that sets a stage-specific `description = "<summary>"` and a multi-line `prompt = """` block with the shared OpenSpec template
- **AND** wrap the OpenSpec managed markers (`<!-- OPENSPEC:START -->` / `<!-- OPENSPEC:END -->`) inside the `prompt` value so `openspec update` can safely refresh the body between markers without touching the TOML framing
- **AND** ensure the slash-command copy matches the existing proposal/apply/archive templates used by other tools
#### Scenario: Generating slash commands for iFlow CLI
- **WHEN** the user selects iFlow CLI during initialization
- **THEN** create `.iflow/commands/openspec-proposal.md`, `.iflow/commands/openspec-apply.md`, and `.iflow/commands/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** include YAML frontmatter with `name`, `id`, `category`, and `description` fields for each command
- **AND** wrap the generated content in OpenSpec managed markers so `openspec update` can safely refresh the commands
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for RooCode
- **WHEN** the user selects RooCode during initialization
- **THEN** create `.roo/commands/openspec-proposal.md`, `.roo/commands/openspec-apply.md`, and `.roo/commands/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** include simple Markdown headings (e.g., `# OpenSpec: Proposal`) without YAML frontmatter
- **AND** wrap the generated content in OpenSpec managed markers where applicable so `openspec update` can safely refresh the commands
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
@@ -1,92 +0,0 @@
# Delta for CLI Update
## MODIFIED Requirements
### Requirement: Slash Command Updates
The update command SHALL refresh existing slash command files for configured tools without creating new ones, and ensure the OpenCode archive command accepts change ID arguments.
#### Scenario: Updating slash commands for Antigravity
- **WHEN** `.agent/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh the OpenSpec-managed portion of each file so the workflow copy matches other tools while preserving the existing single-field `description` frontmatter
- **AND** skip creating any missing workflow files during update, mirroring the behavior for Windsurf and other IDEs
#### 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 CodeBuddy Code
- **WHEN** `.codebuddy/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 Cline
- **WHEN** `.clinerules/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using shared templates
- **AND** include Cline-specific Markdown heading frontmatter
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Updating slash commands for Crush
- **WHEN** `.crush/commands/` contains `openspec/proposal.md`, `openspec/apply.md`, and `openspec/archive.md`
- **THEN** refresh each file using shared templates
- **AND** include Crush-specific frontmatter with OpenSpec category and tags
- **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 Factory Droid
- **WHEN** `.factory/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using the shared Factory templates that include YAML frontmatter for the `description` and `argument-hint` fields
- **AND** ensure the template body retains the `$ARGUMENTS` placeholder so user input keeps flowing into droid
- **AND** update only the content inside the OpenSpec managed markers, leaving any unmanaged notes untouched
- **AND** skip creating missing files during update
#### 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
- **AND** ensure the archive command includes `$ARGUMENTS` placeholder in frontmatter for accepting change ID arguments
#### Scenario: Updating slash commands for Windsurf
- **WHEN** `.windsurf/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using shared templates wrapped in OpenSpec markers
- **AND** ensure templates include instructions for the relevant workflow stage
- **AND** skip creating missing files (the update command only refreshes what already exists)
#### Scenario: Updating slash commands for Kilo Code
- **WHEN** `.kilocode/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using shared templates wrapped in OpenSpec markers
- **AND** ensure templates include instructions for the relevant workflow stage
- **AND** skip creating missing files (the update command only refreshes what already exists)
#### Scenario: Updating slash commands for Codex
- **GIVEN** the global Codex prompt directory contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **WHEN** a user runs `openspec update`
- **THEN** refresh each file using the shared slash-command templates (including placeholder guidance)
- **AND** preserve any unmanaged content outside the OpenSpec marker block
- **AND** skip creation when a Codex prompt file is missing
#### Scenario: Updating slash commands for GitHub Copilot
- **WHEN** `.github/prompts/` contains `openspec-proposal.prompt.md`, `openspec-apply.prompt.md`, and `openspec-archive.prompt.md`
- **THEN** refresh each file using shared templates while preserving the YAML frontmatter
- **AND** update only the OpenSpec-managed block between markers
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Updating slash commands for Gemini CLI
- **WHEN** `.gemini/commands/openspec/` contains `proposal.toml`, `apply.toml`, and `archive.toml`
- **THEN** refresh the body of each file using the shared proposal/apply/archive templates
- **AND** replace only the content between `<!-- OPENSPEC:START -->` and `<!-- OPENSPEC:END -->` markers inside the `prompt = """` block so the TOML framing (`description`, `prompt`) stays intact
- **AND** skip creating any missing `.toml` files during update; only pre-existing Gemini commands are refreshed
#### Scenario: Updating slash commands for iFlow CLI
- **WHEN** `.iflow/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using shared templates
- **AND** preserve the YAML frontmatter with `name`, `id`, `category`, and `description` fields
- **AND** update only the OpenSpec-managed block between markers
- **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
@@ -1,105 +0,0 @@
# Delta for CLI Init
## MODIFIED Requirements
### Requirement: Slash Command Configuration
The init command SHALL generate slash command files for supported editors using shared templates.
#### Scenario: Generating slash commands for Antigravity
- **WHEN** the user selects Antigravity during initialization
- **THEN** create `.agent/workflows/openspec-proposal.md`, `.agent/workflows/openspec-apply.md`, and `.agent/workflows/openspec-archive.md`
- **AND** ensure each file begins with YAML frontmatter that contains only a `description: <stage summary>` field followed by the shared OpenSpec workflow instructions wrapped in managed markers
- **AND** populate the workflow body with the same proposal/apply/archive guidance used for other tools so Antigravity behaves like Windsurf while pointing to the `.agent/workflows/` directory
#### 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 CodeBuddy Code
- **WHEN** the user selects CodeBuddy Code during initialization
- **THEN** create `.codebuddy/commands/openspec/proposal.md`, `.codebuddy/commands/openspec/apply.md`, and `.codebuddy/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 Cline
- **WHEN** the user selects Cline during initialization
- **THEN** create `.clinerules/workflows/openspec-proposal.md`, `.clinerules/workflows/openspec-apply.md`, and `.clinerules/workflows/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** include Cline-specific Markdown heading frontmatter
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Crush
- **WHEN** the user selects Crush during initialization
- **THEN** create `.crush/commands/openspec/proposal.md`, `.crush/commands/openspec/apply.md`, and `.crush/commands/openspec/archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** include Crush-specific frontmatter with OpenSpec category and tags
- **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 Factory Droid
- **WHEN** the user selects Factory Droid during initialization
- **THEN** create `.factory/commands/openspec-proposal.md`, `.factory/commands/openspec-apply.md`, and `.factory/commands/openspec-archive.md`
- **AND** populate each file from shared templates that include Factory-compatible YAML frontmatter for the `description` and `argument-hint` fields
- **AND** include the `$ARGUMENTS` placeholder in the template body so droid receives any user-supplied input
- **AND** wrap the generated content in OpenSpec managed markers so `openspec update` can safely refresh the commands
#### 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
#### Scenario: Generating slash commands for Windsurf
- **WHEN** the user selects Windsurf during initialization
- **THEN** create `.windsurf/workflows/openspec-proposal.md`, `.windsurf/workflows/openspec-apply.md`, and `.windsurf/workflows/openspec-archive.md`
- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Kilo Code
- **WHEN** the user selects Kilo Code during initialization
- **THEN** create `.kilocode/workflows/openspec-proposal.md`, `.kilocode/workflows/openspec-apply.md`, and `.kilocode/workflows/openspec-archive.md`
- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Codex
- **WHEN** the user selects Codex during initialization
- **THEN** create global prompt files at `~/.codex/prompts/openspec-proposal.md`, `~/.codex/prompts/openspec-apply.md`, and `~/.codex/prompts/openspec-archive.md` (or under `$CODEX_HOME/prompts` if set)
- **AND** populate each file from shared templates that map the first numbered placeholder (`$1`) to the primary user input (e.g., change identifier or question text)
- **AND** wrap the generated content in OpenSpec markers so `openspec update` can refresh the prompts without touching surrounding custom notes
#### Scenario: Generating slash commands for GitHub Copilot
- **WHEN** the user selects GitHub Copilot during initialization
- **THEN** create `.github/prompts/openspec-proposal.prompt.md`, `.github/prompts/openspec-apply.prompt.md`, and `.github/prompts/openspec-archive.prompt.md`
- **AND** populate each file with YAML frontmatter containing a `description` field that summarizes the workflow stage
- **AND** include `$ARGUMENTS` placeholder to capture user input
- **AND** wrap the shared template body with OpenSpec markers so `openspec update` can refresh the content
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Gemini CLI
- **WHEN** the user selects Gemini CLI during initialization
- **THEN** create `.gemini/commands/openspec/proposal.toml`, `.gemini/commands/openspec/apply.toml`, and `.gemini/commands/openspec/archive.toml`
- **AND** populate each file as TOML that sets a stage-specific `description = "<summary>"` and a multi-line `prompt = """` block with the shared OpenSpec template
- **AND** wrap the OpenSpec managed markers (`<!-- OPENSPEC:START -->` / `<!-- OPENSPEC:END -->`) inside the `prompt` value so `openspec update` can safely refresh the body between markers without touching the TOML framing
- **AND** ensure the slash-command copy matches the existing proposal/apply/archive templates used by other tools
#### Scenario: Generating slash commands for iFlow CLI
- **WHEN** the user selects iFlow CLI during initialization
- **THEN** create `.iflow/commands/openspec-proposal.md`, `.iflow/commands/openspec-apply.md`, and `.iflow/commands/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** include YAML frontmatter with `name`, `id`, `category`, and `description` fields for each command
- **AND** wrap the generated content in OpenSpec managed markers so `openspec update` can safely refresh the commands
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for RooCode
- **WHEN** the user selects RooCode during initialization
- **THEN** create `.roo/commands/openspec-proposal.md`, `.roo/commands/openspec-apply.md`, and `.roo/commands/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** include simple Markdown headings (e.g., `# OpenSpec: Proposal`) without YAML frontmatter
- **AND** wrap the generated content in OpenSpec managed markers where applicable so `openspec update` can safely refresh the commands
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
@@ -1,92 +0,0 @@
# Delta for CLI Update
## MODIFIED Requirements
### Requirement: Slash Command Updates
The update command SHALL refresh existing slash command files for configured tools without creating new ones, and ensure the OpenCode archive command accepts change ID arguments.
#### Scenario: Updating slash commands for Antigravity
- **WHEN** `.agent/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh the OpenSpec-managed portion of each file so the workflow copy matches other tools while preserving the existing single-field `description` frontmatter
- **AND** skip creating any missing workflow files during update, mirroring the behavior for Windsurf and other IDEs
#### 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 CodeBuddy Code
- **WHEN** `.codebuddy/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 Cline
- **WHEN** `.clinerules/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using shared templates
- **AND** include Cline-specific Markdown heading frontmatter
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Updating slash commands for Crush
- **WHEN** `.crush/commands/` contains `openspec/proposal.md`, `openspec/apply.md`, and `openspec/archive.md`
- **THEN** refresh each file using shared templates
- **AND** include Crush-specific frontmatter with OpenSpec category and tags
- **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 Factory Droid
- **WHEN** `.factory/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using the shared Factory templates that include YAML frontmatter for the `description` and `argument-hint` fields
- **AND** ensure the template body retains the `$ARGUMENTS` placeholder so user input keeps flowing into droid
- **AND** update only the content inside the OpenSpec managed markers, leaving any unmanaged notes untouched
- **AND** skip creating missing files during update
#### 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
- **AND** ensure the archive command includes `$ARGUMENTS` placeholder in frontmatter for accepting change ID arguments
#### Scenario: Updating slash commands for Windsurf
- **WHEN** `.windsurf/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using shared templates wrapped in OpenSpec markers
- **AND** ensure templates include instructions for the relevant workflow stage
- **AND** skip creating missing files (the update command only refreshes what already exists)
#### Scenario: Updating slash commands for Kilo Code
- **WHEN** `.kilocode/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using shared templates wrapped in OpenSpec markers
- **AND** ensure templates include instructions for the relevant workflow stage
- **AND** skip creating missing files (the update command only refreshes what already exists)
#### Scenario: Updating slash commands for Codex
- **GIVEN** the global Codex prompt directory contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **WHEN** a user runs `openspec update`
- **THEN** refresh each file using the shared slash-command templates (including placeholder guidance)
- **AND** preserve any unmanaged content outside the OpenSpec marker block
- **AND** skip creation when a Codex prompt file is missing
#### Scenario: Updating slash commands for GitHub Copilot
- **WHEN** `.github/prompts/` contains `openspec-proposal.prompt.md`, `openspec-apply.prompt.md`, and `openspec-archive.prompt.md`
- **THEN** refresh each file using shared templates while preserving the YAML frontmatter
- **AND** update only the OpenSpec-managed block between markers
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Updating slash commands for Gemini CLI
- **WHEN** `.gemini/commands/openspec/` contains `proposal.toml`, `apply.toml`, and `archive.toml`
- **THEN** refresh the body of each file using the shared proposal/apply/archive templates
- **AND** replace only the content between `<!-- OPENSPEC:START -->` and `<!-- OPENSPEC:END -->` markers inside the `prompt = """` block so the TOML framing (`description`, `prompt`) stays intact
- **AND** skip creating any missing `.toml` files during update; only pre-existing Gemini commands are refreshed
#### Scenario: Updating slash commands for iFlow CLI
- **WHEN** `.iflow/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using shared templates
- **AND** preserve the YAML frontmatter with `name`, `id`, `category`, and `description` fields
- **AND** update only the OpenSpec-managed block between markers
- **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
@@ -1,26 +0,0 @@
## Why
With per-change schema metadata in place (see `add-per-change-schema-metadata`), agents can now create changes with different workflow schemas. However, the agent skills are still hardcoded to `spec-driven` artifacts and don't offer schema selection to users.
## What Changes
**Scope: Experimental artifact workflow agent skills**
**Depends on:** `add-per-change-schema-metadata` (must be implemented first)
- Update `openspec-new-change` skill to prompt user for schema selection
- Update `openspec-continue-change` skill to work with any schema's artifacts
- Update `openspec-apply-change` skill to handle schema-specific task structures
- Add schema descriptions to help users choose appropriate workflow
## Capabilities
### Modified Capabilities
- `cli-artifact-workflow`: Agent skills support dynamic schema selection
## Impact
- **Affected code**: `src/core/templates/skill-templates.ts`
- **User experience**: Users can choose TDD, spec-driven, or future workflows when starting a change
- **Agent behavior**: Skills read artifact list from schema rather than hardcoding
- **Backward compatible**: Default remains `spec-driven` if user doesn't choose
@@ -1,32 +0,0 @@
## Prerequisites
- [x] 0.1 Implement `add-per-change-schema-metadata` change first
## 1. Schema Discovery
- [x] 1.1 Add CLI command or helper to list schemas with descriptions (for agent use)
- [x] 1.2 Ensure `openspec templates --schema <name>` returns artifact list for any schema
## 2. Update New Change Skill
- [x] 2.1 Add schema selection prompt using AskUserQuestion tool
- [x] 2.2 Present available schemas with descriptions (spec-driven, tdd, etc.)
- [x] 2.3 Pass selected schema to `openspec new change --schema <name>`
- [x] 2.4 Update output to show which schema/workflow was selected
## 3. Update Continue Change Skill
- [x] 3.1 Remove hardcoded artifact references (proposal, specs, design, tasks)
- [x] 3.2 Read artifact list dynamically from `openspec status --json`
- [x] 3.3 Adjust artifact creation guidelines to be schema-agnostic
- [x] 3.4 Handle schema-specific artifact types (e.g., TDD's `tests` artifact)
## 4. Update Apply Change Skill
- [x] 4.1 Make task detection work with different schema structures
- [x] 4.2 Adjust context file reading for schema-specific artifacts
## 5. Documentation
- [x] 5.1 Add schema descriptions to help text or skill instructions
- [x] 5.2 Document when to use each schema (TDD for bug fixes, spec-driven for features, etc.)
@@ -1,147 +0,0 @@
## Context
The experimental artifact workflow supports multiple schemas (`spec-driven`, `tdd`), but schema selection must be passed on every command. This creates friction for agents and users.
We need a lightweight metadata file to persist the schema choice per change.
## Goals / Non-Goals
**Goals:**
- Store schema choice once at change creation
- Auto-detect schema in experimental workflow commands
- Maintain backward compatibility (no metadata = default)
- Validate metadata with Zod schema
**Non-Goals:**
- Migrate existing changes (they use default)
- Extend to legacy commands
- Store additional metadata beyond schema (keep minimal for now)
## Decisions
### Decision: Zod Schema Design
The metadata file (`.openspec.yaml`) will be validated with this Zod schema:
```typescript
// src/core/artifact-graph/types.ts (or new metadata.ts)
import { z } from 'zod';
import { listSchemas } from './resolver.js';
/**
* Schema for per-change metadata stored in .openspec.yaml
*/
export const ChangeMetadataSchema = z.object({
// Required: which workflow schema this change uses
schema: z.string().min(1, { message: 'schema is required' }).refine(
(val) => listSchemas().includes(val),
(val) => ({ message: `Unknown schema '${val}'. Available: ${listSchemas().join(', ')}` })
),
// Optional: creation timestamp (ISO date string)
created: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, {
message: 'created must be YYYY-MM-DD format'
}).optional(),
});
export type ChangeMetadata = z.infer<typeof ChangeMetadataSchema>;
```
**Rationale:**
- `schema` is required and validated against available schemas at parse time
- `created` is optional, ISO date format for consistency
- Minimal fields - can extend later without breaking existing files
- Follows existing codebase pattern (see `ArtifactSchema`, `SchemaYamlSchema`)
### Decision: File Location and Format
**Location:** `openspec/changes/<name>/.openspec.yaml`
**Format:**
```yaml
schema: tdd
created: 2025-01-05
```
**Alternatives considered:**
- `change.yaml` - less hidden, but clutters directory
- Frontmatter in `proposal.md` - couples to proposal existence
- `openspec.json` - YAML matches existing schema files
### Decision: Read/Write Functions
```typescript
// src/utils/change-metadata.ts
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as yaml from 'yaml';
import { ChangeMetadataSchema, type ChangeMetadata } from '../core/artifact-graph/types.js';
const METADATA_FILENAME = '.openspec.yaml';
export function writeChangeMetadata(
changeDir: string,
metadata: ChangeMetadata
): void {
// Validate before writing
const validated = ChangeMetadataSchema.parse(metadata);
const content = yaml.stringify(validated);
fs.writeFileSync(path.join(changeDir, METADATA_FILENAME), content);
}
export function readChangeMetadata(
changeDir: string
): ChangeMetadata | null {
const metaPath = path.join(changeDir, METADATA_FILENAME);
if (!fs.existsSync(metaPath)) {
return null;
}
const content = fs.readFileSync(metaPath, 'utf-8');
const parsed = yaml.parse(content);
// Validate and return (throws ZodError if invalid)
return ChangeMetadataSchema.parse(parsed);
}
```
### Decision: Schema Resolution Order
When determining which schema to use:
1. **Explicit `--schema` flag** (highest priority - user override)
2. **`.openspec.yaml` metadata** (persisted choice)
3. **Default `spec-driven`** (fallback)
```typescript
function resolveSchemaForChange(
changeDir: string,
explicitSchema?: string
): string {
if (explicitSchema) return explicitSchema;
const metadata = readChangeMetadata(changeDir);
if (metadata?.schema) return metadata.schema;
return 'spec-driven';
}
```
## Risks / Trade-offs
- **Extra file per change** → Minimal overhead, hidden file
- **YAML parsing dependency** → Already using `yaml` package for schema files
- **Schema validation at read time** → Fail fast with clear error if corrupted
## Migration Plan
No migration needed:
- Existing changes without `.openspec.yaml` continue to work (use default)
- New changes created with `openspec new change --schema X` get metadata file
## Open Questions
- Should `openspec new change` prompt for schema interactively if not specified? (Leaning no - default is fine)
@@ -1,29 +0,0 @@
## Why
Currently, the schema (workflow type) must be passed via `--schema` flag on every experimental workflow command. This is repetitive and error-prone. Agents have no way to know which schema a change uses, so they default to `spec-driven` and cannot leverage alternative workflows like `tdd`.
## What Changes
**Scope: Experimental artifact workflow only** (`openspec new change`, `openspec status`, `openspec instructions`, `openspec templates`)
- Store schema choice in `.openspec.yaml` metadata file when creating a change via `openspec new change`
- Auto-detect schema from metadata in experimental workflow commands
- Make `--schema` flag optional (override only, metadata takes precedence)
- Add `--schema` option to `openspec new change` command
**Not affected**: Legacy commands (`openspec validate`, `openspec archive`, `openspec list`, `openspec show`)
## Capabilities
### New Capabilities
- `change-metadata`: Reading/writing per-change metadata files
### Modified Capabilities
- `cli-artifact-workflow`: Commands auto-detect schema from change metadata
## Impact
- **Affected code**: `src/utils/change-utils.ts`, `src/core/artifact-graph/instruction-loader.ts`, `src/commands/artifact-workflow.ts`
- **Agent skills**: Can be simplified - no longer need to pass schema explicitly
- **Backward compatible**: Changes without `.openspec.yaml` fall back to `spec-driven` default
- **Isolation**: All changes contained within experimental workflow code; legacy commands untouched
@@ -1,98 +0,0 @@
## ADDED Requirements
### Requirement: Change Metadata
The system SHALL store and validate per-change metadata in `.openspec.yaml` files using a Zod schema.
#### Scenario: Metadata file created with new change
- **WHEN** user runs `openspec new change add-feature --schema tdd`
- **THEN** the system creates `.openspec.yaml` in the change directory
- **AND** the file contains `schema: tdd` and `created: <YYYY-MM-DD>`
#### Scenario: Metadata validated on read
- **WHEN** the system reads `.openspec.yaml`
- **AND** the `schema` field references an unknown schema
- **THEN** the system displays a validation error listing available schemas
#### Scenario: Metadata schema validation
- **WHEN** `.openspec.yaml` contains invalid YAML or missing required fields
- **THEN** the system displays a Zod validation error with details
#### Scenario: Missing metadata file
- **WHEN** a change directory has no `.openspec.yaml` file
- **THEN** the system falls back to the default schema (`spec-driven`)
## MODIFIED Requirements
### Requirement: New Change Command
The system SHALL create new change directories with validation and optional schema metadata.
#### Scenario: Create valid change
- **WHEN** user runs `openspec new change add-feature`
- **THEN** the system creates `openspec/changes/add-feature/` directory
- **AND** creates `.openspec.yaml` with `schema: spec-driven` (default)
#### Scenario: Create change with schema
- **WHEN** user runs `openspec new change add-feature --schema tdd`
- **THEN** the system creates `openspec/changes/add-feature/` directory
- **AND** creates `.openspec.yaml` with `schema: tdd`
#### Scenario: Invalid schema on create
- **WHEN** user runs `openspec new change add-feature --schema unknown`
- **THEN** the system displays an error listing available schemas
- **AND** does not create the change directory
#### Scenario: Invalid change name
- **WHEN** user runs `openspec new change "Add Feature"` with invalid name
- **THEN** the system displays validation error with guidance
#### Scenario: Duplicate change name
- **WHEN** user runs `openspec new change existing-change` for an existing change
- **THEN** the system displays an error indicating the change already exists
#### Scenario: Create with description
- **WHEN** user runs `openspec new change add-feature --description "Add new feature"`
- **THEN** the system creates the change directory with description in README.md
### Requirement: Schema Selection
The system SHALL support custom schema selection for workflow commands, with automatic detection from change metadata.
#### Scenario: Schema auto-detected from metadata
- **WHEN** user runs `openspec status --change <id>` without `--schema`
- **AND** the change has `.openspec.yaml` with `schema: tdd`
- **THEN** the system uses the `tdd` schema
#### Scenario: Explicit schema overrides metadata
- **WHEN** user runs `openspec status --change <id> --schema spec-driven`
- **AND** the change has `.openspec.yaml` with `schema: tdd`
- **THEN** the system uses `spec-driven` (explicit flag wins)
#### Scenario: Default schema fallback
- **WHEN** user runs workflow commands without `--schema`
- **AND** the change has no `.openspec.yaml` file
- **THEN** the system uses the "spec-driven" schema
#### Scenario: Custom schema via flag
- **WHEN** user runs `openspec status --change <id> --schema tdd`
- **THEN** the system uses the specified schema for artifact graph
#### Scenario: Unknown schema
- **WHEN** user specifies an unknown schema
- **THEN** the system displays an error listing available schemas
@@ -1,29 +0,0 @@
## 1. Zod Schema and Types
- [x] 1.1 Add `ChangeMetadataSchema` Zod schema to `src/core/artifact-graph/types.ts`
- [x] 1.2 Export `ChangeMetadata` type inferred from schema
## 2. Core Metadata Functions
- [x] 2.1 Create `src/utils/change-metadata.ts` with `writeChangeMetadata()` function
- [x] 2.2 Add `readChangeMetadata()` function with Zod validation
- [x] 2.3 Update `createChange()` to accept optional `schema` param and write metadata
## 3. Auto-Detection in Instruction Loader
- [x] 3.1 Modify `loadChangeContext()` to read schema from `.openspec.yaml`
- [x] 3.2 Make `schemaName` parameter optional (fall back to metadata, then default)
## 4. CLI Updates
- [x] 4.1 Add `--schema <name>` option to `openspec new change` command
- [x] 4.2 Verify existing commands (`status`, `instructions`) work with auto-detection
## 5. Tests
- [x] 5.1 Test `ChangeMetadataSchema` validates correctly (valid/invalid cases)
- [x] 5.2 Test `writeChangeMetadata()` creates valid YAML
- [x] 5.3 Test `readChangeMetadata()` parses and validates schema
- [x] 5.4 Test `loadChangeContext()` auto-detects schema from metadata
- [x] 5.5 Test fallback to default when no metadata exists
- [x] 5.6 Test `--schema` flag overrides metadata
@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-01-06
@@ -1,77 +0,0 @@
## Context
Currently, delta specs are only applied to main specs when running `openspec archive`. This bundles two concerns:
1. Applying spec changes (delta → main)
2. Archiving the change (move to archive folder)
Users want flexibility to sync specs earlier, especially when iterating. The archive command already contains the reconciliation logic in `buildUpdatedSpec()`.
## Goals / Non-Goals
**Goals:**
- Decouple spec syncing from archiving
- Provide `/opsx:sync` skill for agents to sync specs on demand
- Keep operation idempotent (safe to run multiple times)
**Non-Goals:**
- Tracking whether specs have been synced (no state)
- Changing archive behavior (it will continue to apply specs)
- Supporting partial application (all deltas sync together)
## Decisions
### 1. Reuse existing reconciliation logic
**Decision**: Extract `buildUpdatedSpec()` logic from `ArchiveCommand` into a shared module.
**Rationale**: The archive command already implements delta parsing and application. Rather than duplicate, we extract and reuse.
**Alternatives considered**:
- Duplicate logic in new command (rejected: maintenance burden)
- Have sync call archive with flags (rejected: coupling)
### 2. No state tracking
**Decision**: Don't track whether specs have been synced. Each invocation reads delta and main specs, reconciles.
**Rationale**:
- Idempotent operations don't need state
- Avoids sync issues between flag and reality
- Simpler implementation and mental model
**Alternatives considered**:
- Track `specsSynced: true` in `.openspec.yaml` (rejected: unnecessary complexity)
- Store snapshot of synced deltas (rejected: over-engineering)
### 3. Agent-driven approach (no CLI command)
**Decision**: The `/opsx:sync` skill is fully agent-driven - the agent reads delta specs and directly edits main specs.
**Rationale**:
- Allows intelligent merging (add scenarios without copying entire requirements)
- Delta represents *intent*, not wholesale replacement
- More flexible and natural editing workflow
- Archive still uses programmatic merge (for finalized changes)
### 4. Archive behavior unchanged
**Decision**: Archive continues to apply specs as part of its flow. If specs are already reconciled, the operation is a no-op.
**Rationale**: Backward compatibility. Users who don't use `/opsx:sync` get the same experience.
## Risks / Trade-offs
**[Risk] Multiple changes modify same spec**
→ Last to sync wins. Same as today with archive. Users should coordinate or use sequential archives.
**[Risk] User syncs specs then continues editing deltas**
→ Running `/opsx:sync` again reconciles. Idempotent design handles this.
**[Trade-off] No undo mechanism**
→ Users can `git checkout` main specs if needed. Explicit undo command is out of scope.
## Implementation Approach
1. Extract spec application logic from `ArchiveCommand.buildUpdatedSpec()` into `src/core/specs-apply.ts`
2. Add skill template for `/opsx:sync` in `skill-templates.ts`
3. Register skill in managed skills
@@ -1,32 +0,0 @@
## Why
Spec application is currently bundled with archive - users must run `openspec archive` to apply delta specs to main specs. This couples two distinct concerns (applying specs vs. archiving the change) and forces users to wait until they're "done" to see main specs updated. Users want the flexibility to sync specs earlier in the workflow while iterating.
## What Changes
- Add `/opsx:sync` skill that syncs delta specs to main specs as a standalone action
- The operation is idempotent - safe to run multiple times, agent reconciles main specs to match deltas
- Archive continues to work as today (applies specs if not already reconciled, then moves to archive)
- No new state tracking - the agent reads delta and main specs, reconciles on each run
- Agent-driven approach allows intelligent merging (partial updates, adding scenarios)
**Workflow becomes:**
```
/opsx:new → /opsx:continue → /opsx:apply → archive
│
└── /opsx:sync (optional, anytime)
```
## Capabilities
### New Capabilities
- `specs-sync-skill`: Skill template for `/opsx:sync` command that reconciles main specs with delta specs
### Modified Capabilities
- None (agent-driven, no CLI command needed)
## Impact
- **Skills**: New `openspec-sync-specs` skill in `skill-templates.ts`
- **Archive**: No changes needed - already does reconciliation, will continue to work
- **Agent workflow**: Users gain flexibility to sync specs before archive
@@ -1,67 +0,0 @@
## ADDED Requirements
### Requirement: Specs Sync Skill
The system SHALL provide an `/opsx:sync` skill that syncs delta specs from a change to the main specs.
#### Scenario: Sync delta specs to main specs
- **WHEN** agent executes `/opsx:sync` with a change name
- **THEN** the agent reads delta specs from `openspec/changes/<name>/specs/`
- **AND** reads corresponding main specs from `openspec/specs/`
- **AND** reconciles main specs to match what the deltas describe
#### Scenario: Idempotent operation
- **WHEN** agent executes `/opsx:sync` multiple times on the same change
- **THEN** the result is the same as running it once
- **AND** no duplicate requirements are created
#### Scenario: Change selection prompt
- **WHEN** agent executes `/opsx:sync` without specifying a change
- **THEN** the agent prompts user to select from available changes
- **AND** shows changes that have delta specs
### Requirement: Delta Reconciliation Logic
The agent SHALL reconcile main specs with delta specs using the delta operation headers.
#### Scenario: ADDED requirements
- **WHEN** delta contains `## ADDED Requirements` with a requirement
- **AND** the requirement does not exist in main spec
- **THEN** add the requirement to main spec
#### Scenario: ADDED requirement already exists
- **WHEN** delta contains `## ADDED Requirements` with a requirement
- **AND** a requirement with the same name already exists in main spec
- **THEN** update the existing requirement to match the delta version
#### Scenario: MODIFIED requirements
- **WHEN** delta contains `## MODIFIED Requirements` with a requirement
- **AND** the requirement exists in main spec
- **THEN** replace the requirement in main spec with the delta version
#### Scenario: REMOVED requirements
- **WHEN** delta contains `## REMOVED Requirements` with a requirement name
- **AND** the requirement exists in main spec
- **THEN** remove the requirement from main spec
#### Scenario: RENAMED requirements
- **WHEN** delta contains `## RENAMED Requirements` with FROM:/TO: format
- **AND** the FROM requirement exists in main spec
- **THEN** rename the requirement to the TO name
#### Scenario: New capability spec
- **WHEN** delta spec exists for a capability not in main specs
- **THEN** create new main spec file at `openspec/specs/<capability>/spec.md`
### Requirement: Skill Output
The skill SHALL provide clear feedback on what was synced.
#### Scenario: Show synced changes
- **WHEN** reconciliation completes successfully
- **THEN** display summary of changes per capability:
- Number of requirements added
- Number of requirements modified
- Number of requirements removed
- Number of requirements renamed
#### Scenario: No changes needed
- **WHEN** main specs already match delta specs
- **THEN** display "Specs already in sync - no changes needed"
@@ -1,40 +0,0 @@
## Tasks
### Core Implementation
- [x] Extract spec application logic from `ArchiveCommand` into `src/core/specs-apply.ts`
- Move `buildUpdatedSpec()`, `findSpecUpdates()`, `writeUpdatedSpec()` to shared module
- Keep `ArchiveCommand` importing from the new module
- Ensure all validation logic is preserved
### Skill Template
- [x] Add `getSyncSpecsSkillTemplate()` function in `src/core/templates/skill-templates.ts`
- Skill name: `openspec-sync-specs`
- Description: Sync delta specs to main specs
- **Agent-driven**: Instructions for agent to read deltas and edit main specs directly
- [x] Add `/opsx:sync` slash command template in `skill-templates.ts`
- Mirror the skill template for slash command format
- **Agent-driven**: No CLI command, agent does the merge
### Registration
- [x] Register skill in managed skills (via `artifact-experimental-setup`)
- Add to skill list with appropriate metadata
- Ensure it appears in setup output
### Design Decision
**Why agent-driven instead of CLI-driven?**
The programmatic merge operates at requirement-level granularity:
- MODIFIED requires copying ALL scenarios, not just the changed ones
- If agent forgets a scenario, it gets deleted
- Delta specs become bloated with copied content
Agent-driven approach:
- Agent can apply partial updates (add a scenario without copying others)
- Delta represents *intent*, not wholesale replacement
- More flexible and natural editing workflow
- Archive still uses programmatic merge (for finalized changes)
@@ -1,138 +0,0 @@
## Why
The `generateApplyInstructions` function is hardcoded to check for `spec-driven` artifacts (`proposal.md`, `specs/`, `design.md`, `tasks.md`). If a user selects a different schema like `tdd`, the apply instructions are meaningless - they check for files that don't exist in that schema.
This blocks the experimental workflow from supporting multiple schemas properly.
## What Changes
**Scope: Experimental artifact workflow** (`openspec instructions apply`)
**Depends on:** `add-per-change-schema-metadata` (to know which schema a change uses)
- Make `generateApplyInstructions` read artifact definitions from the schema
- Dynamically determine which artifacts exist based on schema
- Define when a change becomes "implementable" (see Design Decision below)
- Generate schema-appropriate context files and instructions
## Design Decision: When is a change implementable?
This is the key question. Different approaches:
### Option A: Explicit `apply` artifact in schema
Add a field to mark which artifact is the "implementation gate":
```yaml
artifacts:
- id: tasks
generates: tasks.md
apply: true # ← This artifact triggers apply mode
```
**Pros:** Explicit, flexible
**Cons:** Another field to maintain, what if multiple artifacts are `apply: true`?
### Option B: Leaf artifacts are implementable
The artifact(s) with no dependents (nothing depends on them) are the apply target.
- `spec-driven`: `tasks` is a leaf → apply = execute tasks
- `tdd`: `docs` is a leaf → but that doesn't make sense for TDD...
**Pros:** No extra schema field, derived from graph
**Cons:** Doesn't match TDD semantics (implementation is the action, not docs)
### Option C: Schema-level `apply_phase` definition
Add a top-level field to the schema:
```yaml
name: spec-driven
apply_phase:
requires: [tasks] # Must exist before apply
tracks: tasks.md # File with checkboxes to track
instruction: "Work through tasks, mark complete as you go"
```
```yaml
name: tdd
apply_phase:
requires: [tests] # Must have tests before implementing
tracks: null # No checkbox tracking - just make tests pass
instruction: "Run tests, implement until green, refactor"
```
**Pros:** Full flexibility, schema controls its own apply semantics
**Cons:** More complex schema format
### Option D: Convention-based (artifact ID matching)
If artifact ID is `tasks` or `implementation`, it's the apply target.
**Pros:** Simple, no schema changes
**Cons:** Brittle, doesn't work for custom schemas
### Option E: All artifacts complete → apply available
Apply becomes available when ALL schema artifacts exist. Implementation is whatever the user does after planning.
**Pros:** Simple, no schema changes
**Cons:** Doesn't guide what "apply" means for different workflows
---
## Decision: Add `apply` block to schema.yaml
Add a top-level `apply` field to schema definitions:
```yaml
name: spec-driven
version: 1
description: Default OpenSpec workflow
artifacts:
# ... existing artifacts ...
apply:
requires: [tasks] # Artifacts that must exist before apply
tracks: tasks.md # File with checkboxes for progress (optional)
instruction: | # Guidance shown to agent
Read context files, work through pending tasks, mark complete as you go.
Pause if you hit blockers or need clarification.
```
```yaml
name: tdd
version: 1
description: Test-driven development workflow
artifacts:
# ... existing artifacts ...
apply:
requires: [tests] # Must have tests before implementing
tracks: null # No checkbox tracking
instruction: |
Run tests to see failures. Implement minimal code to pass each test.
Refactor while keeping tests green.
```
**Key properties:**
- `requires`: Array of artifact IDs that must exist before apply is available
- `tracks`: Path to file with checkboxes (relative to change dir), or `null` if no tracking
- `instruction`: Custom guidance for the apply phase
**Fallback behavior:** Schemas without `apply` block default to "all artifacts must exist"
## Capabilities
### Modified Capabilities
- `cli-artifact-workflow`: Apply instructions become schema-aware
## Impact
- **Affected code**: `src/commands/artifact-workflow.ts` (generateApplyInstructions)
- **Schema format**: May need new `apply_phase` field
- **Existing schemas**: Need to add apply_phase to `spec-driven` and `tdd`
- **Backward compatible**: Schemas without apply_phase can use default behavior
@@ -1,60 +0,0 @@
## ADDED Requirements
### Requirement: Schema Apply Block
The system SHALL support an `apply` block in schema definitions that controls when and how implementation begins.
#### Scenario: Schema with apply block
- **WHEN** a schema defines an `apply` block
- **THEN** the system uses `apply.requires` to determine which artifacts must exist before apply
- **AND** uses `apply.tracks` to identify the file for progress tracking (or null if none)
- **AND** uses `apply.instruction` for guidance shown to the agent
#### Scenario: Schema without apply block
- **WHEN** a schema has no `apply` block
- **THEN** the system requires all artifacts to exist before apply is available
- **AND** uses default instruction: "All artifacts complete. Proceed with implementation."
### Requirement: Apply Instructions Command
The system SHALL generate schema-aware apply instructions via `openspec instructions apply`.
#### Scenario: Generate apply instructions
- **WHEN** user runs `openspec instructions apply --change <id>`
- **AND** all required artifacts (per schema's `apply.requires`) exist
- **THEN** the system outputs:
- Context files from all existing artifacts
- Schema-specific instruction text
- Progress tracking file path (if `apply.tracks` is set)
#### Scenario: Apply blocked by missing artifacts
- **WHEN** user runs `openspec instructions apply --change <id>`
- **AND** required artifacts are missing
- **THEN** the system indicates apply is blocked
- **AND** lists which artifacts must be created first
#### Scenario: Apply instructions JSON output
- **WHEN** user runs `openspec instructions apply --change <id> --json`
- **THEN** the system outputs JSON with:
- `contextFiles`: array of paths to existing artifacts
- `instruction`: the apply instruction text
- `tracks`: path to progress file or null
- `applyRequires`: list of required artifact IDs
## MODIFIED Requirements
### Requirement: Status Command
The system SHALL display artifact completion status for a change, including apply readiness.
#### Scenario: Status JSON includes apply requirements
- **WHEN** user runs `openspec status --change <id> --json`
- **THEN** the system outputs JSON with:
- `changeName`, `schemaName`, `isComplete`, `artifacts` array
- `applyRequires`: array of artifact IDs needed for apply phase
@@ -1,35 +0,0 @@
## Prerequisites
- [x] 0.1 Implement `add-per-change-schema-metadata` first (to auto-detect schema)
## 1. Schema Format
- [x] 1.1 Add `ApplyPhaseSchema` Zod schema to `src/core/artifact-graph/types.ts`
- [x] 1.2 Update `SchemaYamlSchema` to include optional `apply` field
- [x] 1.3 Export `ApplyPhase` type
## 2. Update Existing Schemas
- [x] 2.1 Add `apply` block to `schemas/spec-driven/schema.yaml`
- [x] 2.2 Add `apply` block to `schemas/tdd/schema.yaml`
## 3. Refactor generateApplyInstructions
- [x] 3.1 Load schema via `resolveSchema(schemaName)`
- [x] 3.2 Read `apply.requires` to determine required artifacts
- [x] 3.3 Check artifact existence dynamically (not hardcoded paths)
- [x] 3.4 Use `apply.tracks` for progress tracking (or skip if null)
- [x] 3.5 Use `apply.instruction` for the instruction text
- [x] 3.6 Build `contextFiles` from all existing artifacts in schema
## 4. Handle Fallback
- [x] 4.1 If schema has no `apply` block, require all artifacts to exist
- [x] 4.2 Default instruction: "All artifacts complete. Proceed with implementation."
## 5. Tests
- [x] 5.1 Test apply instructions with spec-driven schema
- [x] 5.2 Test apply instructions with tdd schema
- [x] 5.3 Test fallback when schema has no apply block
- [x] 5.4 Test blocked state when required artifacts missing
@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-01-07
@@ -1,84 +0,0 @@
## Context
The experimental workflow (OPSX) provides a complete lifecycle for creating changes:
- `/opsx:new` - Scaffold a new change with schema
- `/opsx:continue` - Create next artifact
- `/opsx:ff` - Fast-forward all artifacts
- `/opsx:apply` - Implement tasks
- `/opsx:sync` - Sync delta specs to main
The missing piece is archiving. The existing `openspec archive` command works but:
1. Applies specs programmatically (not agent-driven)
2. Doesn't use the artifact graph for completion checking
3. Doesn't integrate with the OPSX workflow philosophy
## Goals / Non-Goals
**Goals:**
- Add `/opsx:archive` skill to complete the OPSX workflow lifecycle
- Use artifact graph for schema-aware completion checking
- Integrate with `/opsx:sync` for agent-driven spec syncing
- Preserve `.openspec.yaml` schema metadata in archive
**Non-Goals:**
- Replacing the existing `openspec archive` CLI command
- Changing how specs are applied in the CLI command
- Modifying the artifact graph or schema system
## Decisions
### Decision 1: Skill-only implementation (no new CLI command)
The `/opsx:archive` will be a slash command/skill only, not a new CLI command.
**Rationale**: The existing `openspec archive` CLI command already handles the core archive functionality (moving to archive folder, date prefixing). The OPSX version just needs different pre-archive checks and optional sync prompting, which are agent behaviors better suited to a skill.
**Alternatives considered**:
- Adding flags to `openspec archive` (e.g., `--experimental`) - Rejected: adds complexity to CLI, harder to maintain two code paths
- New CLI command `openspec archive-experimental` - Rejected: unnecessary duplication, agent skills are the OPSX pattern
### Decision 2: Prompt for sync before archive
The skill will check for unsynced delta specs and prompt the user before archiving.
**Rationale**: The OPSX philosophy is agent-driven intelligent merging via `/opsx:sync`. Rather than programmatically applying specs like the regular archive command, we prompt the user to sync first if needed. This maintains workflow flexibility (user can decline and just archive).
**Flow**:
1. Check if `specs/` directory exists in the change
2. If yes, ask: "This change has delta specs. Would you like to sync them to main specs before archiving?"
3. If user says yes, execute `/opsx:sync` logic
4. Proceed with archive regardless of answer
### Decision 3: Use artifact graph for completion checking
The skill will use `openspec status --change "<name>" --json` to check artifact completion instead of just validating proposal.md and specs.
**Rationale**: The experimental workflow is schema-aware. Different schemas have different required artifacts. The artifact graph knows which artifacts are complete/incomplete for the current schema.
**Behavior**:
- Show warning if any artifacts are not `done`
- Don't block archive (user may have valid reasons to archive early)
- List incomplete artifacts so user can make informed decision
### Decision 4: Reuse tasks.md completion check from regular archive
The skill will parse tasks.md and warn about incomplete tasks, same as regular archive.
**Rationale**: Task completion checking is valuable regardless of workflow. The logic is simple (count `- [ ]` vs `- [x]`) and doesn't need special OPSX handling.
### Decision 5: Move change to archive/ with date prefix
Same archive behavior as regular command: move to `openspec/changes/archive/YYYY-MM-DD-<name>/`.
**Rationale**: Consistency with existing archive convention. The `.openspec.yaml` file moves with the change, preserving schema metadata.
## Risks / Trade-offs
**Risk**: Users confused about when to use `/opsx:archive` vs `openspec archive`
→ **Mitigation**: Documentation should clarify: use `/opsx:archive` if you've been using the OPSX workflow, use `openspec archive` otherwise. Both produce the same archived result.
**Risk**: Incomplete sync if user declines and has delta specs
→ **Mitigation**: The prompt is informational; user has full control. They may want to archive without syncing (e.g., abandoned change). Log a note in output.
**Trade-off**: No programmatic spec application in OPSX archive
→ **Accepted**: This is intentional. OPSX philosophy is agent-driven merging. If user wants programmatic application, use `openspec archive` instead.
@@ -1,28 +0,0 @@
## Why
The experimental workflow (OPSX) provides a schema-driven, artifact-by-artifact approach to creating changes with `/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/opsx:apply`, and `/opsx:sync`. However, there's no corresponding archive command to finalize and archive completed changes. Users must currently fall back to the regular `openspec archive` command, which doesn't integrate with the OPSX philosophy of agent-driven spec syncing and schema-aware artifact tracking.
## What Changes
- Add `/opsx:archive` slash command for archiving changes in the experimental workflow
- Use artifact graph to check completion status (schema-aware) instead of just validating proposal + specs
- Prompt for `/opsx:sync` before archiving instead of programmatically applying specs
- Preserve `.openspec.yaml` schema metadata when moving to archive
- Integrate with existing OPSX commands for a cohesive workflow
## Capabilities
### New Capabilities
- `opsx-archive-skill`: Slash command and skill for archiving completed changes in the experimental workflow. Checks artifact completion via artifact graph, verifies task completion, optionally syncs specs via `/opsx:sync`, and moves the change to `archive/YYYY-MM-DD-<name>/`.
### Modified Capabilities
(none - this is a new skill that doesn't modify existing specs)
## Impact
- New file: `.claude/commands/opsx/archive.md`
- New skill definition (generated via `openspec artifact-experimental-setup`)
- No changes to existing archive command or other OPSX commands
- Completes the OPSX command suite for full lifecycle management
@@ -1,122 +0,0 @@
## ADDED Requirements
### Requirement: OPSX Archive Skill
The system SHALL provide an `/opsx:archive` skill that archives completed changes in the experimental workflow.
#### Scenario: Archive a change with all artifacts complete
- **WHEN** agent executes `/opsx:archive` with a change name
- **AND** all artifacts in the schema are complete
- **AND** all tasks are complete
- **THEN** the agent moves the change to `openspec/changes/archive/YYYY-MM-DD-<name>/`
- **AND** displays success message with archived location
#### Scenario: Change selection prompt
- **WHEN** agent executes `/opsx:archive` without specifying a change
- **THEN** the agent prompts user to select from available changes
- **AND** shows only active changes (excludes archive/)
### Requirement: Artifact Completion Check
The skill SHALL check artifact completion status using the artifact graph before archiving.
#### Scenario: Incomplete artifacts warning
- **WHEN** agent checks artifact status
- **AND** one or more artifacts have status other than `done`
- **THEN** display warning listing incomplete artifacts
- **AND** prompt user for confirmation to continue
- **AND** proceed if user confirms
#### Scenario: All artifacts complete
- **WHEN** agent checks artifact status
- **AND** all artifacts have status `done`
- **THEN** proceed without warning
### Requirement: Task Completion Check
The skill SHALL check task completion status from tasks.md before archiving.
#### Scenario: Incomplete tasks found
- **WHEN** agent reads tasks.md
- **AND** incomplete tasks are found (marked with `- [ ]`)
- **THEN** display warning showing count of incomplete tasks
- **AND** prompt user for confirmation to continue
- **AND** proceed if user confirms
#### Scenario: All tasks complete
- **WHEN** agent reads tasks.md
- **AND** all tasks are complete (marked with `- [x]`)
- **THEN** proceed without task-related warning
#### Scenario: No tasks file
- **WHEN** tasks.md does not exist
- **THEN** proceed without task-related warning
### Requirement: Spec Sync Prompt
The skill SHALL prompt to sync delta specs before archiving if specs exist.
#### Scenario: Delta specs exist
- **WHEN** agent checks for delta specs
- **AND** `specs/` directory exists in the change with spec files
- **THEN** prompt user: "This change has delta specs. Would you like to sync them to main specs before archiving?"
- **AND** if user confirms, execute `/opsx:sync` logic
- **AND** proceed with archive regardless of sync choice
#### Scenario: No delta specs
- **WHEN** agent checks for delta specs
- **AND** no `specs/` directory or no spec files exist
- **THEN** proceed without sync prompt
### Requirement: Archive Process
The skill SHALL move the change to the archive folder with date prefix.
#### Scenario: Successful archive
- **WHEN** archiving a change
- **THEN** create `archive/` directory if it doesn't exist
- **AND** generate target name as `YYYY-MM-DD-<change-name>` using current date
- **AND** move entire change directory to archive location
- **AND** preserve `.openspec.yaml` file in archived change
#### Scenario: Archive already exists
- **WHEN** target archive directory already exists
- **THEN** fail with error message
- **AND** suggest renaming existing archive or using different date
### Requirement: Skill Output
The skill SHALL provide clear feedback about the archive operation.
#### Scenario: Archive complete with sync
- **WHEN** archive completes after syncing specs
- **THEN** display summary:
- Specs synced (from `/opsx:sync` output)
- Change archived to location
- Schema that was used
#### Scenario: Archive complete without sync
- **WHEN** archive completes without syncing specs
- **THEN** display summary:
- Note that specs were not synced (if applicable)
- Change archived to location
- Schema that was used
#### Scenario: Archive complete with warnings
- **WHEN** archive completes with incomplete artifacts or tasks
- **THEN** include note about what was incomplete
- **AND** suggest reviewing if archive was intentional
@@ -1,23 +0,0 @@
## 1. Create Slash Command
- [x] 1.1 Create `.claude/commands/opsx/archive.md` with skill definition
- [x] 1.2 Add YAML frontmatter (name, description, category, tags)
- [x] 1.3 Implement change selection logic (prompt if not provided)
- [x] 1.4 Implement artifact completion check using `openspec status --json`
- [x] 1.5 Implement task completion check (parse tasks.md for `- [ ]`)
- [x] 1.6 Implement spec sync prompt (check for specs/ directory, offer `/opsx:sync`)
- [x] 1.7 Implement archive process (move to archive/YYYY-MM-DD-<name>/)
- [x] 1.8 Add output formatting for success/warning cases
## 2. Regenerate Skills
- [x] 2.1 Run `openspec artifact-experimental-setup` to regenerate skills
- [x] 2.2 Verify skill appears in `.claude/skills/` directory
## 3. Testing
- [x] 3.1 Test `/opsx:archive` with a complete change (all artifacts, all tasks done)
- [x] 3.2 Test `/opsx:archive` with incomplete artifacts (verify warning shown)
- [x] 3.3 Test `/opsx:archive` with incomplete tasks (verify warning shown)
- [x] 3.4 Test `/opsx:archive` with delta specs (verify sync prompt shown)
- [x] 3.5 Test `/opsx:archive` without change name (verify selection prompt)
@@ -1,16 +0,0 @@
## Why
CodeBuddy slash command configurator currently uses inconsistent frontmatter fields compared to other tools. It uses `category` and `tags` fields (like Crush) but should use `argument-hint` field (like Factory, Auggie, and Codex) for better consistency. Additionally, the `proposal` command is missing frontmatter fields entirely. After reviewing CodeBuddy's official documentation, the correct format should use `description` and `argument-hint` fields with square bracket parameter format.
## What Changes
- Replace `category` and `tags` fields with `argument-hint` field in CodeBuddy frontmatter
- Add missing frontmatter fields to the `proposal` command
- Use correct square bracket format for `argument-hint` parameters (e.g., `[change-id]`)
- Ensure consistency with CodeBuddy's official documentation
## Impact
- Affected specs: cli-init, cli-update
- Affected code: `src/core/configurators/slash/codebuddy.ts`
- CodeBuddy users will get proper argument hints in the correct format for slash commands
@@ -1,75 +0,0 @@
## MODIFIED Requirements
### Requirement: Slash Command Configuration
The init command SHALL generate slash command files for supported editors using shared templates.
#### Scenario: Generating slash commands for Antigravity
- **WHEN** the user selects Antigravity during initialization
- **THEN** create `.agent/workflows/openspec-proposal.md`, `.agent/workflows/openspec-apply.md`, and `.agent/workflows/openspec-archive.md`
- **AND** ensure each file begins with YAML frontmatter that contains only a `description: <stage summary>` field followed by the shared OpenSpec workflow instructions wrapped in managed markers
- **AND** populate the workflow body with the same proposal/apply/archive guidance used for other tools so Antigravity behaves like Windsurf while pointing to the `.agent/workflows/` directory
#### 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 CodeBuddy Code
- **WHEN** the user selects CodeBuddy Code during initialization
- **THEN** create `.codebuddy/commands/openspec/proposal.md`, `.codebuddy/commands/openspec/apply.md`, and `.codebuddy/commands/openspec/archive.md`
- **AND** populate each file from shared templates that include CodeBuddy-compatible YAML frontmatter for the `description` and `argument-hint` fields
- **AND** use square bracket format for `argument-hint` parameters (e.g., `[change-id]`)
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Cline
- **WHEN** the user selects Cline during initialization
- **THEN** create `.clinerules/workflows/openspec-proposal.md`, `.clinerules/workflows/openspec-apply.md`, and `.clinerules/workflows/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** include Cline-specific Markdown heading frontmatter
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Crush
- **WHEN** the user selects Crush during initialization
- **THEN** create `.crush/commands/openspec/proposal.md`, `.crush/commands/openspec/apply.md`, and `.crush/commands/openspec/archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** include Crush-specific frontmatter with OpenSpec category and tags
- **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 Factory Droid
- **WHEN** the user selects Factory Droid during initialization
- **THEN** create `.factory/commands/openspec-proposal.md`, `.factory/commands/openspec-apply.md`, and `.factory/commands/openspec-archive.md`
- **AND** populate each file from shared templates that include Factory-compatible YAML frontmatter for the `description` and `argument-hint` fields
- **AND** include the `$ARGUMENTS` placeholder in the template body so droid receives any user-supplied input
- **AND** wrap the generated content in OpenSpec managed markers so `openspec update` can safely refresh the commands
#### 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
#### Scenario: Generating slash commands for Windsurf
- **WHEN** the user selects Windsurf during initialization
- **THEN** create `.windsurf/workflows/openspec-proposal.md`, `.windsurf/workflows/openspec-apply.md`, and `.windsurf/workflows/openspec-archive.md`
- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Kilo Code
- **WHEN** the user selects Kilo Code during initialization
- **THEN** create `.kilocode/workflows/openspec-proposal.md`, `.kilocode/workflows/openspec-apply.md`, and `.kilocode/workflows/openspec-archive.md`
- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Codex
- **WHEN** the user selects Codex during initialization
- **THEN** create global prompt files at `~/.codex/prompts/openspec-proposal.md`, `~/.codex/prompts/openspec-apply.md`, and `~/.codex/prompts/openspec-archive.md` (or under `$CODEX_HOME/prompts` if set)
- **AND** populate each file from shared templates that map the first numbered placeholder (`$1`) to the primary user input (e.g., change identifier or question text)
- **AND** wrap the generated content in OpenSpec markers so `openspec update` can refresh the prompts without touching surrounding custom notes
@@ -1,56 +0,0 @@
## MODIFIED Requirements
### Requirement: Slash Command Updates
The update command SHALL refresh existing slash command files for configured tools without creating new ones, and ensure the OpenCode archive command accepts change ID arguments.
#### Scenario: Updating slash commands for Antigravity
- **WHEN** `.agent/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh the OpenSpec-managed portion of each file so the workflow copy matches other tools while preserving the existing single-field `description` frontmatter
- **AND** skip creating any missing workflow files during update, mirroring the behavior for Windsurf and other IDEs
#### 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 CodeBuddy Code
- **WHEN** `.codebuddy/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`
- **THEN** refresh each file using the shared CodeBuddy templates that include YAML frontmatter for the `description` and `argument-hint` fields
- **AND** use square bracket format for `argument-hint` parameters (e.g., `[change-id]`)
- **AND** preserve any user customizations outside the OpenSpec managed markers
#### Scenario: Updating slash commands for Cline
- **WHEN** `.clinerules/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using shared templates
- **AND** include Cline-specific Markdown heading frontmatter
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Updating slash commands for Crush
- **WHEN** `.crush/commands/` contains `openspec/proposal.md`, `openspec/apply.md`, and `openspec/archive.md`
- **THEN** refresh each file using shared templates
- **AND** include Crush-specific frontmatter with OpenSpec category and tags
- **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 Factory Droid
- **WHEN** `.factory/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using the shared Factory templates that include YAML frontmatter for the `description` and `argument-hint` fields
- **AND** ensure the template body retains the `$ARGUMENTS` placeholder so user input keeps flowing into droid
- **AND** update only the content inside the OpenSpec managed markers, leaving any unmanaged notes untouched
- **AND** skip creating missing files during update
#### 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
- **AND** ensure the archive command includes `$ARGUMENTS` placeholder in frontmatter for accepting change ID arguments
#### Scenario: Updating slash commands for Windsurf
- **WHEN** `.windsurf/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using shared templates
- **AND** ensure templates include instructions for the relevant workflow stage
@@ -1,6 +0,0 @@
## 1. Implementation
- [x] 1.1 Update CodeBuddy frontmatter to use `argument-hint` instead of `category` and `tags`
- [x] 1.2 Add missing frontmatter fields to the `proposal` command
- [x] 1.3 Ensure all three commands (proposal, apply, archive) have consistent frontmatter structure
- [x] 1.4 Test the changes by running `openspec init` and `openspec update`
@@ -8,6 +8,6 @@ The Cline implementation was architecturally incorrect. According to Cline's off
- **BREAKING**: Existing Cline users will need to re-run `openspec init` to get the corrected workflow files
## Impact
- Affected specs: cli-init, cli-update (corrected Cline workflow paths)
- Affected specs: cli-init (corrected Cline workflow paths)
- Affected code: `src/core/configurators/slash/cline.ts`, test files, README.md
- Modified files: `.clinerules/workflows/openspec-*.md` (moved from `.clinerules/openspec-*.md`)
@@ -0,0 +1,11 @@
# Delta for CLI Init
## MODIFIED Requirements
### Requirement: Slash Command Configuration
#### Scenario: Generating slash commands for Cline
- **WHEN** the user selects Cline during initialization
- **THEN** create `.clinerules/workflows/openspec-proposal.md`, `.clinerules/workflows/openspec-apply.md`, and `.clinerules/workflows/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** include Cline-specific Markdown heading frontmatter
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
@@ -0,0 +1,12 @@
## Why
Validation currently errors on changes without spec deltas, even when the change is intentionally proposal-only or tooling-only. This creates false negatives and noisy CI.
## What Changes
- Make change validation scope-aware: validate only artifacts that exist.
- Only error on "No deltas found" if spec delta files exist but parse to zero deltas.
- Keep archive stricter: if specs exist but parse to zero deltas, fail; allow `--skip-specs` for tooling-only changes.
## Impact
- Affected specs: cli-validate
- Affected code: `src/commands/validate.ts`, `src/core/validation/validator.ts`
@@ -0,0 +1,25 @@
## ADDED Requirements
### Requirement: Scope-Aware Change Validation
The validator SHALL validate only artifacts that exist for a change, avoiding errors for proposal-only or tooling-only changes.
#### Scenario: Proposal-only change
- **WHEN** a change contains `proposal.md` but has no `specs/` directory or contains no `*/spec.md` files
- **THEN** validate the proposal (Why/What sections)
- **AND** do not require or validate spec deltas
#### Scenario: Delta validation when specs exist
- **WHEN** a change contains one or more `specs/<capability>/spec.md` files
- **THEN** validate delta-formatted specs with existing rules (SHALL/MUST, scenarios, duplicates, conflicts)
## MODIFIED Requirements
### Requirement: Validation SHALL provide actionable remediation steps
Validation output SHALL include specific guidance to fix each error, including expected structure, example headers, and suggested commands to verify fixes.
#### Scenario: No deltas found in change
- **WHEN** validating a change that contains `specs/` with one or more `*/spec.md` files but the parser finds zero deltas
- **THEN** show error "No deltas found" with guidance:
- Ensure `openspec/changes/{id}/specs/` has `.md` files that include delta headers
- Use delta headers: `## ADDED Requirements`, `## MODIFIED Requirements`, `## REMOVED Requirements`, `## RENAMED Requirements`
- Each requirement must include at least one `#### Scenario:` block
- Try: `openspec change show {id} --json --deltas-only` to inspect parsed deltas
@@ -0,0 +1,16 @@
## 1. Validator changes
- [ ] 1.1 Change `validateChangeDeltaSpecs` to only emit "Change must have at least one delta" when `specs/` exists and contains at least one `*/spec.md` but parsed total deltas is 0
- [ ] 1.2 Return valid (no error) when `specs/` directory is missing or has no `spec.md` files
## 2. CLI changes
- [ ] 2.1 In bulk validation, keep current behavior (call delta validator). Behavior remains correct after 1.1
- [ ] 2.2 Add a short INFO log in human-readable mode when a change has no `specs/` (optional)
## 3. Documentation
- [ ] 3.1 Update README and template: "Validation checks only existing artifacts. Proposal-only changes are valid without spec deltas."
## 4. Tests
- [ ] 4.1 Add test: proposal-only change passes validation without deltas
- [ ] 4.2 Add test: specs present but zero parsed deltas → ERROR
- [ ] 4.3 Add test: specs present with proper deltas → valid
+29 -61
View File
@@ -27,13 +27,6 @@ The system SHALL display artifact completion status for a change, including scaf
- **WHEN** user runs `openspec status --change <id> --json`
- **THEN** the system outputs JSON with changeName, schemaName, isComplete, and artifacts array
#### Scenario: Status JSON includes apply requirements
- **WHEN** user runs `openspec status --change <id> --json`
- **THEN** the system outputs JSON with:
- `changeName`, `schemaName`, `isComplete`, `artifacts` array
- `applyRequires`: array of artifact IDs needed for apply phase
#### Scenario: Status on scaffolded change
- **WHEN** user runs `openspec status --change <id>` on a change with no artifacts
@@ -53,6 +46,35 @@ The system SHALL display artifact completion status for a change, including scaf
- **AND** directory `openspec/changes/unknown-id/` does not exist
- **THEN** the system displays an error listing all available change directories
### Requirement: Next Command
The system SHALL show which artifacts are ready to be created, including for scaffolded changes.
#### Scenario: Show ready artifacts
- **WHEN** user runs `openspec next --change <id>`
- **THEN** the system lists artifacts whose dependencies are all satisfied
#### Scenario: No artifacts ready
- **WHEN** all artifacts are either completed or blocked
- **THEN** the system indicates no artifacts are ready (with explanation)
#### Scenario: All artifacts complete
- **WHEN** all artifacts in the change are completed
- **THEN** the system indicates the change is complete
#### Scenario: Next JSON output
- **WHEN** user runs `openspec next --change <id> --json`
- **THEN** the system outputs JSON array of ready artifact IDs
#### Scenario: Next on scaffolded change
- **WHEN** user runs `openspec next --change <id>` on a change with no artifacts
- **THEN** system shows root artifacts (e.g., "proposal") as ready to create
### Requirement: Instructions Command
The system SHALL output enriched instructions for creating an artifact, including for scaffolded changes.
@@ -166,57 +188,3 @@ The system SHALL implement artifact workflow commands in isolation for easy remo
- **WHEN** user runs `--help` on any artifact workflow command
- **THEN** help text indicates the command is experimental
### Requirement: Schema Apply Block
The system SHALL support an `apply` block in schema definitions that controls when and how implementation begins.
#### Scenario: Schema with apply block
- **WHEN** a schema defines an `apply` block
- **THEN** the system uses `apply.requires` to determine which artifacts must exist before apply
- **AND** uses `apply.tracks` to identify the file for progress tracking (or null if none)
- **AND** uses `apply.instruction` for guidance shown to the agent
#### Scenario: Schema without apply block
- **WHEN** a schema has no `apply` block
- **THEN** the system requires all artifacts to exist before apply is available
- **AND** uses default instruction: "All artifacts complete. Proceed with implementation."
### Requirement: Apply Instructions Command
The system SHALL generate schema-aware apply instructions via `openspec instructions apply`.
#### Scenario: Generate apply instructions
- **WHEN** user runs `openspec instructions apply --change <id>`
- **AND** all required artifacts (per schema's `apply.requires`) exist
- **THEN** the system outputs:
- Context files from all existing artifacts
- Schema-specific instruction text
- Progress tracking file path (if `apply.tracks` is set)
#### Scenario: Apply blocked by missing artifacts
- **WHEN** user runs `openspec instructions apply --change <id>`
- **AND** required artifacts are missing
- **THEN** the system indicates apply is blocked
- **AND** lists which artifacts must be created first
#### Scenario: Apply instructions JSON output
- **WHEN** user runs `openspec instructions apply --change <id> --json`
- **THEN** the system outputs JSON with:
- `contextFiles`: array of paths to existing artifacts
- `instruction`: the apply instruction text
- `tracks`: path to progress file or null
- `applyRequires`: list of required artifact IDs
## REMOVED Requirements
### Requirement: Next Command
**Reason**: Redundant with Status Command - `openspec status` already shows which artifacts are ready (status: "ready") vs blocked vs done.
**Migration**: Use `openspec status --change <id> --json` and filter artifacts with `status: "ready"` to find artifacts that can be created next.
+42 -187
View File
@@ -1,11 +1,11 @@
# cli-completion Specification
## Purpose
Provide shell completion scripts for the OpenSpec CLI, enabling tab-completion for commands, flags, and dynamic values (change IDs, spec IDs) across multiple shells. Supports Zsh, Bash, Fish, and PowerShell.
Provide shell completion scripts for the OpenSpec CLI, enabling tab-completion for commands, flags, and dynamic values (change IDs, spec IDs) in supported shells. Currently supports Zsh with architecture designed for future shell expansion.
## Requirements
### Requirement: Native Shell Behavior Integration
The completion system SHALL respect and integrate with each supported shell's native completion patterns and user interaction model.
The completion system SHALL respect and integrate with Zsh's native completion patterns and user interaction model.
#### Scenario: Zsh native completion
@@ -15,36 +15,12 @@ The completion system SHALL respect and integrate with each supported shell's na
- **AND** display as an interactive menu that users navigate with TAB/arrow keys
- **AND** support Oh My Zsh's enhanced menu styling automatically
#### Scenario: Bash native completion
- **WHEN** generating Bash completion scripts
- **THEN** use Bash completion with `complete` builtin and `COMPREPLY` array
- **AND** completions SHALL trigger on double TAB (standard Bash behavior)
- **AND** display as space-separated list or column format
- **AND** support both bash-completion v1 and v2 patterns
#### Scenario: Fish native completion
- **WHEN** generating Fish completion scripts
- **THEN** use Fish's `complete` command with conditions
- **AND** completions SHALL trigger on single TAB with auto-suggestion preview
- **AND** display with Fish's native coloring and description alignment
- **AND** leverage Fish's built-in caching automatically
#### Scenario: PowerShell native completion
- **WHEN** generating PowerShell completion scripts
- **THEN** use `Register-ArgumentCompleter` with scriptblock
- **AND** completions SHALL trigger on TAB with cycling behavior
- **AND** display with PowerShell's native completion UI
- **AND** support both Windows PowerShell 5.1 and PowerShell Core 7+
#### Scenario: No custom UX patterns
- **WHEN** implementing completion for any shell
- **WHEN** implementing Zsh completion
- **THEN** do NOT attempt to customize completion trigger behavior
- **AND** do NOT override shell-specific navigation patterns
- **AND** ensure completions feel native to experienced users of that shell
- **AND** do NOT override Zsh-specific navigation patterns
- **AND** ensure completions feel native to experienced Zsh users
### Requirement: Command Structure
@@ -67,35 +43,17 @@ The completion system SHALL automatically detect the user's current shell enviro
- **WHEN** no shell is explicitly specified
- **THEN** read the `$SHELL` environment variable
- **AND** extract the shell name from the path (e.g., `/bin/zsh` → `zsh`)
- **AND** validate the shell is one of: `zsh`, `bash`, `fish`, `powershell`
- **AND** throw an error if the shell is not supported
- **AND** validate the shell is `zsh`
- **AND** throw an error if the shell is not `zsh`, with message indicating only Zsh is currently supported
#### Scenario: Detecting Bash from environment
#### Scenario: Non-Zsh shell detection
- **WHEN** `$SHELL` contains `bash` in the path
- **THEN** detect shell as `bash`
- **AND** proceed with bash-specific completion logic
#### Scenario: Detecting Fish from environment
- **WHEN** `$SHELL` contains `fish` in the path
- **THEN** detect shell as `fish`
- **AND** proceed with fish-specific completion logic
#### Scenario: Detecting PowerShell from environment
- **WHEN** `$PSModulePath` environment variable is present
- **THEN** detect shell as `powershell`
- **AND** proceed with PowerShell-specific completion logic
#### Scenario: Unsupported shell detection
- **WHEN** shell path indicates an unsupported shell
- **THEN** throw error: "Shell '<name>' is not supported. Supported shells: zsh, bash, fish, powershell"
- **WHEN** shell path indicates bash, fish, powershell, or other non-Zsh shell
- **THEN** throw error: "Shell '<name>' is not supported yet. Currently supported: zsh"
### Requirement: Completion Generation
The completion command SHALL generate completion scripts for all supported shells on demand.
The completion command SHALL generate Zsh completion scripts on demand.
#### Scenario: Generating Zsh completion
@@ -106,33 +64,6 @@ The completion command SHALL generate completion scripts for all supported shell
- **AND** use Zsh's `_arguments` and `_describe` built-in functions
- **AND** support dynamic completion for change and spec IDs
#### Scenario: Generating Bash completion
- **WHEN** user executes `openspec completion generate bash`
- **THEN** output a complete Bash completion script to stdout
- **AND** include completions for all commands and subcommands
- **AND** use `complete -F` with custom completion function
- **AND** populate `COMPREPLY` with appropriate suggestions
- **AND** support dynamic completion for change and spec IDs via `openspec __complete`
#### Scenario: Generating Fish completion
- **WHEN** user executes `openspec completion generate fish`
- **THEN** output a complete Fish completion script to stdout
- **AND** use `complete -c openspec` with conditions
- **AND** include command-specific completions with `--condition` predicates
- **AND** support dynamic completion for change and spec IDs via `openspec __complete`
- **AND** include descriptions for each completion option
#### Scenario: Generating PowerShell completion
- **WHEN** user executes `openspec completion generate powershell`
- **THEN** output a complete PowerShell completion script to stdout
- **AND** use `Register-ArgumentCompleter -CommandName openspec`
- **AND** implement scriptblock that handles command context
- **AND** support dynamic completion for change and spec IDs via `openspec __complete`
- **AND** return `[System.Management.Automation.CompletionResult]` objects
### Requirement: Dynamic Completions
The completion system SHALL provide context-aware dynamic completions for project-specific values.
@@ -167,7 +98,7 @@ The completion system SHALL provide context-aware dynamic completions for projec
### Requirement: Installation Automation
The completion command SHALL automatically install completion scripts into shell configuration files for all supported shells.
The completion command SHALL automatically install completion scripts into shell configuration files.
#### Scenario: Installing for Oh My Zsh
@@ -187,37 +118,12 @@ The completion command SHALL automatically install completion scripts into shell
- **AND** add `autoload -Uz compinit && compinit` to `~/.zshrc` if not already present
- **AND** display success message with instruction to run `exec zsh` or restart terminal
#### Scenario: Installing for Bash with bash-completion
- **WHEN** user executes `openspec completion install bash`
- **THEN** detect if bash-completion is installed by checking for `/usr/share/bash-completion` or `/etc/bash_completion.d`
- **AND** if bash-completion is available, write to `/etc/bash_completion.d/openspec` (with sudo) or `~/.local/share/bash-completion/completions/openspec`
- **AND** if bash-completion is not available, write to `~/.bash_completion.d/openspec` and source it from `~/.bashrc`
- **AND** add sourcing line to `~/.bashrc` using marker-based updates if needed
- **AND** display success message with instruction to run `exec bash` or restart terminal
#### Scenario: Installing for Fish
- **WHEN** user executes `openspec completion install fish`
- **THEN** create Fish completions directory at `~/.config/fish/completions/` if it doesn't exist
- **AND** write completion script to `~/.config/fish/completions/openspec.fish`
- **AND** Fish automatically loads completions from this directory (no config file modification needed)
- **AND** display success message indicating completions are immediately available
#### Scenario: Installing for PowerShell
- **WHEN** user executes `openspec completion install powershell`
- **THEN** detect PowerShell profile location via `$PROFILE` environment variable or default paths
- **AND** create profile directory if it doesn't exist
- **AND** add completion script import to profile using marker-based updates
- **AND** write completion script to PowerShell modules directory or alongside profile
- **AND** display success message with instruction to restart PowerShell or run `. $PROFILE`
#### Scenario: Auto-detecting shell for installation
#### Scenario: Auto-detecting Zsh for installation
- **WHEN** user executes `openspec completion install` without specifying a shell
- **THEN** detect current shell using shell detection logic
- **AND** install completion for the detected shell (zsh, bash, fish, or powershell)
- **AND** install completion if detected shell is Zsh
- **AND** throw error if detected shell is not Zsh
- **AND** display which shell was detected
#### Scenario: Already installed
@@ -229,45 +135,23 @@ The completion command SHALL automatically install completion scripts into shell
### Requirement: Uninstallation
The completion command SHALL remove installed completion scripts and configuration for all supported shells.
The completion command SHALL remove installed completion scripts and configuration.
#### Scenario: Uninstalling Zsh completion
#### Scenario: Uninstalling Oh My Zsh completion
- **WHEN** user executes `openspec completion uninstall zsh`
- **THEN** prompt for confirmation before proceeding (unless `--yes` flag provided)
- **AND** if user declines, cancel uninstall and display "Uninstall cancelled."
- **AND** if user confirms, remove `~/.oh-my-zsh/custom/completions/_openspec` if Oh My Zsh is detected
- **AND** remove `~/.zsh/completions/_openspec` if standard Zsh setup is detected
- **AND** remove fpath modifications from `~/.zshrc` using marker-based removal
- **AND** remove fpath modifications from `~/.zshrc`
- **AND** display success message
#### Scenario: Uninstalling Bash completion
- **WHEN** user executes `openspec completion uninstall bash`
- **THEN** prompt for confirmation (unless `--yes` flag provided)
- **AND** if user confirms, remove completion file from bash-completion directory or `~/.bash_completion.d/`
- **AND** remove sourcing lines from `~/.bashrc` using marker-based removal
- **AND** display success message
#### Scenario: Uninstalling Fish completion
- **WHEN** user executes `openspec completion uninstall fish`
- **THEN** prompt for confirmation (unless `--yes` flag provided)
- **AND** if user confirms, remove `~/.config/fish/completions/openspec.fish`
- **AND** display success message (no config file modification needed)
#### Scenario: Uninstalling PowerShell completion
- **WHEN** user executes `openspec completion uninstall powershell`
- **THEN** prompt for confirmation (unless `--yes` flag provided)
- **AND** if user confirms, remove completion import from PowerShell profile using marker-based removal
- **AND** remove completion script file
- **AND** display success message
#### Scenario: Auto-detecting shell for uninstallation
#### Scenario: Auto-detecting Zsh for uninstallation
- **WHEN** user executes `openspec completion uninstall` without specifying a shell
- **THEN** detect current shell and uninstall completion for that shell
- **THEN** detect current shell and uninstall completion if shell is Zsh
- **AND** throw error if detected shell is not Zsh
#### Scenario: Not installed
@@ -277,34 +161,17 @@ The completion command SHALL remove installed completion scripts and configurati
### Requirement: Architecture Patterns
The completion implementation SHALL follow clean architecture principles with TypeScript best practices, supporting multiple shells through a plugin-based pattern.
The completion implementation SHALL follow clean architecture principles with TypeScript best practices.
#### Scenario: Shell-specific generators
- **WHEN** implementing completion generators
- **THEN** create generator classes for each shell: `ZshGenerator`, `BashGenerator`, `FishGenerator`, `PowerShellGenerator`
- **AND** implement a common `CompletionGenerator` interface with method:
- `generate(commands: CommandDefinition[]): string` - Returns complete shell script
- **AND** each generator handles shell-specific syntax, escaping, and patterns
- **AND** all generators consume the same `CommandDefinition[]` from the command registry
#### Scenario: Shell-specific installers
- **WHEN** implementing completion installers
- **THEN** create installer classes for each shell: `ZshInstaller`, `BashInstaller`, `FishInstaller`, `PowerShellInstaller`
- **AND** implement a common `CompletionInstaller` interface with methods:
- `install(script: string): Promise<InstallationResult>` - Installs completion script
- `uninstall(): Promise<{ success: boolean; message: string }>` - Removes completion
- **AND** each installer handles shell-specific paths, config files, and installation patterns
#### Scenario: Factory pattern for shell selection
- **WHEN** selecting shell-specific implementation
- **THEN** use `CompletionFactory` class with static methods:
- `createGenerator(shell: SupportedShell): CompletionGenerator`
- `createInstaller(shell: SupportedShell): CompletionInstaller`
- **AND** factory uses switch statements with TypeScript exhaustiveness checking
- **AND** adding new shell requires updating `SupportedShell` type and factory cases
- **THEN** create `ZshCompletionGenerator` class for Zsh
- **AND** implement a common `CompletionGenerator` interface with methods:
- `generate(): string` - Returns complete shell script
- `getInstallPath(): string` - Returns target installation path
- `getConfigFile(): string` - Returns shell configuration file path
- **AND** design interface to be extensible for future shells (bash, fish, powershell)
#### Scenario: Dynamic completion providers
@@ -323,18 +190,18 @@ The completion implementation SHALL follow clean architecture principles with Ty
- `name: string` - Command name
- `description: string` - Help text
- `flags: FlagDefinition[]` - Available flags
- `acceptsPositional: boolean` - Whether command takes positional arguments
- `positionalType: string` - Type of positional (change-id, spec-id, path, shell)
- `acceptsChangeId: boolean` - Whether command takes change ID argument
- `acceptsSpecId: boolean` - Whether command takes spec ID argument
- `subcommands?: CommandDefinition[]` - Nested subcommands
- **AND** export a `COMMAND_REGISTRY` constant with all command definitions
- **AND** all generators consume this registry to ensure consistency across shells
- **AND** generators consume this registry to ensure consistency
#### Scenario: Type-safe shell detection
- **WHEN** implementing shell detection
- **THEN** define a `SupportedShell` type as literal type: `'zsh' | 'bash' | 'fish' | 'powershell'`
- **AND** implement `detectShell()` function in `src/utils/shell-detection.ts`
- **AND** return detected shell or throw error with supported shells list
- **THEN** define a `SupportedShell` type as literal type: `'zsh'`
- **AND** implement `detectShell()` function that returns 'zsh' or throws error
- **AND** design type to be extensible (e.g., future: `'bash' | 'zsh' | 'fish' | 'powershell'`)
### Requirement: Error Handling
@@ -342,8 +209,8 @@ The completion command SHALL provide clear error messages for common failure sce
#### Scenario: Unsupported shell
- **WHEN** user requests completion for unsupported shell (e.g., ksh, csh, tcsh)
- **THEN** display error message: "Shell '<name>' is not supported yet. Currently supported: zsh, bash, fish, powershell"
- **WHEN** user requests completion for unsupported shell (bash, fish, powershell, etc.)
- **THEN** display error message: "Shell '<name>' is not supported yet. Currently supported: zsh"
- **AND** exit with code 1
#### Scenario: Permission errors during installation
@@ -361,7 +228,7 @@ The completion command SHALL provide clear error messages for common failure sce
#### Scenario: Shell not detected
- **WHEN** `openspec completion install` cannot detect current shell
- **WHEN** `openspec completion install` cannot detect current shell or detects non-Zsh shell
- **THEN** display error: "Could not auto-detect shell. Please specify shell explicitly."
- **AND** display usage hint: "Usage: openspec completion <operation> [shell]"
- **AND** exit with code 1
@@ -396,37 +263,25 @@ The completion command SHALL provide machine-parseable and human-readable output
### Requirement: Testing Support
The completion implementation SHALL be testable with unit and integration tests for all supported shells.
The completion implementation SHALL be testable with unit and integration tests.
#### Scenario: Mock shell environment
- **WHEN** writing tests for shell detection
- **THEN** allow overriding `$SHELL` and `$PSModulePath` environment variables
- **THEN** allow overriding `$SHELL` environment variable
- **AND** use dependency injection for file system operations
- **AND** test detection for all four shells independently
#### Scenario: Generator output verification
- **WHEN** testing completion generators
- **THEN** create test suite for each shell generator (zsh, bash, fish, powershell)
- **AND** verify generated scripts contain expected patterns for that shell
- **THEN** verify generated scripts contain expected patterns
- **AND** test that command registry is properly consumed
- **AND** ensure dynamic completion placeholders are present
- **AND** verify shell-specific syntax and escaping
#### Scenario: Installer simulation
#### Scenario: Installation simulation
- **WHEN** testing installation logic
- **THEN** create test suite for each shell installer
- **AND** use temporary test directories instead of actual home directories
- **THEN** use temporary test directories instead of actual home directories
- **AND** verify file creation without modifying real shell configurations
- **AND** test path resolution logic independently
- **AND** mock file system operations to avoid side effects
#### Scenario: Cross-shell consistency
- **WHEN** testing completion behavior
- **THEN** verify all shells support the same commands and flags
- **AND** verify dynamic completions work consistently across shells
- **AND** ensure error messages are consistent across shells
+2 -9
View File
@@ -172,12 +172,6 @@ The command SHALL use consistent exit codes to indicate different failure modes.
### Requirement: Slash Command Configuration
The init command SHALL generate slash command files for supported editors using shared templates.
#### Scenario: Generating slash commands for Antigravity
- **WHEN** the user selects Antigravity during initialization
- **THEN** create `.agent/workflows/openspec-proposal.md`, `.agent/workflows/openspec-apply.md`, and `.agent/workflows/openspec-archive.md`
- **AND** ensure each file begins with YAML frontmatter that contains only a `description: <stage summary>` field followed by the shared OpenSpec workflow instructions wrapped in managed markers
- **AND** populate the workflow body with the same proposal/apply/archive guidance used for other tools so Antigravity behaves like Windsurf while pointing to the `.agent/workflows/` directory
#### 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`
@@ -187,13 +181,12 @@ The init command SHALL generate slash command files for supported editors using
#### Scenario: Generating slash commands for CodeBuddy Code
- **WHEN** the user selects CodeBuddy Code during initialization
- **THEN** create `.codebuddy/commands/openspec/proposal.md`, `.codebuddy/commands/openspec/apply.md`, and `.codebuddy/commands/openspec/archive.md`
- **AND** populate each file from shared templates that include CodeBuddy-compatible YAML frontmatter for the `description` and `argument-hint` fields
- **AND** use square bracket format for `argument-hint` parameters (e.g., `[change-id]`)
- **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 Cline
- **WHEN** the user selects Cline during initialization
- **THEN** create `.clinerules/workflows/openspec-proposal.md`, `.clinerules/workflows/openspec-apply.md`, and `.clinerules/workflows/openspec-archive.md`
- **THEN** create `.clinerules/openspec-proposal.md`, `.clinerules/openspec-apply.md`, and `.clinerules/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** include Cline-specific Markdown heading frontmatter
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
+3 -10
View File
@@ -50,14 +50,8 @@ The update command SHALL always update the core OpenSpec files and display an AS
- **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, and ensure the OpenCode archive command accepts change ID arguments.
#### Scenario: Updating slash commands for Antigravity
- **WHEN** `.agent/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh the OpenSpec-managed portion of each file so the workflow copy matches other tools while preserving the existing single-field `description` frontmatter
- **AND** skip creating any missing workflow files during update, mirroring the behavior for Windsurf and other IDEs
#### 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
@@ -65,12 +59,11 @@ The update command SHALL refresh existing slash command files for configured too
#### Scenario: Updating slash commands for CodeBuddy Code
- **WHEN** `.codebuddy/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`
- **THEN** refresh each file using the shared CodeBuddy templates that include YAML frontmatter for the `description` and `argument-hint` fields
- **AND** use square bracket format for `argument-hint` parameters (e.g., `[change-id]`)
- **AND** preserve any user customizations outside the OpenSpec managed markers
- **THEN** refresh each file using shared templates
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Updating slash commands for Cline
- **WHEN** `.clinerules/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **WHEN** `.clinerules/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using shared templates
- **AND** include Cline-specific Markdown heading frontmatter
- **AND** ensure templates include instructions for the relevant workflow stage
-122
View File
@@ -1,122 +0,0 @@
# OPSX Archive Skill Spec
### Requirement: OPSX Archive Skill
The system SHALL provide an `/opsx:archive` skill that archives completed changes in the experimental workflow.
#### Scenario: Archive a change with all artifacts complete
- **WHEN** agent executes `/opsx:archive` with a change name
- **AND** all artifacts in the schema are complete
- **AND** all tasks are complete
- **THEN** the agent moves the change to `openspec/changes/archive/YYYY-MM-DD-<name>/`
- **AND** displays success message with archived location
#### Scenario: Change selection prompt
- **WHEN** agent executes `/opsx:archive` without specifying a change
- **THEN** the agent prompts user to select from available changes
- **AND** shows only active changes (excludes archive/)
### Requirement: Artifact Completion Check
The skill SHALL check artifact completion status using the artifact graph before archiving.
#### Scenario: Incomplete artifacts warning
- **WHEN** agent checks artifact status
- **AND** one or more artifacts have status other than `done`
- **THEN** display warning listing incomplete artifacts
- **AND** prompt user for confirmation to continue
- **AND** proceed if user confirms
#### Scenario: All artifacts complete
- **WHEN** agent checks artifact status
- **AND** all artifacts have status `done`
- **THEN** proceed without warning
### Requirement: Task Completion Check
The skill SHALL check task completion status from tasks.md before archiving.
#### Scenario: Incomplete tasks found
- **WHEN** agent reads tasks.md
- **AND** incomplete tasks are found (marked with `- [ ]`)
- **THEN** display warning showing count of incomplete tasks
- **AND** prompt user for confirmation to continue
- **AND** proceed if user confirms
#### Scenario: All tasks complete
- **WHEN** agent reads tasks.md
- **AND** all tasks are complete (marked with `- [x]`)
- **THEN** proceed without task-related warning
#### Scenario: No tasks file
- **WHEN** tasks.md does not exist
- **THEN** proceed without task-related warning
### Requirement: Spec Sync Prompt
The skill SHALL prompt to sync delta specs before archiving if specs exist.
#### Scenario: Delta specs exist
- **WHEN** agent checks for delta specs
- **AND** `specs/` directory exists in the change with spec files
- **THEN** prompt user: "This change has delta specs. Would you like to sync them to main specs before archiving?"
- **AND** if user confirms, execute `/opsx:sync` logic
- **AND** proceed with archive regardless of sync choice
#### Scenario: No delta specs
- **WHEN** agent checks for delta specs
- **AND** no `specs/` directory or no spec files exist
- **THEN** proceed without sync prompt
### Requirement: Archive Process
The skill SHALL move the change to the archive folder with date prefix.
#### Scenario: Successful archive
- **WHEN** archiving a change
- **THEN** create `archive/` directory if it doesn't exist
- **AND** generate target name as `YYYY-MM-DD-<change-name>` using current date
- **AND** move entire change directory to archive location
- **AND** preserve `.openspec.yaml` file in archived change
#### Scenario: Archive already exists
- **WHEN** target archive directory already exists
- **THEN** fail with error message
- **AND** suggest renaming existing archive or using different date
### Requirement: Skill Output
The skill SHALL provide clear feedback about the archive operation.
#### Scenario: Archive complete with sync
- **WHEN** archive completes after syncing specs
- **THEN** display summary:
- Specs synced (from `/opsx:sync` output)
- Change archived to location
- Schema that was used
#### Scenario: Archive complete without sync
- **WHEN** archive completes without syncing specs
- **THEN** display summary:
- Note that specs were not synced (if applicable)
- Change archived to location
- Schema that was used
#### Scenario: Archive complete with warnings
- **WHEN** archive completes with incomplete artifacts or tasks
- **THEN** include note about what was incomplete
- **AND** suggest reviewing if archive was intentional
-72
View File
@@ -1,72 +0,0 @@
# specs-sync-skill Specification
## Purpose
Defines the agent skill for syncing delta specs from changes to main specs.
## Requirements
### Requirement: Specs Sync Skill
The system SHALL provide an `/opsx:sync` skill that syncs delta specs from a change to the main specs.
#### Scenario: Sync delta specs to main specs
- **WHEN** agent executes `/opsx:sync` with a change name
- **THEN** the agent reads delta specs from `openspec/changes/<name>/specs/`
- **AND** reads corresponding main specs from `openspec/specs/`
- **AND** reconciles main specs to match what the deltas describe
#### Scenario: Idempotent operation
- **WHEN** agent executes `/opsx:sync` multiple times on the same change
- **THEN** the result is the same as running it once
- **AND** no duplicate requirements are created
#### Scenario: Change selection prompt
- **WHEN** agent executes `/opsx:sync` without specifying a change
- **THEN** the agent prompts user to select from available changes
- **AND** shows changes that have delta specs
### Requirement: Delta Reconciliation Logic
The agent SHALL reconcile main specs with delta specs using the delta operation headers.
#### Scenario: ADDED requirements
- **WHEN** delta contains `## ADDED Requirements` with a requirement
- **AND** the requirement does not exist in main spec
- **THEN** add the requirement to main spec
#### Scenario: ADDED requirement already exists
- **WHEN** delta contains `## ADDED Requirements` with a requirement
- **AND** a requirement with the same name already exists in main spec
- **THEN** update the existing requirement to match the delta version
#### Scenario: MODIFIED requirements
- **WHEN** delta contains `## MODIFIED Requirements` with a requirement
- **AND** the requirement exists in main spec
- **THEN** replace the requirement in main spec with the delta version
#### Scenario: REMOVED requirements
- **WHEN** delta contains `## REMOVED Requirements` with a requirement name
- **AND** the requirement exists in main spec
- **THEN** remove the requirement from main spec
#### Scenario: RENAMED requirements
- **WHEN** delta contains `## RENAMED Requirements` with FROM:/TO: format
- **AND** the FROM requirement exists in main spec
- **THEN** rename the requirement to the TO name
#### Scenario: New capability spec
- **WHEN** delta spec exists for a capability not in main specs
- **THEN** create new main spec file at `openspec/specs/<capability>/spec.md`
### Requirement: Skill Output
The skill SHALL provide clear feedback on what was applied.
#### Scenario: Show applied changes
- **WHEN** reconciliation completes successfully
- **THEN** display summary of changes per capability:
- Number of requirements added
- Number of requirements modified
- Number of requirements removed
- Number of requirements renamed
#### Scenario: No changes needed
- **WHEN** main specs already match delta specs
- **THEN** display "Specs already in sync - no changes needed"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@fission-ai/openspec",
"version": "0.18.0",
"version": "0.17.2",
"description": "AI-native system for spec-driven development",
"keywords": [
"openspec",
-120
View File
@@ -6,143 +6,23 @@ artifacts:
generates: proposal.md
description: Initial proposal document outlining the change
template: proposal.md
instruction: |
Create the proposal document that establishes WHY this change is needed.
Sections:
- **Why**: 1-2 sentences on the problem or opportunity. What problem does this solve? Why now?
- **What Changes**: Bullet list of changes. Be specific about new capabilities, modifications, or removals. Mark breaking changes with **BREAKING**.
- **Capabilities**: Identify which specs will be created or modified:
- **New Capabilities**: List capabilities being introduced. Each becomes a new `specs/<name>/spec.md`. Use kebab-case names (e.g., `user-auth`, `data-export`).
- **Modified Capabilities**: List existing capabilities whose REQUIREMENTS are changing. Only include if spec-level behavior changes (not just implementation details). Each needs a delta spec file. Check `openspec/specs/` for existing spec names. Leave empty if no requirement changes.
- **Impact**: Affected code, APIs, dependencies, or systems.
IMPORTANT: The Capabilities section is critical. It creates the contract between
proposal and specs phases. Research existing specs before filling this in.
Each capability listed here will need a corresponding spec file.
Keep it concise (1-2 pages). Focus on the "why" not the "how" -
implementation details belong in design.md.
This is the foundation - specs, design, and tasks all build on this.
requires: []
- id: specs
generates: "specs/**/*.md"
description: Detailed specifications for the change
template: spec.md
instruction: |
Create specification files that define WHAT the system should do.
Create one spec file per capability/feature area in specs/<name>/spec.md.
Delta operations (use ## headers):
- **ADDED Requirements**: New capabilities
- **MODIFIED Requirements**: Changed behavior - MUST include full updated content
- **REMOVED Requirements**: Deprecated features - MUST include **Reason** and **Migration**
- **RENAMED Requirements**: Name changes only - use FROM:/TO: format
Format requirements:
- Each requirement: `### Requirement: <name>` followed by description
- Use SHALL/MUST for normative requirements (avoid should/may)
- Each scenario: `#### Scenario: <name>` with WHEN/THEN format
- **CRITICAL**: Scenarios MUST use exactly 4 hashtags (`####`). Using 3 hashtags or bullets will fail silently.
- Every requirement MUST have at least one scenario.
MODIFIED requirements workflow:
1. Locate the existing requirement in openspec/specs/<capability>/spec.md
2. Copy the ENTIRE requirement block (from `### Requirement:` through all scenarios)
3. Paste under `## MODIFIED Requirements` and edit to reflect new behavior
4. Ensure header text matches exactly (whitespace-insensitive)
Common pitfall: Using MODIFIED with partial content loses detail at archive time.
If adding new concerns without changing existing behavior, use ADDED instead.
Example:
```
## ADDED Requirements
### Requirement: User can export data
The system SHALL allow users to export their data in CSV format.
#### Scenario: Successful export
- **WHEN** user clicks "Export" button
- **THEN** system downloads a CSV file with all user data
## REMOVED Requirements
### Requirement: Legacy export
**Reason**: Replaced by new export system
**Migration**: Use new export endpoint at /api/v2/export
```
Specs should be testable - each scenario is a potential test case.
requires:
- proposal
- id: design
generates: design.md
description: Technical design document with implementation details
template: design.md
instruction: |
Create the design document that explains HOW to implement the change.
When to include design.md (create only if any apply):
- Cross-cutting change (multiple services/modules) or new architectural pattern
- New external dependency or significant data model changes
- Security, performance, or migration complexity
- Ambiguity that benefits from technical decisions before coding
Sections:
- **Context**: Background, current state, constraints, stakeholders
- **Goals / Non-Goals**: What this design achieves and explicitly excludes
- **Decisions**: Key technical choices with rationale (why X over Y?). Include alternatives considered for each decision.
- **Risks / Trade-offs**: Known limitations, things that could go wrong. Format: [Risk] → Mitigation
- **Migration Plan**: Steps to deploy, rollback strategy (if applicable)
- **Open Questions**: Outstanding decisions or unknowns to resolve
Focus on architecture and approach, not line-by-line implementation.
Reference the proposal for motivation and specs for requirements.
Good design docs explain the "why" behind technical decisions.
requires:
- proposal
- id: tasks
generates: tasks.md
description: Implementation tasks derived from specs and design
template: tasks.md
instruction: |
Create the task list that breaks down the implementation work.
Guidelines:
- Group related tasks under ## numbered headings
- Each task is a checkbox: - [ ] X.Y Task description
- Tasks should be small enough to complete in one session
- Order tasks by dependency (what must be done first?)
Example:
```
## 1. Setup
- [ ] 1.1 Create new module structure
- [ ] 1.2 Add dependencies to package.json
## 2. Core Implementation
- [ ] 2.1 Implement data export function
- [ ] 2.2 Add CSV formatting utilities
```
Reference specs for what needs to be built, design for how to build it.
Each task should be verifiable - you know when it's done.
requires:
- specs
- design
apply:
requires: [tasks]
tracks: tasks.md
instruction: |
Read context files, work through pending tasks, mark complete as you go.
Pause if you hit blockers or need clarification.
+3 -15
View File
@@ -1,23 +1,11 @@
## Why
<!-- Explain the motivation for this change. What problem does this solve? Why now? -->
<!-- Explain the motivation for this change -->
## What Changes
<!-- Describe what will change. Be specific about new capabilities, modifications, or removals. -->
## Capabilities
### New Capabilities
<!-- Capabilities being introduced. Replace <name> with kebab-case identifier (e.g., user-auth, data-export, api-rate-limiting). Each creates specs/<name>/spec.md -->
- `<name>`: <brief description of what this capability covers>
### Modified Capabilities
<!-- Existing capabilities whose REQUIREMENTS are changing (not just implementation).
Only list here if spec-level behavior changes. Each needs a delta spec file.
Use existing spec names from openspec/specs/. Leave empty if no requirement changes. -->
- `<existing-name>`: <what requirement is changing>
<!-- Describe what will change -->
## Impact
<!-- Affected code, APIs, dependencies, systems -->
<!-- List affected areas -->
-186
View File
@@ -6,208 +6,22 @@ artifacts:
generates: spec.md
description: Feature specification defining requirements
template: spec.md
instruction: |
Create the feature specification that defines WHAT to build.
Sections:
- **Feature**: Name and high-level description of the feature's purpose and user value
- **Requirements**: List of specific requirements. Use SHALL/MUST for normative language.
- **Acceptance Criteria**: Testable criteria in WHEN/THEN format
Format requirements:
- Each requirement should be specific and testable
- Use `#### Scenario: <name>` with WHEN/THEN format for acceptance criteria
- Define edge cases and error scenarios explicitly
- Every requirement MUST have at least one scenario
Example:
```
## Feature: User Authentication
Users can securely log into the application.
## Requirements
### Requirement: Password validation
The system SHALL validate passwords meet minimum security requirements.
#### Scenario: Valid password accepted
- **WHEN** password has 8+ chars, uppercase, lowercase, and number
- **THEN** password is accepted
#### Scenario: Weak password rejected
- **WHEN** password is less than 8 characters
- **THEN** system displays "Password too short" error
```
This spec drives test creation - each scenario becomes a test case.
requires: []
- id: tests
generates: "tests/*.test.ts"
description: Test files written before implementation
template: test.md
instruction: |
Write tests BEFORE implementation (TDD red phase).
File naming:
- Create test files as `tests/<feature>.test.ts`
- One test file per feature/capability
- Use descriptive names matching the spec
Test structure:
- Use Given/When/Then format matching spec scenarios
- Group related tests with `describe()` blocks
- Each scenario from spec becomes at least one `it()` test
Coverage requirements:
- Cover each requirement from the spec
- Include happy path (success cases)
- Include edge cases (boundary conditions)
- Include error scenarios (invalid input, failures)
- Tests should fail initially (no implementation yet)
Example:
```typescript
describe('Password validation', () => {
it('accepts valid password with all requirements', () => {
// GIVEN a password meeting all requirements
const password = 'SecurePass1';
// WHEN validating
const result = validatePassword(password);
// THEN it should be accepted
expect(result.valid).toBe(true);
});
it('rejects password shorter than 8 characters', () => {
// GIVEN a short password
const password = 'Short1';
// WHEN validating
const result = validatePassword(password);
// THEN it should be rejected with message
expect(result.valid).toBe(false);
expect(result.error).toBe('Password too short');
});
});
```
Follow the spec requirements exactly - tests verify the spec.
requires:
- spec
- id: implementation
generates: "src/*.ts"
description: Implementation code to pass the tests
template: implementation.md
instruction: |
Implement the feature to make tests pass (TDD green phase).
TDD workflow:
1. Run tests - confirm they fail (red)
2. Write minimal code to pass ONE test
3. Run tests - confirm that test passes (green)
4. Refactor if needed while keeping tests green
5. Repeat for next failing test
Implementation guidelines:
- Write minimal code to pass each test - no more, no less
- Run tests frequently to verify progress
- Keep functions small and focused
- Use clear, descriptive names
Code organization:
- Create source files in `src/<feature>.ts`
- Export public API clearly
- Keep implementation details private
- Add JSDoc comments for public functions
Example structure:
```typescript
/**
* Validates a password meets security requirements.
* @param password - The password to validate
* @returns Validation result with valid flag and optional error
*/
export function validatePassword(password: string): ValidationResult {
if (password.length < 8) {
return { valid: false, error: 'Password too short' };
}
// ... additional checks
return { valid: true };
}
```
Don't over-engineer - implement only what tests require.
requires:
- tests
- id: docs
generates: "docs/*.md"
description: Documentation for the implemented feature
template: docs.md
instruction: |
Document the implemented feature.
Sections:
- **Overview**: What the feature does and why it exists (1-2 paragraphs)
- **Getting Started**: Quick start guide to use the feature immediately
- **Examples**: Code examples showing common use cases
- **Reference**: Detailed API documentation, configuration options
Guidelines:
- Write for the user, not the developer
- Start with the most common use case
- Include copy-pasteable code examples
- Document all configuration options with defaults
- Note any limitations, edge cases, or gotchas
- Link to related features or specs
Example structure:
```markdown
## Overview
Password validation ensures user passwords meet security requirements
before account creation or password changes.
## Getting Started
Import and use the validation function:
```typescript
import { validatePassword } from './password';
const result = validatePassword('MySecurePass1');
if (!result.valid) {
console.error(result.error);
}
```
## Examples
### Basic validation
...
### Custom error handling
...
## Reference
### validatePassword(password)
| Parameter | Type | Description |
|-----------|------|-------------|
| password | string | The password to validate |
**Returns**: `{ valid: boolean, error?: string }`
```
Reference the spec for requirements, implementation for details.
requires:
- implementation
apply:
requires: [tests]
tracks: null
instruction: |
Run tests to see failures. Implement minimal code to pass each test.
Refactor while keeping tests green.
+2 -5
View File
@@ -96,14 +96,11 @@ program
.description('List items (changes by default). Use --specs to list specs.')
.option('--specs', 'List specs instead of changes')
.option('--changes', 'List changes explicitly (default)')
.option('--sort <order>', 'Sort order: "recent" (default) or "name"', 'recent')
.option('--json', 'Output as JSON (for programmatic use)')
.action(async (options?: { specs?: boolean; changes?: boolean; sort?: string; json?: boolean }) => {
.action(async (options?: { specs?: boolean; changes?: boolean }) => {
try {
const listCommand = new ListCommand();
const mode: 'changes' | 'specs' = options?.specs ? 'specs' : 'changes';
const sort = options?.sort === 'name' ? 'name' : 'recent';
await listCommand.execute('.', mode, { sort, json: options?.json });
await listCommand.execute('.', mode);
} catch (error) {
console.log(); // Empty line for spacing
ora().fail(`Error: ${(error as Error).message}`);
+100 -615
View File
@@ -19,43 +19,13 @@ import {
formatChangeStatus,
generateInstructions,
listSchemas,
listSchemasWithInfo,
getSchemaDir,
resolveSchema,
ArtifactGraph,
type ChangeStatus,
type ArtifactInstructions,
type SchemaInfo,
} from '../core/artifact-graph/index.js';
import { createChange, validateChangeName } from '../utils/change-utils.js';
import { getNewChangeSkillTemplate, getContinueChangeSkillTemplate, getApplyChangeSkillTemplate, getFfChangeSkillTemplate, getSyncSpecsSkillTemplate, getArchiveChangeSkillTemplate, getOpsxNewCommandTemplate, getOpsxContinueCommandTemplate, getOpsxApplyCommandTemplate, getOpsxFfCommandTemplate, getOpsxSyncCommandTemplate, getOpsxArchiveCommandTemplate } from '../core/templates/skill-templates.js';
import { FileSystemUtils } from '../utils/file-system.js';
// -----------------------------------------------------------------------------
// Types for Apply Instructions
// -----------------------------------------------------------------------------
interface TaskItem {
id: string;
description: string;
done: boolean;
}
interface ApplyInstructions {
changeName: string;
changeDir: string;
schemaName: string;
contextFiles: Record<string, string>;
progress: {
total: number;
complete: number;
remaining: number;
};
tasks: TaskItem[];
state: 'blocked' | 'all_done' | 'ready';
missingArtifacts?: string[];
instruction: string;
}
const DEFAULT_SCHEMA = 'spec-driven';
@@ -185,14 +155,9 @@ async function statusCommand(options: StatusOptions): Promise<void> {
try {
const projectRoot = process.cwd();
const changeName = await validateChangeExists(options.change, projectRoot);
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
// Validate schema if explicitly provided
if (options.schema) {
validateSchemaExists(options.schema);
}
// loadChangeContext will auto-detect schema from metadata if not provided
const context = loadChangeContext(projectRoot, changeName, options.schema);
const context = loadChangeContext(projectRoot, changeName, schemaName);
const status = formatChangeStatus(context);
spinner.stop();
@@ -236,6 +201,57 @@ function printStatusText(status: ChangeStatus): void {
}
}
// -----------------------------------------------------------------------------
// Next Command
// -----------------------------------------------------------------------------
interface NextOptions {
change?: string;
schema?: string;
json?: boolean;
}
async function nextCommand(options: NextOptions): Promise<void> {
const spinner = ora('Finding next artifacts...').start();
try {
const projectRoot = process.cwd();
const changeName = await validateChangeExists(options.change, projectRoot);
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
const context = loadChangeContext(projectRoot, changeName, schemaName);
const ready = context.graph.getNextArtifacts(context.completed);
const isComplete = context.graph.isComplete(context.completed);
spinner.stop();
if (options.json) {
console.log(JSON.stringify(ready, null, 2));
return;
}
if (isComplete) {
console.log(chalk.green('All artifacts are complete!'));
return;
}
if (ready.length === 0) {
console.log('No artifacts are ready. All remaining artifacts are blocked.');
console.log('Run `openspec status --change ' + changeName + '` to see blocked dependencies.');
return;
}
console.log('Artifacts ready to create:');
for (const artifactId of ready) {
const color = getStatusColor('ready');
console.log(color(` ${artifactId}`));
}
} catch (error) {
spinner.stop();
throw error;
}
}
// -----------------------------------------------------------------------------
// Instructions Command
// -----------------------------------------------------------------------------
@@ -255,30 +271,26 @@ async function instructionsCommand(
try {
const projectRoot = process.cwd();
const changeName = await validateChangeExists(options.change, projectRoot);
// Validate schema if explicitly provided
if (options.schema) {
validateSchemaExists(options.schema);
}
// loadChangeContext will auto-detect schema from metadata if not provided
const context = loadChangeContext(projectRoot, changeName, options.schema);
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA);
if (!artifactId) {
spinner.stop();
const validIds = context.graph.getAllArtifacts().map((a) => a.id);
const schema = resolveSchema(schemaName);
const graph = ArtifactGraph.fromSchema(schema);
const validIds = graph.getAllArtifacts().map((a) => a.id);
throw new Error(
`Missing required argument <artifact>. Valid artifacts:\n ${validIds.join('\n ')}`
);
}
const context = loadChangeContext(projectRoot, changeName, schemaName);
const artifact = context.graph.getArtifact(artifactId);
if (!artifact) {
spinner.stop();
const validIds = context.graph.getAllArtifacts().map((a) => a.id);
throw new Error(
`Artifact '${artifactId}' not found in schema '${context.schemaName}'. Valid artifacts:\n ${validIds.join('\n ')}`
`Artifact '${artifactId}' not found in schema '${schemaName}'. Valid artifacts:\n ${validIds.join('\n ')}`
);
}
@@ -300,372 +312,38 @@ async function instructionsCommand(
}
function printInstructionsText(instructions: ArtifactInstructions, isBlocked: boolean): void {
const {
artifactId,
changeName,
schemaName,
changeDir,
outputPath,
description,
instruction,
template,
dependencies,
unlocks,
} = instructions;
// Opening tag
console.log(`<artifact id="${artifactId}" change="${changeName}" schema="${schemaName}">`);
console.log();
// Warning for blocked artifacts
if (isBlocked) {
const missing = dependencies.filter((d) => !d.done).map((d) => d.id);
console.log('<warning>');
console.log('This artifact has unmet dependencies. Complete them first or proceed with caution.');
console.log(`Missing: ${missing.join(', ')}`);
console.log('</warning>');
console.log(chalk.yellow('Warning: This artifact has unmet dependencies.'));
console.log();
}
// Task directive
console.log('<task>');
console.log(`Create the ${artifactId} artifact for change "${changeName}".`);
console.log(description);
console.log('</task>');
console.log(`Artifact: ${instructions.artifactId}`);
console.log(`Output: ${instructions.outputPath}`);
console.log(`Description: ${instructions.description}`);
console.log();
// Context (dependencies)
if (dependencies.length > 0) {
console.log('<context>');
console.log('Read these files for context before creating this artifact:');
console.log();
for (const dep of dependencies) {
const status = dep.done ? 'done' : 'missing';
const fullPath = path.join(changeDir, dep.path);
console.log(`<dependency id="${dep.id}" status="${status}">`);
console.log(` <path>${fullPath}</path>`);
console.log(` <description>${dep.description}</description>`);
console.log('</dependency>');
}
console.log('</context>');
console.log();
}
// Output location
console.log('<output>');
console.log(`Write to: ${path.join(changeDir, outputPath)}`);
console.log('</output>');
console.log();
// Instruction (guidance)
if (instruction) {
console.log('<instruction>');
console.log(instruction.trim());
console.log('</instruction>');
console.log();
}
// Template
console.log('<template>');
console.log(template.trim());
console.log('</template>');
console.log();
// Success criteria placeholder
console.log('<success_criteria>');
console.log('<!-- To be defined in schema validation rules -->');
console.log('</success_criteria>');
console.log();
// Unlocks
if (unlocks.length > 0) {
console.log('<unlocks>');
console.log(`Completing this artifact enables: ${unlocks.join(', ')}`);
console.log('</unlocks>');
console.log();
}
// Closing tag
console.log('</artifact>');
}
// -----------------------------------------------------------------------------
// Apply Instructions Command
// -----------------------------------------------------------------------------
interface ApplyInstructionsOptions {
change?: string;
schema?: string;
json?: boolean;
}
/**
* Parses tasks.md content and extracts task items with their completion status.
*/
function parseTasksFile(content: string): TaskItem[] {
const tasks: TaskItem[] = [];
const lines = content.split('\n');
let taskIndex = 0;
for (const line of lines) {
// Match checkbox patterns: - [ ] or - [x] or - [X]
const checkboxMatch = line.match(/^[-*]\s*\[([ xX])\]\s*(.+)$/);
if (checkboxMatch) {
taskIndex++;
const done = checkboxMatch[1].toLowerCase() === 'x';
const description = checkboxMatch[2].trim();
tasks.push({
id: `${taskIndex}`,
description,
done,
});
}
}
return tasks;
}
/**
* Checks if an artifact output exists in the change directory.
* Supports glob patterns (e.g., "specs/*.md") by verifying at least one matching file exists.
*/
function artifactOutputExists(changeDir: string, generates: string): boolean {
// Normalize the generates path to use platform-specific separators
const normalizedGenerates = generates.split('/').join(path.sep);
const fullPath = path.join(changeDir, normalizedGenerates);
// If it's a glob pattern (contains ** or *), check for matching files
if (generates.includes('*')) {
// Extract the directory part before the glob pattern
const parts = normalizedGenerates.split(path.sep);
const dirParts: string[] = [];
let patternPart = '';
for (const part of parts) {
if (part.includes('*')) {
patternPart = part;
break;
}
dirParts.push(part);
}
const dirPath = path.join(changeDir, ...dirParts);
// Check if directory exists
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
return false;
}
// Extract expected extension from pattern (e.g., "*.md" -> ".md")
const extMatch = patternPart.match(/\*(\.[a-zA-Z0-9]+)$/);
const expectedExt = extMatch ? extMatch[1] : null;
// Recursively check for matching files
const hasMatchingFiles = (dir: string): boolean => {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
// For ** patterns, recurse into subdirectories
if (generates.includes('**') && hasMatchingFiles(path.join(dir, entry.name))) {
return true;
}
} else if (entry.isFile()) {
// Check if file matches expected extension (or any file if no extension specified)
if (!expectedExt || entry.name.endsWith(expectedExt)) {
return true;
}
}
}
} catch {
return false;
}
return false;
};
return hasMatchingFiles(dirPath);
}
return fs.existsSync(fullPath);
}
/**
* Generates apply instructions for implementing tasks from a change.
* Schema-aware: reads apply phase configuration from schema to determine
* required artifacts, tracking file, and instruction.
*/
async function generateApplyInstructions(
projectRoot: string,
changeName: string,
schemaName?: string
): Promise<ApplyInstructions> {
// loadChangeContext will auto-detect schema from metadata if not provided
const context = loadChangeContext(projectRoot, changeName, schemaName);
const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);
// Get the full schema to access the apply phase configuration
const schema = resolveSchema(context.schemaName);
const applyConfig = schema.apply;
// Determine required artifacts and tracking file from schema
// Fallback: if no apply block, require all artifacts
const requiredArtifactIds = applyConfig?.requires ?? schema.artifacts.map((a) => a.id);
const tracksFile = applyConfig?.tracks ?? null;
const schemaInstruction = applyConfig?.instruction ?? null;
// Check which required artifacts are missing
const missingArtifacts: string[] = [];
for (const artifactId of requiredArtifactIds) {
const artifact = schema.artifacts.find((a) => a.id === artifactId);
if (artifact && !artifactOutputExists(changeDir, artifact.generates)) {
missingArtifacts.push(artifactId);
}
}
// Build context files from all existing artifacts in schema
const contextFiles: Record<string, string> = {};
for (const artifact of schema.artifacts) {
if (artifactOutputExists(changeDir, artifact.generates)) {
contextFiles[artifact.id] = path.join(changeDir, artifact.generates);
}
}
// Parse tasks if tracking file exists
let tasks: TaskItem[] = [];
let tracksFileExists = false;
if (tracksFile) {
const tracksPath = path.join(changeDir, tracksFile);
tracksFileExists = fs.existsSync(tracksPath);
if (tracksFileExists) {
const tasksContent = await fs.promises.readFile(tracksPath, 'utf-8');
tasks = parseTasksFile(tasksContent);
}
}
// Calculate progress
const total = tasks.length;
const complete = tasks.filter((t) => t.done).length;
const remaining = total - complete;
// Determine state and instruction
let state: ApplyInstructions['state'];
let instruction: string;
if (missingArtifacts.length > 0) {
state = 'blocked';
instruction = `Cannot apply this change yet. Missing artifacts: ${missingArtifacts.join(', ')}.\nUse the openspec-continue-change skill to create the missing artifacts first.`;
} else if (tracksFile && !tracksFileExists) {
// Tracking file configured but doesn't exist yet
const tracksFilename = path.basename(tracksFile);
state = 'blocked';
instruction = `The ${tracksFilename} file is missing and must be created.\nUse openspec-continue-change to generate the tracking file.`;
} else if (tracksFile && tracksFileExists && total === 0) {
// Tracking file exists but contains no tasks
const tracksFilename = path.basename(tracksFile);
state = 'blocked';
instruction = `The ${tracksFilename} file exists but contains no tasks.\nAdd tasks to ${tracksFilename} or regenerate it with openspec-continue-change.`;
} else if (tracksFile && remaining === 0 && total > 0) {
state = 'all_done';
instruction = 'All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving.';
} else if (!tracksFile) {
// No tracking file (e.g., TDD schema) - ready to apply
state = 'ready';
instruction = schemaInstruction?.trim() ?? 'All required artifacts complete. Proceed with implementation.';
console.log('Dependencies:');
if (instructions.dependencies.length === 0) {
console.log(' (none)');
} else {
state = 'ready';
instruction = schemaInstruction?.trim() ?? 'Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification.';
}
return {
changeName,
changeDir,
schemaName: context.schemaName,
contextFiles,
progress: { total, complete, remaining },
tasks,
state,
missingArtifacts: missingArtifacts.length > 0 ? missingArtifacts : undefined,
instruction,
};
}
async function applyInstructionsCommand(options: ApplyInstructionsOptions): Promise<void> {
const spinner = ora('Generating apply instructions...').start();
try {
const projectRoot = process.cwd();
const changeName = await validateChangeExists(options.change, projectRoot);
// Validate schema if explicitly provided
if (options.schema) {
validateSchemaExists(options.schema);
for (const dep of instructions.dependencies) {
const status = dep.done ? chalk.green('[done]') : chalk.red('[missing]');
console.log(` ${status} ${dep.id}`);
}
// generateApplyInstructions uses loadChangeContext which auto-detects schema
const instructions = await generateApplyInstructions(projectRoot, changeName, options.schema);
spinner.stop();
if (options.json) {
console.log(JSON.stringify(instructions, null, 2));
return;
}
printApplyInstructionsText(instructions);
} catch (error) {
spinner.stop();
throw error;
}
}
function printApplyInstructionsText(instructions: ApplyInstructions): void {
const { changeName, schemaName, contextFiles, progress, tasks, state, missingArtifacts, instruction } = instructions;
console.log(`## Apply: ${changeName}`);
console.log(`Schema: ${schemaName}`);
console.log();
// Warning for blocked state
if (state === 'blocked' && missingArtifacts) {
console.log('### ⚠️ Blocked');
console.log();
console.log(`Missing artifacts: ${missingArtifacts.join(', ')}`);
console.log('Use the openspec-continue-change skill to create these first.');
console.log();
}
// Context files (dynamically from schema)
const contextFileEntries = Object.entries(contextFiles);
if (contextFileEntries.length > 0) {
console.log('### Context Files');
for (const [artifactId, filePath] of contextFileEntries) {
console.log(`- ${artifactId}: ${filePath}`);
if (instructions.unlocks.length > 0) {
console.log('Unlocks:');
for (const unlocked of instructions.unlocks) {
console.log(` ${unlocked}`);
}
console.log();
}
// Progress (only show if we have tracking)
if (progress.total > 0 || tasks.length > 0) {
console.log('### Progress');
if (state === 'all_done') {
console.log(`${progress.complete}/${progress.total} complete ✓`);
} else {
console.log(`${progress.complete}/${progress.total} complete`);
}
console.log();
}
// Tasks
if (tasks.length > 0) {
console.log('### Tasks');
for (const task of tasks) {
const checkbox = task.done ? '[x]' : '[ ]';
console.log(`- ${checkbox} ${task.description}`);
}
console.log();
}
// Instruction
console.log('### Instruction');
console.log(instruction);
console.log('Template:');
console.log('─'.repeat(40));
console.log(instructions.template);
}
// -----------------------------------------------------------------------------
@@ -734,7 +412,6 @@ async function templatesCommand(options: TemplatesOptions): Promise<void> {
interface NewChangeOptions {
description?: string;
schema?: string;
}
async function newChangeCommand(name: string | undefined, options: NewChangeOptions): Promise<void> {
@@ -747,17 +424,11 @@ async function newChangeCommand(name: string | undefined, options: NewChangeOpti
throw new Error(validation.error);
}
// Validate schema if provided
if (options.schema) {
validateSchemaExists(options.schema);
}
const schemaDisplay = options.schema ? ` with schema '${options.schema}'` : '';
const spinner = ora(`Creating change '${name}'${schemaDisplay}...`).start();
const spinner = ora(`Creating change '${name}'...`).start();
try {
const projectRoot = process.cwd();
await createChange(projectRoot, name, { schema: options.schema });
await createChange(projectRoot, name);
// If description provided, create README.md with description
if (options.description) {
@@ -767,181 +438,13 @@ async function newChangeCommand(name: string | undefined, options: NewChangeOpti
await fs.writeFile(readmePath, `# ${name}\n\n${options.description}\n`, 'utf-8');
}
const schemaUsed = options.schema ?? DEFAULT_SCHEMA;
spinner.succeed(`Created change '${name}' at openspec/changes/${name}/ (schema: ${schemaUsed})`);
spinner.succeed(`Created change '${name}' at openspec/changes/${name}/`);
} catch (error) {
spinner.fail(`Failed to create change '${name}'`);
throw error;
}
}
// -----------------------------------------------------------------------------
// Artifact Experimental Setup Command
// -----------------------------------------------------------------------------
/**
* Generates Agent Skills and slash commands for the experimental artifact workflow.
* Creates .claude/skills/ directory with SKILL.md files following Agent Skills spec.
* Creates .claude/commands/opsx/ directory with slash command files.
*/
async function artifactExperimentalSetupCommand(): Promise<void> {
const spinner = ora('Setting up experimental artifact workflow...').start();
try {
const projectRoot = process.cwd();
const skillsDir = path.join(projectRoot, '.claude', 'skills');
const commandsDir = path.join(projectRoot, '.claude', 'commands', 'opsx');
// Get skill templates
const newChangeSkill = getNewChangeSkillTemplate();
const continueChangeSkill = getContinueChangeSkillTemplate();
const applyChangeSkill = getApplyChangeSkillTemplate();
const ffChangeSkill = getFfChangeSkillTemplate();
const syncSpecsSkill = getSyncSpecsSkillTemplate();
const archiveChangeSkill = getArchiveChangeSkillTemplate();
// Get command templates
const newCommand = getOpsxNewCommandTemplate();
const continueCommand = getOpsxContinueCommandTemplate();
const applyCommand = getOpsxApplyCommandTemplate();
const ffCommand = getOpsxFfCommandTemplate();
const syncCommand = getOpsxSyncCommandTemplate();
const archiveCommand = getOpsxArchiveCommandTemplate();
// Create skill directories and SKILL.md files
const skills = [
{ template: newChangeSkill, dirName: 'openspec-new-change' },
{ template: continueChangeSkill, dirName: 'openspec-continue-change' },
{ template: applyChangeSkill, dirName: 'openspec-apply-change' },
{ template: ffChangeSkill, dirName: 'openspec-ff-change' },
{ template: syncSpecsSkill, dirName: 'openspec-sync-specs' },
{ template: archiveChangeSkill, dirName: 'openspec-archive-change' },
];
const createdSkillFiles: string[] = [];
for (const { template, dirName } of skills) {
const skillDir = path.join(skillsDir, dirName);
const skillFile = path.join(skillDir, 'SKILL.md');
// Generate SKILL.md content with YAML frontmatter
const skillContent = `---
name: ${template.name}
description: ${template.description}
---
${template.instructions}
`;
// Write the skill file
await FileSystemUtils.writeFile(skillFile, skillContent);
createdSkillFiles.push(path.relative(projectRoot, skillFile));
}
// Create slash command files
const commands = [
{ template: newCommand, fileName: 'new.md' },
{ template: continueCommand, fileName: 'continue.md' },
{ template: applyCommand, fileName: 'apply.md' },
{ template: ffCommand, fileName: 'ff.md' },
{ template: syncCommand, fileName: 'sync.md' },
{ template: archiveCommand, fileName: 'archive.md' },
];
const createdCommandFiles: string[] = [];
for (const { template, fileName } of commands) {
const commandFile = path.join(commandsDir, fileName);
// Generate command content with YAML frontmatter
const commandContent = `---
name: ${template.name}
description: ${template.description}
category: ${template.category}
tags: [${template.tags.join(', ')}]
---
${template.content}
`;
// Write the command file
await FileSystemUtils.writeFile(commandFile, commandContent);
createdCommandFiles.push(path.relative(projectRoot, commandFile));
}
spinner.succeed('Experimental artifact workflow setup complete!');
// Print success message
console.log();
console.log(chalk.bold('🧪 Experimental Artifact Workflow Setup Complete'));
console.log();
console.log(chalk.bold('Skills Created:'));
for (const file of createdSkillFiles) {
console.log(chalk.green(' ✓ ' + file));
}
console.log();
console.log(chalk.bold('Slash Commands Created:'));
for (const file of createdCommandFiles) {
console.log(chalk.green(' ✓ ' + file));
}
console.log();
console.log(chalk.bold('📖 Usage:'));
console.log();
console.log(' ' + chalk.cyan('Skills') + ' work automatically in compatible editors:');
console.log(' • Claude Code - Auto-detected, ready to use');
console.log(' • Cursor - Enable in Settings → Rules → Import Settings');
console.log(' • Windsurf - Auto-imports from .claude directory');
console.log();
console.log(' Ask Claude naturally:');
console.log(' • "I want to start a new OpenSpec change to add <feature>"');
console.log(' • "Continue working on this change"');
console.log(' • "Implement the tasks for this change"');
console.log();
console.log(' ' + chalk.cyan('Slash Commands') + ' for explicit invocation:');
console.log(' • /opsx:new - Start a new change');
console.log(' • /opsx:continue - Create the next artifact');
console.log(' • /opsx:apply - Implement tasks');
console.log(' • /opsx:ff - Fast-forward: create all artifacts at once');
console.log(' • /opsx:sync - Sync delta specs to main specs');
console.log(' • /opsx:archive - Archive a completed change');
console.log();
console.log(chalk.yellow('💡 This is an experimental feature.'));
console.log(' Feedback welcome at: https://github.com/Fission-AI/OpenSpec/issues');
console.log();
} catch (error) {
spinner.fail('Failed to setup experimental artifact workflow');
throw error;
}
}
// -----------------------------------------------------------------------------
// Schemas Command
// -----------------------------------------------------------------------------
interface SchemasOptions {
json?: boolean;
}
async function schemasCommand(options: SchemasOptions): Promise<void> {
const schemas = listSchemasWithInfo();
if (options.json) {
console.log(JSON.stringify(schemas, null, 2));
return;
}
console.log('Available schemas:');
console.log();
for (const schema of schemas) {
const sourceLabel = schema.source === 'user' ? chalk.dim(' (user override)') : '';
console.log(` ${chalk.bold(schema.name)}${sourceLabel}`);
console.log(` ${schema.description}`);
console.log(` Artifacts: ${schema.artifacts.join(' → ')}`);
console.log();
}
}
// -----------------------------------------------------------------------------
// Command Registration
// -----------------------------------------------------------------------------
@@ -956,7 +459,7 @@ export function registerArtifactWorkflowCommands(program: Command): void {
.command('status')
.description('[Experimental] Display artifact completion status for a change')
.option('--change <id>', 'Change name to show status for')
.option('--schema <name>', 'Schema override (auto-detected from .openspec.yaml)')
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
.option('--json', 'Output as JSON')
.action(async (options: StatusOptions) => {
try {
@@ -968,21 +471,33 @@ export function registerArtifactWorkflowCommands(program: Command): void {
}
});
// Next command
program
.command('next')
.description('[Experimental] Show artifacts ready to be created')
.option('--change <id>', 'Change name to check')
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
.option('--json', 'Output as JSON array of ready artifact IDs')
.action(async (options: NextOptions) => {
try {
await nextCommand(options);
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
// Instructions command
program
.command('instructions [artifact]')
.description('[Experimental] Output enriched instructions for creating an artifact or applying tasks')
.description('[Experimental] Output enriched instructions for creating an artifact')
.option('--change <id>', 'Change name')
.option('--schema <name>', 'Schema override (auto-detected from .openspec.yaml)')
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
.option('--json', 'Output as JSON')
.action(async (artifactId: string | undefined, options: InstructionsOptions) => {
try {
// Special case: "apply" is not an artifact, but a command to get apply instructions
if (artifactId === 'apply') {
await applyInstructionsCommand(options);
} else {
await instructionsCommand(artifactId, options);
}
await instructionsCommand(artifactId, options);
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
@@ -1006,21 +521,6 @@ export function registerArtifactWorkflowCommands(program: Command): void {
}
});
// Schemas command
program
.command('schemas')
.description('[Experimental] List available workflow schemas with descriptions')
.option('--json', 'Output as JSON (for agent use)')
.action(async (options: SchemasOptions) => {
try {
await schemasCommand(options);
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
// New command group with change subcommand
const newCmd = program.command('new').description('[Experimental] Create new items');
@@ -1028,7 +528,6 @@ export function registerArtifactWorkflowCommands(program: Command): void {
.command('change <name>')
.description('[Experimental] Create a new change directory')
.option('--description <text>', 'Description to add to README.md')
.option('--schema <name>', `Workflow schema to use (default: ${DEFAULT_SCHEMA})`)
.action(async (name: string, options: NewChangeOptions) => {
try {
await newChangeCommand(name, options);
@@ -1038,18 +537,4 @@ export function registerArtifactWorkflowCommands(program: Command): void {
process.exit(1);
}
});
// Artifact experimental setup command
program
.command('artifact-experimental-setup')
.description('[Experimental] Setup Agent Skills for the experimental artifact workflow')
.action(async () => {
try {
await artifactExperimentalSetupCommand();
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
}
+6 -50
View File
@@ -144,27 +144,8 @@ export class CompletionCommand {
if (result.backupPath) {
console.log(` Backup created: ${result.backupPath}`);
}
// Check if any shell config was updated
const configWasUpdated = result.zshrcConfigured || result.bashrcConfigured || result.profileConfigured;
if (configWasUpdated) {
const configPaths: Record<string, string> = {
zsh: '~/.zshrc',
bash: '~/.bashrc',
fish: '~/.config/fish/config.fish',
powershell: '$PROFILE',
};
const configPath = configPaths[shell] || 'config file';
console.log(` ${configPath} configured automatically`);
}
}
// Display warnings if present
if (result.warnings && result.warnings.length > 0) {
console.log('');
for (const warning of result.warnings) {
console.log(warning);
if (result.zshrcConfigured) {
console.log(` ~/.zshrc configured automatically`);
}
}
@@ -174,24 +155,9 @@ export class CompletionCommand {
for (const instruction of result.instructions) {
console.log(instruction);
}
} else {
// Check if any shell config was updated (InstallationResult has: zshrcConfigured, bashrcConfigured, profileConfigured)
const configWasUpdated = result.zshrcConfigured || result.bashrcConfigured || result.profileConfigured;
if (configWasUpdated) {
console.log('');
// Shell-specific reload instructions
const reloadCommands: Record<string, string> = {
zsh: 'exec zsh',
bash: 'exec bash',
fish: 'exec fish',
powershell: '. $PROFILE',
};
const reloadCmd = reloadCommands[shell] || `restart your ${shell} shell`;
console.log(`Restart your shell or run: ${reloadCmd}`);
}
} else if (result.zshrcConfigured) {
console.log('');
console.log('Restart your shell or run: exec zsh');
}
} else {
console.error(`✗ ${result.message}`);
@@ -213,18 +179,8 @@ export class CompletionCommand {
// Prompt for confirmation unless --yes flag is provided
if (!skipConfirmation) {
const { confirm } = await import('@inquirer/prompts');
// Get shell-specific config file path
const configPaths: Record<string, string> = {
zsh: '~/.zshrc',
bash: '~/.bashrc',
fish: 'Fish configuration', // Fish doesn't modify profile, just removes script file
powershell: '$PROFILE',
};
const configPath = configPaths[shell] || `${shell} configuration`;
const confirmed = await confirm({
message: `Remove OpenSpec configuration from ${configPath}?`,
message: 'Remove OpenSpec configuration from ~/.zshrc?',
default: false,
});
+331 -8
View File
@@ -4,11 +4,17 @@ import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progre
import { Validator } from './validation/validator.js';
import chalk from 'chalk';
import {
findSpecUpdates,
buildUpdatedSpec,
writeUpdatedSpec,
type SpecUpdate,
} from './specs-apply.js';
extractRequirementsSection,
parseDeltaSpec,
normalizeRequirementName,
type RequirementBlock,
} from './parsers/requirement-blocks.js';
interface SpecUpdate {
source: string;
target: string;
exists: boolean;
}
export class ArchiveCommand {
async execute(
@@ -161,7 +167,7 @@ export class ArchiveCommand {
console.log('Skipping spec updates (--skip-specs flag provided).');
} else {
// Find specs to update
const specUpdates = await findSpecUpdates(changeDir, mainSpecsDir);
const specUpdates = await this.findSpecUpdates(changeDir, mainSpecsDir);
if (specUpdates.length > 0) {
console.log('\nSpecs to update:');
@@ -188,7 +194,7 @@ export class ArchiveCommand {
const prepared: Array<{ update: SpecUpdate; rebuilt: string; counts: { added: number; modified: number; removed: number; renamed: number } }> = [];
try {
for (const update of specUpdates) {
const built = await buildUpdatedSpec(update, changeName!);
const built = await this.buildUpdatedSpec(update, changeName!);
prepared.push({ update, rebuilt: built.rebuilt, counts: built.counts });
}
} catch (err: any) {
@@ -213,7 +219,7 @@ export class ArchiveCommand {
return;
}
}
await writeUpdatedSpec(p.update, p.rebuilt, p.counts);
await this.writeUpdatedSpec(p.update, p.rebuilt, p.counts);
totals.added += p.counts.added;
totals.modified += p.counts.modified;
totals.removed += p.counts.removed;
@@ -295,6 +301,323 @@ export class ArchiveCommand {
}
}
// Deprecated: replaced by shared task-progress utilities
private async checkIncompleteTasks(_tasksPath: string): Promise<number> {
return 0;
}
private async findSpecUpdates(changeDir: string, mainSpecsDir: string): Promise<SpecUpdate[]> {
const updates: SpecUpdate[] = [];
const changeSpecsDir = path.join(changeDir, 'specs');
try {
const entries = await fs.readdir(changeSpecsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const specFile = path.join(changeSpecsDir, entry.name, 'spec.md');
const targetFile = path.join(mainSpecsDir, entry.name, 'spec.md');
try {
await fs.access(specFile);
// Check if target exists
let exists = false;
try {
await fs.access(targetFile);
exists = true;
} catch {
exists = false;
}
updates.push({
source: specFile,
target: targetFile,
exists
});
} catch {
// Source spec doesn't exist, skip
}
}
}
} catch {
// No specs directory in change
}
return updates;
}
private async buildUpdatedSpec(update: SpecUpdate, changeName: string): Promise<{ rebuilt: string; counts: { added: number; modified: number; removed: number; renamed: number } }> {
// Read change spec content (delta-format expected)
const changeContent = await fs.readFile(update.source, 'utf-8');
// Parse deltas from the change spec file
const plan = parseDeltaSpec(changeContent);
const specName = path.basename(path.dirname(update.target));
// Pre-validate duplicates within sections
const addedNames = new Set<string>();
for (const add of plan.added) {
const name = normalizeRequirementName(add.name);
if (addedNames.has(name)) {
throw new Error(
`${specName} validation failed - duplicate requirement in ADDED for header "### Requirement: ${add.name}"`
);
}
addedNames.add(name);
}
const modifiedNames = new Set<string>();
for (const mod of plan.modified) {
const name = normalizeRequirementName(mod.name);
if (modifiedNames.has(name)) {
throw new Error(
`${specName} validation failed - duplicate requirement in MODIFIED for header "### Requirement: ${mod.name}"`
);
}
modifiedNames.add(name);
}
const removedNamesSet = new Set<string>();
for (const rem of plan.removed) {
const name = normalizeRequirementName(rem);
if (removedNamesSet.has(name)) {
throw new Error(
`${specName} validation failed - duplicate requirement in REMOVED for header "### Requirement: ${rem}"`
);
}
removedNamesSet.add(name);
}
const renamedFromSet = new Set<string>();
const renamedToSet = new Set<string>();
for (const { from, to } of plan.renamed) {
const fromNorm = normalizeRequirementName(from);
const toNorm = normalizeRequirementName(to);
if (renamedFromSet.has(fromNorm)) {
throw new Error(
`${specName} validation failed - duplicate FROM in RENAMED for header "### Requirement: ${from}"`
);
}
if (renamedToSet.has(toNorm)) {
throw new Error(
`${specName} validation failed - duplicate TO in RENAMED for header "### Requirement: ${to}"`
);
}
renamedFromSet.add(fromNorm);
renamedToSet.add(toNorm);
}
// Pre-validate cross-section conflicts
const conflicts: Array<{ name: string; a: string; b: string }> = [];
for (const n of modifiedNames) {
if (removedNamesSet.has(n)) conflicts.push({ name: n, a: 'MODIFIED', b: 'REMOVED' });
if (addedNames.has(n)) conflicts.push({ name: n, a: 'MODIFIED', b: 'ADDED' });
}
for (const n of addedNames) {
if (removedNamesSet.has(n)) conflicts.push({ name: n, a: 'ADDED', b: 'REMOVED' });
}
// Renamed interplay: MODIFIED must reference the NEW header, not FROM
for (const { from, to } of plan.renamed) {
const fromNorm = normalizeRequirementName(from);
const toNorm = normalizeRequirementName(to);
if (modifiedNames.has(fromNorm)) {
throw new Error(
`${specName} validation failed - when a rename exists, MODIFIED must reference the NEW header "### Requirement: ${to}"`
);
}
// Detect ADDED colliding with a RENAMED TO
if (addedNames.has(toNorm)) {
throw new Error(
`${specName} validation failed - RENAMED TO header collides with ADDED for "### Requirement: ${to}"`
);
}
}
if (conflicts.length > 0) {
const c = conflicts[0];
throw new Error(
`${specName} validation failed - requirement present in multiple sections (${c.a} and ${c.b}) for header "### Requirement: ${c.name}"`
);
}
const hasAnyDelta = (plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length) > 0;
if (!hasAnyDelta) {
throw new Error(
`Delta parsing found no operations for ${path.basename(path.dirname(update.source))}. ` +
`Provide ADDED/MODIFIED/REMOVED/RENAMED sections in change spec.`
);
}
// Load or create base target content
let targetContent: string;
let isNewSpec = false;
try {
targetContent = await fs.readFile(update.target, 'utf-8');
} catch {
// Target spec does not exist; MODIFIED and RENAMED are not allowed for new specs
// REMOVED will be ignored with a warning since there's nothing to remove
if (plan.modified.length > 0 || plan.renamed.length > 0) {
throw new Error(
`${specName}: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.`
);
}
// Warn about REMOVED requirements being ignored for new specs
if (plan.removed.length > 0) {
console.log(
chalk.yellow(
`⚠️ Warning: ${specName} - ${plan.removed.length} REMOVED requirement(s) ignored for new spec (nothing to remove).`
)
);
}
isNewSpec = true;
targetContent = this.buildSpecSkeleton(specName, changeName);
}
// Extract requirements section and build name->block map
const parts = extractRequirementsSection(targetContent);
const nameToBlock = new Map<string, RequirementBlock>();
for (const block of parts.bodyBlocks) {
nameToBlock.set(normalizeRequirementName(block.name), block);
}
// Apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED
// RENAMED
for (const r of plan.renamed) {
const from = normalizeRequirementName(r.from);
const to = normalizeRequirementName(r.to);
if (!nameToBlock.has(from)) {
throw new Error(
`${specName} RENAMED failed for header "### Requirement: ${r.from}" - source not found`
);
}
if (nameToBlock.has(to)) {
throw new Error(
`${specName} RENAMED failed for header "### Requirement: ${r.to}" - target already exists`
);
}
const block = nameToBlock.get(from)!;
const newHeader = `### Requirement: ${to}`;
const rawLines = block.raw.split('\n');
rawLines[0] = newHeader;
const renamedBlock: RequirementBlock = {
headerLine: newHeader,
name: to,
raw: rawLines.join('\n'),
};
nameToBlock.delete(from);
nameToBlock.set(to, renamedBlock);
}
// REMOVED
for (const name of plan.removed) {
const key = normalizeRequirementName(name);
if (!nameToBlock.has(key)) {
// For new specs, REMOVED requirements are already warned about and ignored
// For existing specs, missing requirements are an error
if (!isNewSpec) {
throw new Error(
`${specName} REMOVED failed for header "### Requirement: ${name}" - not found`
);
}
// Skip removal for new specs (already warned above)
continue;
}
nameToBlock.delete(key);
}
// MODIFIED
for (const mod of plan.modified) {
const key = normalizeRequirementName(mod.name);
if (!nameToBlock.has(key)) {
throw new Error(
`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - not found`
);
}
// Replace block with provided raw (ensure header line matches key)
const modHeaderMatch = mod.raw.split('\n')[0].match(/^###\s*Requirement:\s*(.+)\s*$/);
if (!modHeaderMatch || normalizeRequirementName(modHeaderMatch[1]) !== key) {
throw new Error(
`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - header mismatch in content`
);
}
nameToBlock.set(key, mod);
}
// ADDED
for (const add of plan.added) {
const key = normalizeRequirementName(add.name);
if (nameToBlock.has(key)) {
throw new Error(
`${specName} ADDED failed for header "### Requirement: ${add.name}" - already exists`
);
}
nameToBlock.set(key, add);
}
// Duplicates within resulting map are implicitly prevented by key uniqueness.
// Recompose requirements section preserving original ordering where possible
const keptOrder: RequirementBlock[] = [];
const seen = new Set<string>();
for (const block of parts.bodyBlocks) {
const key = normalizeRequirementName(block.name);
const replacement = nameToBlock.get(key);
if (replacement) {
keptOrder.push(replacement);
seen.add(key);
}
}
// Append any newly added that were not in original order
for (const [key, block] of nameToBlock.entries()) {
if (!seen.has(key)) {
keptOrder.push(block);
}
}
const reqBody = [
parts.preamble && parts.preamble.trim() ? parts.preamble.trimEnd() : ''
]
.filter(Boolean)
.concat(keptOrder.map(b => b.raw))
.join('\n\n')
.trimEnd();
const rebuilt = [
parts.before.trimEnd(),
parts.headerLine,
reqBody,
parts.after
]
.filter((s, idx) => !(idx === 0 && s === ''))
.join('\n')
.replace(/\n{3,}/g, '\n\n');
return {
rebuilt,
counts: {
added: plan.added.length,
modified: plan.modified.length,
removed: plan.removed.length,
renamed: plan.renamed.length,
}
};
}
private async writeUpdatedSpec(update: SpecUpdate, rebuilt: string, counts: { added: number; modified: number; removed: number; renamed: number }): Promise<void> {
// Create target directory if needed
const targetDir = path.dirname(update.target);
await fs.mkdir(targetDir, { recursive: true });
await fs.writeFile(update.target, rebuilt);
const specName = path.basename(path.dirname(update.target));
console.log(`Applying changes to openspec/specs/${specName}/spec.md:`);
if (counts.added) console.log(` + ${counts.added} added`);
if (counts.modified) console.log(` ~ ${counts.modified} modified`);
if (counts.removed) console.log(` - ${counts.removed} removed`);
if (counts.renamed) console.log(` → ${counts.renamed} renamed`);
}
private buildSpecSkeleton(specFolderName: string, changeName: string): string {
const titleBase = specFolderName;
return `# ${titleBase} Specification\n\n## Purpose\nTBD - created by archiving change ${changeName}. Update Purpose after archive.\n\n## Requirements\n`;
}
private getArchiveDate(): string {
// Returns date in YYYY-MM-DD format
return new Date().toISOString().split('T')[0];
+1 -3
View File
@@ -21,12 +21,10 @@ export { detectCompleted } from './state.js';
export {
resolveSchema,
listSchemas,
listSchemasWithInfo,
getSchemaDir,
getPackageSchemasDir,
getUserSchemasDir,
SchemaLoadError,
type SchemaInfo,
} from './resolver.js';
// Instruction loading
@@ -38,7 +36,7 @@ export {
TemplateLoadError,
type ChangeContext,
type ArtifactInstructions,
type DependencyInfo,
type DependencyStatus,
type ArtifactStatus,
type ChangeStatus,
} from './instruction-loader.js';
+18 -51
View File
@@ -3,7 +3,6 @@ import * as path from 'node:path';
import { getSchemaDir, resolveSchema } from './resolver.js';
import { ArtifactGraph } from './graph.js';
import { detectCompleted } from './state.js';
import { resolveSchemaForChange } from '../../utils/change-metadata.js';
import type { Artifact, CompletedSet } from './types.js';
/**
@@ -45,34 +44,26 @@ export interface ArtifactInstructions {
artifactId: string;
/** Schema name */
schemaName: string;
/** Full path to change directory */
changeDir: string;
/** Output path pattern (e.g., "proposal.md") */
outputPath: string;
/** Artifact description */
description: string;
/** Guidance on how to create this artifact (from schema instruction field) */
instruction: string | undefined;
/** Template content (structure to follow) */
/** Template content */
template: string;
/** Dependencies with completion status and paths */
dependencies: DependencyInfo[];
/** Dependencies with completion status */
dependencies: DependencyStatus[];
/** Artifacts that become available after completing this one */
unlocks: string[];
}
/**
* Dependency information including path and description.
* Dependency status information.
*/
export interface DependencyInfo {
export interface DependencyStatus {
/** Artifact ID */
id: string;
/** Whether the dependency is completed */
done: boolean;
/** Relative output path of the dependency (e.g., "proposal.md") */
path: string;
/** Description of the dependency artifact */
description: string;
}
/**
@@ -99,8 +90,6 @@ export interface ChangeStatus {
schemaName: string;
/** Whether all artifacts are complete */
isComplete: boolean;
/** Artifact IDs required before apply phase (from schema's apply.requires) */
applyRequires: string[];
/** Status of each artifact */
artifacts: ArtifactStatus[];
}
@@ -145,34 +134,25 @@ export function loadTemplate(schemaName: string, templatePath: string): string {
/**
* Loads change context combining graph and completion state.
*
* Schema resolution order:
* 1. Explicit schemaName parameter (if provided)
* 2. Schema from .openspec.yaml metadata (if exists in change directory)
* 3. Default 'spec-driven'
*
* @param projectRoot - Project root directory
* @param changeName - Change name
* @param schemaName - Optional schema name override. If not provided, auto-detected from metadata.
* @param schemaName - Optional schema name (defaults to "spec-driven")
* @returns Change context with graph, completed set, and metadata
*/
export function loadChangeContext(
projectRoot: string,
changeName: string,
schemaName?: string
schemaName: string = 'spec-driven'
): ChangeContext {
const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);
// Resolve schema: explicit > metadata > default
const resolvedSchemaName = resolveSchemaForChange(changeDir, schemaName);
const schema = resolveSchema(resolvedSchemaName);
const schema = resolveSchema(schemaName);
const graph = ArtifactGraph.fromSchema(schema);
const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);
const completed = detectCompleted(graph, changeDir);
return {
graph,
completed,
schemaName: resolvedSchemaName,
schemaName,
changeName,
changeDir,
};
@@ -196,17 +176,15 @@ export function generateInstructions(
}
const template = loadTemplate(context.schemaName, artifact.template);
const dependencies = getDependencyInfo(artifact, context.graph, context.completed);
const dependencies = getDependencyStatus(artifact, context.completed);
const unlocks = getUnlockedArtifacts(context.graph, artifactId);
return {
changeName: context.changeName,
artifactId: artifact.id,
schemaName: context.schemaName,
changeDir: context.changeDir,
outputPath: artifact.generates,
description: artifact.description,
instruction: artifact.instruction,
template,
dependencies,
unlocks,
@@ -214,22 +192,16 @@ export function generateInstructions(
}
/**
* Gets dependency info including paths and descriptions.
* Gets dependency status for an artifact.
*/
function getDependencyInfo(
function getDependencyStatus(
artifact: Artifact,
graph: ArtifactGraph,
completed: CompletedSet
): DependencyInfo[] {
return artifact.requires.map(id => {
const depArtifact = graph.getArtifact(id);
return {
id,
done: completed.has(id),
path: depArtifact?.generates ?? id,
description: depArtifact?.description ?? '',
};
});
): DependencyStatus[] {
return artifact.requires.map(id => ({
id,
done: completed.has(id),
}));
}
/**
@@ -254,10 +226,6 @@ function getUnlockedArtifacts(graph: ArtifactGraph, artifactId: string): string[
* @returns Formatted change status
*/
export function formatChangeStatus(context: ChangeContext): ChangeStatus {
// Load schema to get apply phase configuration
const schema = resolveSchema(context.schemaName);
const applyRequires = schema.apply?.requires ?? schema.artifacts.map(a => a.id);
const artifacts = context.graph.getAllArtifacts();
const ready = new Set(context.graph.getNextArtifacts(context.completed));
const blocked = context.graph.getBlocked(context.completed);
@@ -296,7 +264,6 @@ export function formatChangeStatus(context: ChangeContext): ChangeStatus {
changeName: context.changeName,
schemaName: context.schemaName,
isComplete: context.graph.isComplete(context.completed),
applyRequires,
artifacts: artifactStatuses,
};
}
-68
View File
@@ -156,71 +156,3 @@ export function listSchemas(): string[] {
return Array.from(schemas).sort();
}
/**
* Schema info with metadata (name, description, artifacts).
*/
export interface SchemaInfo {
name: string;
description: string;
artifacts: string[];
source: 'package' | 'user';
}
/**
* Lists all available schemas with their descriptions and artifact lists.
* Useful for agent skills to present schema selection to users.
*/
export function listSchemasWithInfo(): SchemaInfo[] {
const schemas: SchemaInfo[] = [];
const seenNames = new Set<string>();
// Add user override schemas first (they take precedence)
const userDir = getUserSchemasDir();
if (fs.existsSync(userDir)) {
for (const entry of fs.readdirSync(userDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
const schemaPath = path.join(userDir, entry.name, 'schema.yaml');
if (fs.existsSync(schemaPath)) {
try {
const schema = parseSchema(fs.readFileSync(schemaPath, 'utf-8'));
schemas.push({
name: entry.name,
description: schema.description || '',
artifacts: schema.artifacts.map((a) => a.id),
source: 'user',
});
seenNames.add(entry.name);
} catch {
// Skip invalid schemas
}
}
}
}
}
// Add package built-in schemas (if not overridden)
const packageDir = getPackageSchemasDir();
if (fs.existsSync(packageDir)) {
for (const entry of fs.readdirSync(packageDir, { withFileTypes: true })) {
if (entry.isDirectory() && !seenNames.has(entry.name)) {
const schemaPath = path.join(packageDir, entry.name, 'schema.yaml');
if (fs.existsSync(schemaPath)) {
try {
const schema = parseSchema(fs.readFileSync(schemaPath, 'utf-8'));
schemas.push({
name: entry.name,
description: schema.description || '',
artifacts: schema.artifacts.map((a) => a.id),
source: 'package',
});
} catch {
// Skip invalid schemas
}
}
}
}
}
return schemas.sort((a, b) => a.name.localeCompare(b.name));
}
-32
View File
@@ -6,53 +6,21 @@ export const ArtifactSchema = z.object({
generates: z.string().min(1, { error: 'generates field is required' }),
description: z.string(),
template: z.string().min(1, { error: 'template field is required' }),
instruction: z.string().optional(),
requires: z.array(z.string()).default([]),
});
// Apply phase configuration for schema-aware apply instructions
export const ApplyPhaseSchema = z.object({
// Artifact IDs that must exist before apply is available
requires: z.array(z.string()).min(1, { error: 'At least one required artifact' }),
// Path to file with checkboxes for progress (relative to change dir), or null if no tracking
tracks: z.string().nullable().optional(),
// Custom guidance for the apply phase
instruction: z.string().optional(),
});
// Full schema YAML structure
export const SchemaYamlSchema = z.object({
name: z.string().min(1, { error: 'Schema name is required' }),
version: z.number().int().positive({ error: 'Version must be a positive integer' }),
description: z.string().optional(),
artifacts: z.array(ArtifactSchema).min(1, { error: 'At least one artifact required' }),
// Optional apply phase configuration (for schema-aware apply instructions)
apply: ApplyPhaseSchema.optional(),
});
// Derived TypeScript types
export type Artifact = z.infer<typeof ArtifactSchema>;
export type ApplyPhase = z.infer<typeof ApplyPhaseSchema>;
export type SchemaYaml = z.infer<typeof SchemaYamlSchema>;
// Per-change metadata schema
// Note: schema field is validated at parse time against available schemas
// using a lazy import to avoid circular dependencies
export const ChangeMetadataSchema = z.object({
// Required: which workflow schema this change uses
schema: z.string().min(1, { message: 'schema is required' }),
// Optional: creation timestamp (ISO date string)
created: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, {
message: 'created must be YYYY-MM-DD format',
})
.optional(),
});
export type ChangeMetadata = z.infer<typeof ChangeMetadataSchema>;
// Runtime state types (not Zod - internal only)
// Slice 1: Simple completion tracking via filesystem
+1 -7
View File
@@ -284,13 +284,7 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
description: 'Uninstall completion script for a shell',
acceptsPositional: true,
positionalType: 'shell',
flags: [
{
name: 'yes',
short: 'y',
description: 'Skip confirmation prompts',
},
],
flags: [],
},
],
},
+5 -37
View File
@@ -1,31 +1,8 @@
import { CompletionGenerator } from './types.js';
import { ZshGenerator } from './generators/zsh-generator.js';
import { BashGenerator } from './generators/bash-generator.js';
import { FishGenerator } from './generators/fish-generator.js';
import { PowerShellGenerator } from './generators/powershell-generator.js';
import { ZshInstaller } from './installers/zsh-installer.js';
import { BashInstaller } from './installers/bash-installer.js';
import { FishInstaller } from './installers/fish-installer.js';
import { PowerShellInstaller } from './installers/powershell-installer.js';
import { ZshInstaller, InstallationResult } from './installers/zsh-installer.js';
import { SupportedShell } from '../../utils/shell-detection.js';
/**
* Common installation result interface
*/
export interface InstallationResult {
success: boolean;
installedPath?: string;
backupPath?: string;
message: string;
instructions?: string[];
warnings?: string[];
// Shell-specific optional fields
isOhMyZsh?: boolean;
zshrcConfigured?: boolean;
bashrcConfigured?: boolean;
profileConfigured?: boolean;
}
/**
* Interface for completion installers
*/
@@ -34,12 +11,15 @@ export interface CompletionInstaller {
uninstall(): Promise<{ success: boolean; message: string }>;
}
// Re-export InstallationResult for convenience
export type { InstallationResult };
/**
* Factory for creating completion generators and installers
* This design makes it easy to add support for additional shells
*/
export class CompletionFactory {
private static readonly SUPPORTED_SHELLS: SupportedShell[] = ['zsh', 'bash', 'fish', 'powershell'];
private static readonly SUPPORTED_SHELLS: SupportedShell[] = ['zsh'];
/**
* Create a completion generator for the specified shell
@@ -52,12 +32,6 @@ export class CompletionFactory {
switch (shell) {
case 'zsh':
return new ZshGenerator();
case 'bash':
return new BashGenerator();
case 'fish':
return new FishGenerator();
case 'powershell':
return new PowerShellGenerator();
default:
throw new Error(`Unsupported shell: ${shell}`);
}
@@ -74,12 +48,6 @@ export class CompletionFactory {
switch (shell) {
case 'zsh':
return new ZshInstaller();
case 'bash':
return new BashInstaller();
case 'fish':
return new FishInstaller();
case 'powershell':
return new PowerShellInstaller();
default:
throw new Error(`Unsupported shell: ${shell}`);
}
@@ -1,175 +0,0 @@
import { CompletionGenerator, CommandDefinition, FlagDefinition } from '../types.js';
import { BASH_DYNAMIC_HELPERS } from '../templates/bash-templates.js';
/**
* Generates Bash completion scripts for the OpenSpec CLI.
* Follows Bash completion conventions using complete builtin and COMPREPLY array.
*/
export class BashGenerator implements CompletionGenerator {
readonly shell = 'bash' as const;
/**
* Generate a Bash completion script
*
* @param commands - Command definitions to generate completions for
* @returns Bash completion script as a string
*/
generate(commands: CommandDefinition[]): string {
// Build command list for top-level completions
const commandList = commands.map(c => this.escapeCommandName(c.name)).join(' ');
// Build command cases using push() for loop clarity
const caseLines: string[] = [];
for (const cmd of commands) {
caseLines.push(` ${cmd.name})`);
caseLines.push(...this.generateCommandCase(cmd, ' '));
caseLines.push(' ;;');
}
const commandCases = caseLines.join('\n');
// Dynamic completion helpers from template
const helpers = BASH_DYNAMIC_HELPERS;
// Assemble final script with template literal
return `# Bash completion script for OpenSpec CLI
# Auto-generated - do not edit manually
_openspec_completion() {
local cur prev words cword
# Use _init_completion if available (from bash-completion package)
# The -n : option prevents colons from being treated as word separators
# (important for spec/change IDs that may contain colons)
# Otherwise, fall back to manual initialization
if declare -F _init_completion >/dev/null 2>&1; then
_init_completion -n : || return
else
# Manual fallback when bash-completion is not installed
COMPREPLY=()
cur="\${COMP_WORDS[COMP_CWORD]}"
prev="\${COMP_WORDS[COMP_CWORD-1]}"
words=("\${COMP_WORDS[@]}")
cword=$COMP_CWORD
fi
local cmd="\${words[1]}"
local subcmd="\${words[2]}"
# Top-level commands
if [[ $cword -eq 1 ]]; then
local commands="${commandList}"
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
return 0
fi
# Command-specific completion
case "$cmd" in
${commandCases}
esac
return 0
}
${helpers}
complete -F _openspec_completion openspec
`;
}
/**
* Generate completion case logic for a command
*/
private generateCommandCase(cmd: CommandDefinition, indent: string): string[] {
const lines: string[] = [];
// Handle subcommands
if (cmd.subcommands && cmd.subcommands.length > 0) {
lines.push(`${indent}if [[ $cword -eq 2 ]]; then`);
lines.push(`${indent} local subcommands="` + cmd.subcommands.map(s => this.escapeCommandName(s.name)).join(' ') + '"');
lines.push(`${indent} COMPREPLY=($(compgen -W "$subcommands" -- "$cur"))`);
lines.push(`${indent} return 0`);
lines.push(`${indent}fi`);
lines.push('');
lines.push(`${indent}case "$subcmd" in`);
for (const subcmd of cmd.subcommands) {
lines.push(`${indent} ${subcmd.name})`);
lines.push(...this.generateArgumentCompletion(subcmd, indent + ' '));
lines.push(`${indent} ;;`);
}
lines.push(`${indent}esac`);
} else {
// No subcommands, just complete arguments
lines.push(...this.generateArgumentCompletion(cmd, indent));
}
return lines;
}
/**
* Generate argument completion (flags and positional arguments)
*/
private generateArgumentCompletion(cmd: CommandDefinition, indent: string): string[] {
const lines: string[] = [];
// Check for flag completion
if (cmd.flags.length > 0) {
lines.push(`${indent}if [[ "$cur" == -* ]]; then`);
const flags = cmd.flags.map(f => {
const parts: string[] = [];
if (f.short) parts.push(`-${f.short}`);
parts.push(`--${f.name}`);
return parts.join(' ');
}).join(' ');
lines.push(`${indent} local flags="${flags}"`);
lines.push(`${indent} COMPREPLY=($(compgen -W "$flags" -- "$cur"))`);
lines.push(`${indent} return 0`);
lines.push(`${indent}fi`);
lines.push('');
}
// Handle positional completions
if (cmd.acceptsPositional) {
lines.push(...this.generatePositionalCompletion(cmd.positionalType, indent));
}
return lines;
}
/**
* Generate positional argument completion based on type
*/
private generatePositionalCompletion(positionalType: string | undefined, indent: string): string[] {
const lines: string[] = [];
switch (positionalType) {
case 'change-id':
lines.push(`${indent}_openspec_complete_changes`);
break;
case 'spec-id':
lines.push(`${indent}_openspec_complete_specs`);
break;
case 'change-or-spec-id':
lines.push(`${indent}_openspec_complete_items`);
break;
case 'shell':
lines.push(`${indent}local shells="zsh bash fish powershell"`);
lines.push(`${indent}COMPREPLY=($(compgen -W "$shells" -- "$cur"))`);
break;
case 'path':
lines.push(`${indent}COMPREPLY=($(compgen -f -- "$cur"))`);
break;
}
return lines;
}
/**
* Escape command/subcommand names for safe use in Bash scripts
*/
private escapeCommandName(name: string): string {
// Escape shell metacharacters to prevent command injection
return name.replace(/["\$`\\]/g, '\\$&');
}
}
@@ -1,188 +0,0 @@
import { CompletionGenerator, CommandDefinition, FlagDefinition } from '../types.js';
import { FISH_STATIC_HELPERS, FISH_DYNAMIC_HELPERS } from '../templates/fish-templates.js';
/**
* Generates Fish completion scripts for the OpenSpec CLI.
* Follows Fish completion conventions using the complete command.
*/
export class FishGenerator implements CompletionGenerator {
readonly shell = 'fish' as const;
/**
* Generate a Fish completion script
*
* @param commands - Command definitions to generate completions for
* @returns Fish completion script as a string
*/
generate(commands: CommandDefinition[]): string {
// Build top-level commands using push() for loop clarity
const topLevelLines: string[] = [];
for (const cmd of commands) {
topLevelLines.push(`# ${cmd.name} command`);
topLevelLines.push(
`complete -c openspec -n '__fish_openspec_no_subcommand' -a '${cmd.name}' -d '${this.escapeDescription(cmd.description)}'`
);
}
const topLevelCommands = topLevelLines.join('\n');
// Build command-specific completions using push() for loop clarity
const commandCompletionLines: string[] = [];
for (const cmd of commands) {
commandCompletionLines.push(...this.generateCommandCompletions(cmd));
commandCompletionLines.push('');
}
const commandCompletions = commandCompletionLines.join('\n');
// Static helper functions from template
const helperFunctions = FISH_STATIC_HELPERS;
// Dynamic completion helpers from template
const dynamicHelpers = FISH_DYNAMIC_HELPERS;
// Assemble final script with template literal
return `# Fish completion script for OpenSpec CLI
# Auto-generated - do not edit manually
${helperFunctions}
${dynamicHelpers}
${topLevelCommands}
${commandCompletions}`;
}
/**
* Generate completions for a specific command
*/
private generateCommandCompletions(cmd: CommandDefinition): string[] {
const lines: string[] = [];
// If command has subcommands
if (cmd.subcommands && cmd.subcommands.length > 0) {
// Add subcommand completions
for (const subcmd of cmd.subcommands) {
lines.push(
`complete -c openspec -n '__fish_openspec_using_subcommand ${cmd.name}; and not __fish_openspec_using_subcommand ${subcmd.name}' -a '${subcmd.name}' -d '${this.escapeDescription(subcmd.description)}'`
);
}
lines.push('');
// Add flags for parent command
for (const flag of cmd.flags) {
lines.push(...this.generateFlagCompletion(flag, `__fish_openspec_using_subcommand ${cmd.name}`));
}
// Add completions for each subcommand
for (const subcmd of cmd.subcommands) {
lines.push(`# ${cmd.name} ${subcmd.name} flags`);
for (const flag of subcmd.flags) {
lines.push(...this.generateFlagCompletion(flag, `__fish_openspec_using_subcommand ${cmd.name}; and __fish_openspec_using_subcommand ${subcmd.name}`));
}
// Add positional completions for subcommand
if (subcmd.acceptsPositional) {
lines.push(...this.generatePositionalCompletion(subcmd.positionalType, `__fish_openspec_using_subcommand ${cmd.name}; and __fish_openspec_using_subcommand ${subcmd.name}`));
}
}
} else {
// Command without subcommands
lines.push(`# ${cmd.name} flags`);
for (const flag of cmd.flags) {
lines.push(...this.generateFlagCompletion(flag, `__fish_openspec_using_subcommand ${cmd.name}`));
}
// Add positional completions
if (cmd.acceptsPositional) {
lines.push(...this.generatePositionalCompletion(cmd.positionalType, `__fish_openspec_using_subcommand ${cmd.name}`));
}
}
return lines;
}
/**
* Generate flag completion
*/
private generateFlagCompletion(flag: FlagDefinition, condition: string): string[] {
const lines: string[] = [];
const longFlag = `--${flag.name}`;
const shortFlag = flag.short ? `-${flag.short}` : undefined;
if (flag.takesValue && flag.values) {
// Flag with enum values
for (const value of flag.values) {
if (shortFlag) {
lines.push(
`complete -c openspec -n '${condition}' -s ${flag.short} -l ${flag.name} -a '${value}' -d '${this.escapeDescription(flag.description)}'`
);
} else {
lines.push(
`complete -c openspec -n '${condition}' -l ${flag.name} -a '${value}' -d '${this.escapeDescription(flag.description)}'`
);
}
}
} else if (flag.takesValue) {
// Flag that takes a value but no specific values defined
if (shortFlag) {
lines.push(
`complete -c openspec -n '${condition}' -s ${flag.short} -l ${flag.name} -r -d '${this.escapeDescription(flag.description)}'`
);
} else {
lines.push(
`complete -c openspec -n '${condition}' -l ${flag.name} -r -d '${this.escapeDescription(flag.description)}'`
);
}
} else {
// Boolean flag
if (shortFlag) {
lines.push(
`complete -c openspec -n '${condition}' -s ${flag.short} -l ${flag.name} -d '${this.escapeDescription(flag.description)}'`
);
} else {
lines.push(
`complete -c openspec -n '${condition}' -l ${flag.name} -d '${this.escapeDescription(flag.description)}'`
);
}
}
return lines;
}
/**
* Generate positional argument completion
*/
private generatePositionalCompletion(positionalType: string | undefined, condition: string): string[] {
const lines: string[] = [];
switch (positionalType) {
case 'change-id':
lines.push(`complete -c openspec -n '${condition}' -a '(__fish_openspec_changes)' -f`);
break;
case 'spec-id':
lines.push(`complete -c openspec -n '${condition}' -a '(__fish_openspec_specs)' -f`);
break;
case 'change-or-spec-id':
lines.push(`complete -c openspec -n '${condition}' -a '(__fish_openspec_items)' -f`);
break;
case 'shell':
lines.push(`complete -c openspec -n '${condition}' -a 'zsh bash fish powershell' -f`);
break;
case 'path':
// Fish automatically completes files, no need to specify
break;
}
return lines;
}
/**
* Escape description text for Fish
*/
private escapeDescription(description: string): string {
return description
.replace(/\\/g, '\\\\') // Backslashes first
.replace(/'/g, "\\'") // Single quotes
.replace(/\$/g, '\\$') // Dollar signs (prevents $())
.replace(/`/g, '\\`'); // Backticks
}
}
@@ -1,191 +0,0 @@
import { CompletionGenerator, CommandDefinition, FlagDefinition } from '../types.js';
import { POWERSHELL_DYNAMIC_HELPERS } from '../templates/powershell-templates.js';
/**
* Generates PowerShell completion scripts for the OpenSpec CLI.
* Uses Register-ArgumentCompleter for command completion.
*/
export class PowerShellGenerator implements CompletionGenerator {
readonly shell = 'powershell' as const;
/**
* Generate a PowerShell completion script
*
* @param commands - Command definitions to generate completions for
* @returns PowerShell completion script as a string
*/
generate(commands: CommandDefinition[]): string {
// Build top-level commands using push() for loop clarity
const commandLines: string[] = [];
for (const cmd of commands) {
commandLines.push(` @{Name="${cmd.name}"; Description="${this.escapeDescription(cmd.description)}"},`);
}
const topLevelCommands = commandLines.join('\n');
// Build command cases using push() for loop clarity
const commandCaseLines: string[] = [];
for (const cmd of commands) {
commandCaseLines.push(` "${cmd.name}" {`);
commandCaseLines.push(...this.generateCommandCase(cmd, ' '));
commandCaseLines.push(' }');
}
const commandCases = commandCaseLines.join('\n');
// Dynamic completion helpers from template
const helpers = POWERSHELL_DYNAMIC_HELPERS;
// Assemble final script with template literal
return `# PowerShell completion script for OpenSpec CLI
# Auto-generated - do not edit manually
${helpers}
$openspecCompleter = {
param($wordToComplete, $commandAst, $cursorPosition)
$tokens = $commandAst.ToString() -split "\\s+"
$commandCount = ($tokens | Measure-Object).Count
# Top-level commands
if ($commandCount -eq 1 -or ($commandCount -eq 2 -and $wordToComplete)) {
$commands = @(
${topLevelCommands}
)
$commands | Where-Object { $_.Name -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, "ParameterValue", $_.Description)
}
return
}
$command = $tokens[1]
switch ($command) {
${commandCases}
}
}
Register-ArgumentCompleter -CommandName openspec -ScriptBlock $openspecCompleter
`;
}
/**
* Generate completion case for a command
*/
private generateCommandCase(cmd: CommandDefinition, indent: string): string[] {
const lines: string[] = [];
if (cmd.subcommands && cmd.subcommands.length > 0) {
// Handle subcommands
lines.push(`${indent}if ($commandCount -eq 2 -or ($commandCount -eq 3 -and $wordToComplete)) {`);
lines.push(`${indent} $subcommands = @(`);
for (const subcmd of cmd.subcommands) {
lines.push(`${indent} @{Name="${subcmd.name}"; Description="${this.escapeDescription(subcmd.description)}"},`);
}
lines.push(`${indent} )`);
lines.push(`${indent} $subcommands | Where-Object { $_.Name -like "$wordToComplete*" } | ForEach-Object {`);
lines.push(`${indent} [System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, "ParameterValue", $_.Description)`);
lines.push(`${indent} }`);
lines.push(`${indent} return`);
lines.push(`${indent}}`);
lines.push('');
lines.push(`${indent}$subcommand = if ($commandCount -gt 2) { $tokens[2] } else { "" }`);
lines.push(`${indent}switch ($subcommand) {`);
for (const subcmd of cmd.subcommands) {
lines.push(`${indent} "${subcmd.name}" {`);
lines.push(...this.generateArgumentCompletion(subcmd, indent + ' '));
lines.push(`${indent} }`);
}
lines.push(`${indent}}`);
} else {
// No subcommands
lines.push(...this.generateArgumentCompletion(cmd, indent));
}
return lines;
}
/**
* Generate argument completion (flags and positional)
*/
private generateArgumentCompletion(cmd: CommandDefinition, indent: string): string[] {
const lines: string[] = [];
// Flag completion
if (cmd.flags.length > 0) {
lines.push(`${indent}if ($wordToComplete -like "-*") {`);
lines.push(`${indent} $flags = @(`);
for (const flag of cmd.flags) {
const longFlag = `--${flag.name}`;
const shortFlag = flag.short ? `-${flag.short}` : undefined;
if (shortFlag) {
lines.push(`${indent} @{Name="${longFlag}"; Description="${this.escapeDescription(flag.description)}"},`);
lines.push(`${indent} @{Name="${shortFlag}"; Description="${this.escapeDescription(flag.description)}"},`);
} else {
lines.push(`${indent} @{Name="${longFlag}"; Description="${this.escapeDescription(flag.description)}"},`);
}
}
lines.push(`${indent} )`);
lines.push(`${indent} $flags | Where-Object { $_.Name -like "$wordToComplete*" } | ForEach-Object {`);
lines.push(`${indent} [System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, "ParameterName", $_.Description)`);
lines.push(`${indent} }`);
lines.push(`${indent} return`);
lines.push(`${indent}}`);
lines.push('');
}
// Positional completion
if (cmd.acceptsPositional) {
lines.push(...this.generatePositionalCompletion(cmd.positionalType, indent));
}
return lines;
}
/**
* Generate positional argument completion
*/
private generatePositionalCompletion(positionalType: string | undefined, indent: string): string[] {
const lines: string[] = [];
switch (positionalType) {
case 'change-id':
lines.push(`${indent}Get-OpenSpecChanges | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {`);
lines.push(`${indent} [System.Management.Automation.CompletionResult]::new($_, $_, "ParameterValue", "Change: $_")`);
lines.push(`${indent}}`);
break;
case 'spec-id':
lines.push(`${indent}Get-OpenSpecSpecs | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {`);
lines.push(`${indent} [System.Management.Automation.CompletionResult]::new($_, $_, "ParameterValue", "Spec: $_")`);
lines.push(`${indent}}`);
break;
case 'change-or-spec-id':
lines.push(`${indent}$items = @(Get-OpenSpecChanges) + @(Get-OpenSpecSpecs)`);
lines.push(`${indent}$items | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {`);
lines.push(`${indent} [System.Management.Automation.CompletionResult]::new($_, $_, "ParameterValue", $_)`);
lines.push(`${indent}}`);
break;
case 'shell':
lines.push(`${indent}$shells = @("zsh", "bash", "fish", "powershell")`);
lines.push(`${indent}$shells | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {`);
lines.push(`${indent} [System.Management.Automation.CompletionResult]::new($_, $_, "ParameterValue", "Shell: $_")`);
lines.push(`${indent}}`);
break;
case 'path':
// PowerShell handles file path completion automatically
break;
}
return lines;
}
/**
* Escape description text for PowerShell
*/
private escapeDescription(description: string): string {
return description
.replace(/`/g, '``') // Backticks (escape sequences)
.replace(/\$/g, '`$') // Dollar signs (prevents $())
.replace(/"/g, '""'); // Double quotes
}
}
+141 -48
View File
@@ -1,5 +1,4 @@
import { CompletionGenerator, CommandDefinition, FlagDefinition } from '../types.js';
import { ZSH_DYNAMIC_HELPERS } from '../templates/zsh-templates.js';
/**
* Generates Zsh completion scripts for the OpenSpec CLI.
@@ -15,69 +14,163 @@ export class ZshGenerator implements CompletionGenerator {
* @returns Zsh completion script as a string
*/
generate(commands: CommandDefinition[]): string {
// Build command list using push() for loop clarity
const commandLines: string[] = [];
const script: string[] = [];
// Header comment
script.push('#compdef openspec');
script.push('');
script.push('# Zsh completion script for OpenSpec CLI');
script.push('# Auto-generated - do not edit manually');
script.push('');
// Main completion function
script.push('_openspec() {');
script.push(' local context state line');
script.push(' typeset -A opt_args');
script.push('');
// Generate main command argument specification
script.push(' local -a commands');
script.push(' commands=(');
for (const cmd of commands) {
const escapedDesc = this.escapeDescription(cmd.description);
commandLines.push(` '${cmd.name}:${escapedDesc}'`);
script.push(` '${cmd.name}:${escapedDesc}'`);
}
const commandList = commandLines.join('\n');
script.push(' )');
script.push('');
// Build command cases using push() for loop clarity
const commandCaseLines: string[] = [];
// Main _arguments call
script.push(' _arguments -C \\');
script.push(' "1: :->command" \\');
script.push(' "*::arg:->args"');
script.push('');
// Command dispatch logic
script.push(' case $state in');
script.push(' command)');
script.push(' _describe "openspec command" commands');
script.push(' ;;');
script.push(' args)');
script.push(' case $words[1] in');
// Generate completion for each command
for (const cmd of commands) {
commandCaseLines.push(` ${cmd.name})`);
commandCaseLines.push(` _openspec_${this.sanitizeFunctionName(cmd.name)}`);
commandCaseLines.push(' ;;');
script.push(` ${cmd.name})`);
script.push(` _openspec_${this.sanitizeFunctionName(cmd.name)}`);
script.push(' ;;');
}
const commandCases = commandCaseLines.join('\n');
// Build command functions using push() for loop clarity
const commandFunctionLines: string[] = [];
script.push(' esac');
script.push(' ;;');
script.push(' esac');
script.push('}');
script.push('');
// Generate individual command completion functions
for (const cmd of commands) {
commandFunctionLines.push(...this.generateCommandFunction(cmd));
commandFunctionLines.push('');
script.push(...this.generateCommandFunction(cmd));
script.push('');
}
const commandFunctions = commandFunctionLines.join('\n');
// Dynamic completion helpers from template
const helpers = ZSH_DYNAMIC_HELPERS;
// Add dynamic completion helper functions
script.push(...this.generateDynamicCompletionHelpers());
// Assemble final script with template literal
return `#compdef openspec
// Register the completion function
script.push('compdef _openspec openspec');
script.push('');
# Zsh completion script for OpenSpec CLI
# Auto-generated - do not edit manually
return script.join('\n');
}
_openspec() {
local context state line
typeset -A opt_args
/**
* Generate a single completion function
*
* @param functionName - Name of the completion function
* @param varName - Name of the local array variable
* @param varLabel - Label for the completion items
* @param commandLines - Command line(s) to populate the array
* @param comment - Optional comment describing the function
*/
private generateCompletionFunction(
functionName: string,
varName: string,
varLabel: string,
commandLines: string[],
comment?: string
): string[] {
const lines: string[] = [];
local -a commands
commands=(
${commandList}
)
if (comment) {
lines.push(comment);
}
_arguments -C \\
"1: :->command" \\
"*::arg:->args"
lines.push(`${functionName}() {`);
lines.push(` local -a ${varName}`);
case $state in
command)
_describe "openspec command" commands
;;
args)
case $words[1] in
${commandCases}
esac
;;
esac
}
if (commandLines.length === 1) {
lines.push(` ${commandLines[0]}`);
} else {
lines.push(` ${varName}=(`);
for (let i = 0; i < commandLines.length; i++) {
const suffix = i < commandLines.length - 1 ? ' \\' : '';
lines.push(` ${commandLines[i]}${suffix}`);
}
lines.push(' )');
}
${commandFunctions}
${helpers}
compdef _openspec openspec
`;
lines.push(` _describe "${varLabel}" ${varName}`);
lines.push('}');
lines.push('');
return lines;
}
/**
* Generate dynamic completion helper functions for change and spec IDs
*/
private generateDynamicCompletionHelpers(): string[] {
const lines: string[] = [];
lines.push('# Dynamic completion helpers');
lines.push('');
// Helper function for completing change IDs
lines.push('# Use openspec __complete to get available changes');
lines.push('_openspec_complete_changes() {');
lines.push(' local -a changes');
lines.push(' while IFS=$\'\\t\' read -r id desc; do');
lines.push(' changes+=("$id:$desc")');
lines.push(' done < <(openspec __complete changes 2>/dev/null)');
lines.push(' _describe "change" changes');
lines.push('}');
lines.push('');
// Helper function for completing spec IDs
lines.push('# Use openspec __complete to get available specs');
lines.push('_openspec_complete_specs() {');
lines.push(' local -a specs');
lines.push(' while IFS=$\'\\t\' read -r id desc; do');
lines.push(' specs+=("$id:$desc")');
lines.push(' done < <(openspec __complete specs 2>/dev/null)');
lines.push(' _describe "spec" specs');
lines.push('}');
lines.push('');
// Helper function for completing both changes and specs
lines.push('# Get both changes and specs');
lines.push('_openspec_complete_items() {');
lines.push(' local -a items');
lines.push(' while IFS=$\'\\t\' read -r id desc; do');
lines.push(' items+=("$id:$desc")');
lines.push(' done < <(openspec __complete changes 2>/dev/null)');
lines.push(' while IFS=$\'\\t\' read -r id desc; do');
lines.push(' items+=("$id:$desc")');
lines.push(' done < <(openspec __complete specs 2>/dev/null)');
lines.push(' _describe "item" items');
lines.push('}');
lines.push('');
return lines;
}
/**
@@ -244,7 +337,7 @@ compdef _openspec openspec
case 'path':
return "'*:path:_files'";
case 'shell':
return "'*:shell:(zsh bash fish powershell)'";
return "'*:shell:(zsh)'";
default:
return "'*: :_default'";
}
@@ -1,366 +0,0 @@
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { FileSystemUtils } from '../../../utils/file-system.js';
import { InstallationResult } from '../factory.js';
/**
* Installer for Bash completion scripts.
* Supports bash-completion package and standalone installations.
*/
export class BashInstaller {
private readonly homeDir: string;
/**
* Markers for .bashrc configuration management
*/
private readonly BASHRC_MARKERS = {
start: '# OPENSPEC:START',
end: '# OPENSPEC:END',
};
constructor(homeDir: string = os.homedir()) {
this.homeDir = homeDir;
}
/**
* Check if bash-completion is installed
*
* @returns true if bash-completion directories exist
*/
async isBashCompletionInstalled(): Promise<boolean> {
const paths = [
'/usr/share/bash-completion', // Linux system-wide
'/usr/local/share/bash-completion', // Homebrew Intel (main)
'/opt/homebrew/etc/bash_completion.d', // Homebrew Apple Silicon
'/usr/local/etc/bash_completion.d', // Homebrew Intel (alt path)
'/etc/bash_completion.d', // Legacy fallback
];
for (const p of paths) {
try {
const stat = await fs.stat(p);
if (stat.isDirectory()) {
return true;
}
} catch {
// Continue checking other paths
}
}
return false;
}
/**
* Get the appropriate installation path for the completion script
*
* @returns Installation path
*/
async getInstallationPath(): Promise<string> {
// Try user-local bash-completion directory first
const localCompletionDir = path.join(this.homeDir, '.local', 'share', 'bash-completion', 'completions');
// For user installation, use local directory
return path.join(localCompletionDir, 'openspec');
}
/**
* Backup an existing completion file if it exists
*
* @param targetPath - Path to the file to backup
* @returns Path to the backup file, or undefined if no backup was needed
*/
async backupExistingFile(targetPath: string): Promise<string | undefined> {
try {
await fs.access(targetPath);
// File exists, create a backup
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `${targetPath}.backup-${timestamp}`;
await fs.copyFile(targetPath, backupPath);
return backupPath;
} catch {
// File doesn't exist, no backup needed
return undefined;
}
}
/**
* Get the path to .bashrc file
*
* @returns Path to .bashrc
*/
private getBashrcPath(): string {
return path.join(this.homeDir, '.bashrc');
}
/**
* Generate .bashrc configuration content
*
* @param completionsDir - Directory containing completion scripts
* @returns Configuration content
*/
private generateBashrcConfig(completionsDir: string): string {
return [
'# OpenSpec shell completions configuration',
`if [ -d "${completionsDir}" ]; then`,
` for f in "${completionsDir}"/*; do`,
' [ -f "$f" ] && . "$f"',
' done',
'fi',
].join('\n');
}
/**
* Configure .bashrc to enable completions
*
* @param completionsDir - Directory containing completion scripts
* @returns true if configured successfully, false otherwise
*/
async configureBashrc(completionsDir: string): Promise<boolean> {
// Check if auto-configuration is disabled
if (process.env.OPENSPEC_NO_AUTO_CONFIG === '1') {
return false;
}
try {
const bashrcPath = this.getBashrcPath();
const config = this.generateBashrcConfig(completionsDir);
// Check write permissions
const canWrite = await FileSystemUtils.canWriteFile(bashrcPath);
if (!canWrite) {
return false;
}
// Use marker-based update
await FileSystemUtils.updateFileWithMarkers(
bashrcPath,
config,
this.BASHRC_MARKERS.start,
this.BASHRC_MARKERS.end
);
return true;
} catch (error: any) {
// Fail gracefully - don't break installation
console.debug(`Unable to configure .bashrc for completions: ${error.message}`);
return false;
}
}
/**
* Remove .bashrc configuration
* Used during uninstallation
*
* @returns true if removed successfully, false otherwise
*/
async removeBashrcConfig(): Promise<boolean> {
try {
const bashrcPath = this.getBashrcPath();
// Check if file exists
try {
await fs.access(bashrcPath);
} catch {
// File doesn't exist, nothing to remove
return true;
}
// Read file content
const content = await fs.readFile(bashrcPath, 'utf-8');
// Check if markers exist
if (!content.includes(this.BASHRC_MARKERS.start) || !content.includes(this.BASHRC_MARKERS.end)) {
// Markers don't exist, nothing to remove
return true;
}
// Remove content between markers (including markers)
const lines = content.split('\n');
const startIndex = lines.findIndex((line) => line.trim() === this.BASHRC_MARKERS.start);
const endIndex = lines.findIndex((line) => line.trim() === this.BASHRC_MARKERS.end);
if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) {
// Invalid marker placement
return false;
}
// Remove lines between markers (inclusive)
lines.splice(startIndex, endIndex - startIndex + 1);
// Remove trailing empty lines
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
lines.pop();
}
// Write back
await fs.writeFile(bashrcPath, lines.join('\n'), 'utf-8');
return true;
} catch (error: any) {
// Fail gracefully
console.debug(`Unable to remove .bashrc configuration: ${error.message}`);
return false;
}
}
/**
* Install the completion script
*
* @param completionScript - The completion script content to install
* @returns Installation result with status and instructions
*/
async install(completionScript: string): Promise<InstallationResult> {
try {
const targetPath = await this.getInstallationPath();
// Check for bash-completion package
const hasBashCompletion = await this.isBashCompletionInstalled();
// Check if already installed with same content
let isUpdate = false;
try {
const existingContent = await fs.readFile(targetPath, 'utf-8');
if (existingContent === completionScript) {
// Already installed and up to date
return {
success: true,
installedPath: targetPath,
message: 'Completion script is already installed (up to date)',
instructions: [
'The completion script is already installed and up to date.',
'If completions are not working, try: exec bash',
],
};
}
// File exists but content is different - this is an update
isUpdate = true;
} catch (error: any) {
// File doesn't exist or can't be read, proceed with installation
console.debug(`Unable to read existing completion file at ${targetPath}: ${error.message}`);
}
// Ensure the directory exists
const targetDir = path.dirname(targetPath);
await fs.mkdir(targetDir, { recursive: true });
// Backup existing file if updating
const backupPath = isUpdate ? await this.backupExistingFile(targetPath) : undefined;
// Write the completion script
await fs.writeFile(targetPath, completionScript, 'utf-8');
// Auto-configure .bashrc
const bashrcConfigured = await this.configureBashrc(targetDir);
// Generate instructions if .bashrc wasn't auto-configured
const instructions = bashrcConfigured ? undefined : this.generateInstructions(targetPath);
// Collect warnings
const warnings: string[] = [];
if (!hasBashCompletion) {
warnings.push(
'⚠️ Warning: bash-completion package not detected',
'',
'The completion script requires bash-completion to function.',
'Install it with:',
' brew install bash-completion@2',
'',
'Then add to your ~/.bash_profile:',
' [[ -r "/opt/homebrew/etc/profile.d/bash_completion.sh" ]] && . "/opt/homebrew/etc/profile.d/bash_completion.sh"'
);
}
// Determine appropriate message
let message: string;
if (isUpdate) {
message = backupPath
? 'Completion script updated successfully (previous version backed up)'
: 'Completion script updated successfully';
} else {
message = bashrcConfigured
? 'Completion script installed and .bashrc configured successfully'
: 'Completion script installed successfully for Bash';
}
return {
success: true,
installedPath: targetPath,
backupPath,
bashrcConfigured,
message,
instructions,
warnings: warnings.length > 0 ? warnings : undefined,
};
} catch (error) {
return {
success: false,
message: `Failed to install completion script: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
/**
* Generate user instructions for enabling completions
*
* @param installedPath - Path where the script was installed
* @returns Array of instruction strings
*/
private generateInstructions(installedPath: string): string[] {
const completionsDir = path.dirname(installedPath);
return [
'Completion script installed successfully.',
'',
'To enable completions, add the following to your ~/.bashrc file:',
'',
` # Source OpenSpec completions`,
` if [ -d "${completionsDir}" ]; then`,
` for f in "${completionsDir}"/*; do`,
' [ -f "$f" ] && . "$f"',
' done',
' fi',
'',
'Then restart your shell or run: exec bash',
];
}
/**
* Uninstall the completion script
*
* @param options - Optional uninstall options
* @param options.yes - Skip confirmation prompt (handled by command layer)
* @returns Uninstallation result
*/
async uninstall(options?: { yes?: boolean }): Promise<{ success: boolean; message: string }> {
try {
const targetPath = await this.getInstallationPath();
// Check if installed
try {
await fs.access(targetPath);
} catch {
return {
success: false,
message: 'Completion script is not installed',
};
}
// Remove the completion script
await fs.unlink(targetPath);
// Remove .bashrc configuration
await this.removeBashrcConfig();
return {
success: true,
message: 'Completion script uninstalled successfully',
};
} catch (error) {
return {
success: false,
message: `Failed to uninstall completion script: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
}
@@ -1,152 +0,0 @@
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { InstallationResult } from '../factory.js';
/**
* Installer for Fish completion scripts.
* Fish automatically loads completions from ~/.config/fish/completions/
*/
export class FishInstaller {
private readonly homeDir: string;
constructor(homeDir: string = os.homedir()) {
this.homeDir = homeDir;
}
/**
* Get the installation path for Fish completions
*
* @returns Installation path
*/
getInstallationPath(): string {
return path.join(this.homeDir, '.config', 'fish', 'completions', 'openspec.fish');
}
/**
* Backup an existing completion file if it exists
*
* @param targetPath - Path to the file to backup
* @returns Path to the backup file, or undefined if no backup was needed
*/
async backupExistingFile(targetPath: string): Promise<string | undefined> {
try {
await fs.access(targetPath);
// File exists, create a backup
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `${targetPath}.backup-${timestamp}`;
await fs.copyFile(targetPath, backupPath);
return backupPath;
} catch {
// File doesn't exist, no backup needed
return undefined;
}
}
/**
* Install the completion script
*
* @param completionScript - The completion script content to install
* @returns Installation result with status and instructions
*/
async install(completionScript: string): Promise<InstallationResult> {
try {
const targetPath = this.getInstallationPath();
// Check if already installed with same content
let isUpdate = false;
try {
const existingContent = await fs.readFile(targetPath, 'utf-8');
if (existingContent === completionScript) {
// Already installed and up to date
return {
success: true,
installedPath: targetPath,
message: 'Completion script is already installed (up to date)',
instructions: [
'The completion script is already installed and up to date.',
'Fish automatically loads completions - they should be available immediately.',
],
};
}
// File exists but content is different - this is an update
isUpdate = true;
} catch (error: any) {
// File doesn't exist or can't be read, proceed with installation
console.debug(`Unable to read existing completion file at ${targetPath}: ${error.message}`);
}
// Ensure the directory exists
const targetDir = path.dirname(targetPath);
await fs.mkdir(targetDir, { recursive: true });
// Backup existing file if updating
const backupPath = isUpdate ? await this.backupExistingFile(targetPath) : undefined;
// Write the completion script
await fs.writeFile(targetPath, completionScript, 'utf-8');
// Determine appropriate message
let message: string;
if (isUpdate) {
message = backupPath
? 'Completion script updated successfully (previous version backed up)'
: 'Completion script updated successfully';
} else {
message = 'Completion script installed successfully for Fish';
}
return {
success: true,
installedPath: targetPath,
backupPath,
message,
instructions: [
'Fish automatically loads completions from ~/.config/fish/completions/',
'Completions are available immediately - no shell restart needed.',
],
};
} catch (error) {
return {
success: false,
message: `Failed to install completion script: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
/**
* Uninstall the completion script
*
* @param options - Optional uninstall options
* @param options.yes - Skip confirmation prompt (handled by command layer)
* @returns Uninstallation result
*/
async uninstall(options?: { yes?: boolean }): Promise<{ success: boolean; message: string }> {
try {
const targetPath = this.getInstallationPath();
// Check if installed
try {
await fs.access(targetPath);
} catch {
return {
success: false,
message: 'Completion script is not installed',
};
}
// Remove the completion script
await fs.unlink(targetPath);
return {
success: true,
message: 'Completion script uninstalled successfully',
};
} catch (error) {
return {
success: false,
message: `Failed to uninstall completion script: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
}
@@ -1,358 +0,0 @@
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { FileSystemUtils } from '../../../utils/file-system.js';
import { InstallationResult } from '../factory.js';
/**
* Installer for PowerShell completion scripts.
* Works with both Windows PowerShell 5.1 and PowerShell Core 7+
*/
export class PowerShellInstaller {
private readonly homeDir: string;
/**
* Markers for PowerShell profile configuration management
*/
private readonly PROFILE_MARKERS = {
start: '# OPENSPEC:START',
end: '# OPENSPEC:END',
};
constructor(homeDir: string = os.homedir()) {
this.homeDir = homeDir;
}
/**
* Get PowerShell profile path
* Prefers $PROFILE environment variable, falls back to platform defaults
*
* @returns Profile path
*/
getProfilePath(): string {
// Check $PROFILE environment variable (set when running in PowerShell)
if (process.env.PROFILE) {
return process.env.PROFILE;
}
// Fall back to platform-specific defaults
if (process.platform === 'win32') {
// Windows: Documents/PowerShell/Microsoft.PowerShell_profile.ps1
return path.join(this.homeDir, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1');
} else {
// macOS/Linux: .config/powershell/Microsoft.PowerShell_profile.ps1
return path.join(this.homeDir, '.config', 'powershell', 'Microsoft.PowerShell_profile.ps1');
}
}
/**
* Get all PowerShell profile paths to configure.
* On Windows, returns both PowerShell Core and Windows PowerShell 5.1 paths.
* On Unix, returns PowerShell Core path only.
*/
private getAllProfilePaths(): string[] {
// If PROFILE env var is set, use only that path
if (process.env.PROFILE) {
return [process.env.PROFILE];
}
if (process.platform === 'win32') {
return [
// PowerShell Core 6+ (cross-platform)
path.join(this.homeDir, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1'),
// Windows PowerShell 5.1 (Windows-only)
path.join(this.homeDir, 'Documents', 'WindowsPowerShell', 'Microsoft.PowerShell_profile.ps1'),
];
} else {
// Unix systems: PowerShell Core only
return [path.join(this.homeDir, '.config', 'powershell', 'Microsoft.PowerShell_profile.ps1')];
}
}
/**
* Get the installation path for the completion script
*
* @returns Installation path
*/
getInstallationPath(): string {
const profilePath = this.getProfilePath();
const profileDir = path.dirname(profilePath);
return path.join(profileDir, 'OpenSpecCompletion.ps1');
}
/**
* Backup an existing completion file if it exists
*
* @param targetPath - Path to the file to backup
* @returns Path to the backup file, or undefined if no backup was needed
*/
async backupExistingFile(targetPath: string): Promise<string | undefined> {
try {
await fs.access(targetPath);
// File exists, create a backup
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `${targetPath}.backup-${timestamp}`;
await fs.copyFile(targetPath, backupPath);
return backupPath;
} catch {
// File doesn't exist, no backup needed
return undefined;
}
}
/**
* Generate PowerShell profile configuration content
*
* @param scriptPath - Path to the completion script
* @returns Configuration content
*/
private generateProfileConfig(scriptPath: string): string {
return [
'# OpenSpec shell completions configuration',
`if (Test-Path "${scriptPath}") {`,
` . "${scriptPath}"`,
'}',
].join('\n');
}
/**
* Configure PowerShell profile to source the completion script
*
* @param scriptPath - Path to the completion script
* @returns true if configured successfully, false otherwise
*/
async configureProfile(scriptPath: string): Promise<boolean> {
const profilePaths = this.getAllProfilePaths();
let anyConfigured = false;
for (const profilePath of profilePaths) {
try {
// Create profile file if it doesn't exist
const profileDir = path.dirname(profilePath);
await fs.mkdir(profileDir, { recursive: true });
let profileContent = '';
try {
profileContent = await fs.readFile(profilePath, 'utf-8');
} catch {
// Profile doesn't exist yet, that's fine
}
// Check if already configured
const scriptLine = `. "${scriptPath}"`;
if (profileContent.includes(scriptLine)) {
continue; // Already configured, skip
}
// Add OpenSpec completion configuration with markers
const openspecBlock = [
'',
'# OPENSPEC:START - OpenSpec completion (managed block, do not edit manually)',
scriptLine,
'# OPENSPEC:END',
'',
].join('\n');
const newContent = profileContent + openspecBlock;
await fs.writeFile(profilePath, newContent, 'utf-8');
anyConfigured = true;
} catch (error) {
// Continue to next profile if this one fails
console.warn(`Warning: Could not configure ${profilePath}: ${error}`);
}
}
return anyConfigured;
}
/**
* Remove PowerShell profile configuration
* Used during uninstallation
*
* @returns true if removed successfully, false otherwise
*/
async removeProfileConfig(): Promise<boolean> {
const profilePaths = this.getAllProfilePaths();
let anyRemoved = false;
for (const profilePath of profilePaths) {
try {
// Read profile content
let profileContent: string;
try {
profileContent = await fs.readFile(profilePath, 'utf-8');
} catch {
continue; // Profile doesn't exist, nothing to remove
}
// Remove OPENSPEC:START -> OPENSPEC:END block
const startMarker = '# OPENSPEC:START';
const endMarker = '# OPENSPEC:END';
const startIndex = profileContent.indexOf(startMarker);
if (startIndex === -1) {
continue; // No OpenSpec block found
}
const endIndex = profileContent.indexOf(endMarker, startIndex);
if (endIndex === -1) {
console.warn(`Warning: Found start marker but no end marker in ${profilePath}`);
continue;
}
// Remove the block (including markers and surrounding newlines)
const beforeBlock = profileContent.substring(0, startIndex);
const afterBlock = profileContent.substring(endIndex + endMarker.length);
// Clean up extra newlines
const newContent = (beforeBlock.trimEnd() + '\n' + afterBlock.trimStart()).trim() + '\n';
await fs.writeFile(profilePath, newContent, 'utf-8');
anyRemoved = true;
} catch (error) {
console.warn(`Warning: Could not clean ${profilePath}: ${error}`);
}
}
return anyRemoved;
}
/**
* Install the completion script
*
* @param completionScript - The completion script content to install
* @returns Installation result with status and instructions
*/
async install(completionScript: string): Promise<InstallationResult> {
try {
const targetPath = this.getInstallationPath();
// Check if already installed with same content
let isUpdate = false;
try {
const existingContent = await fs.readFile(targetPath, 'utf-8');
if (existingContent === completionScript) {
// Already installed and up to date
return {
success: true,
installedPath: targetPath,
message: 'Completion script is already installed (up to date)',
instructions: [
'The completion script is already installed and up to date.',
'If completions are not working, try restarting PowerShell or run: . $PROFILE',
],
};
}
// File exists but content is different - this is an update
isUpdate = true;
} catch (error: any) {
// File doesn't exist or can't be read, proceed with installation
console.debug(`Unable to read existing completion file at ${targetPath}: ${error.message}`);
}
// Ensure the directory exists
const targetDir = path.dirname(targetPath);
await fs.mkdir(targetDir, { recursive: true });
// Backup existing file if updating
const backupPath = isUpdate ? await this.backupExistingFile(targetPath) : undefined;
// Write the completion script
await fs.writeFile(targetPath, completionScript, 'utf-8');
// Auto-configure PowerShell profile
const profileConfigured = await this.configureProfile(targetPath);
// Generate instructions if profile wasn't auto-configured
const instructions = profileConfigured ? undefined : this.generateInstructions(targetPath);
// Determine appropriate message
let message: string;
if (isUpdate) {
message = backupPath
? 'Completion script updated successfully (previous version backed up)'
: 'Completion script updated successfully';
} else {
message = profileConfigured
? 'Completion script installed and PowerShell profile configured successfully'
: 'Completion script installed successfully for PowerShell';
}
return {
success: true,
installedPath: targetPath,
backupPath,
profileConfigured,
message,
instructions,
};
} catch (error) {
return {
success: false,
message: `Failed to install completion script: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
/**
* Generate user instructions for enabling completions
*
* @param installedPath - Path where the script was installed
* @returns Array of instruction strings
*/
private generateInstructions(installedPath: string): string[] {
const profilePath = this.getProfilePath();
return [
'Completion script installed successfully.',
'',
`To enable completions, add the following to your PowerShell profile (${profilePath}):`,
'',
' # Source OpenSpec completions',
` if (Test-Path "${installedPath}") {`,
` . "${installedPath}"`,
' }',
'',
'Then restart PowerShell or run: . $PROFILE',
];
}
/**
* Uninstall the completion script
*
* @param options - Optional uninstall options
* @param options.yes - Skip confirmation prompt (handled by command layer)
* @returns Uninstallation result
*/
async uninstall(options?: { yes?: boolean }): Promise<{ success: boolean; message: string }> {
try {
const targetPath = this.getInstallationPath();
// Check if installed
try {
await fs.access(targetPath);
} catch {
return {
success: false,
message: 'Completion script is not installed',
};
}
// Remove the completion script
await fs.unlink(targetPath);
// Remove profile configuration
await this.removeProfileConfig();
return {
success: true,
message: 'Completion script uninstalled successfully',
};
} catch (error) {
return {
success: false,
message: `Failed to uninstall completion script: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
}
@@ -2,7 +2,19 @@ import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { FileSystemUtils } from '../../../utils/file-system.js';
import { InstallationResult } from '../factory.js';
/**
* Installation result information
*/
export interface InstallationResult {
success: boolean;
installedPath?: string;
backupPath?: string;
isOhMyZsh: boolean;
zshrcConfigured?: boolean;
message: string;
instructions?: string[];
}
/**
* Installer for Zsh completion scripts.
@@ -1,24 +0,0 @@
/**
* Static template strings for Bash completion scripts.
* These are Bash-specific helper functions that never change.
*/
export const BASH_DYNAMIC_HELPERS = `# Dynamic completion helpers
_openspec_complete_changes() {
local changes
changes=$(openspec __complete changes 2>/dev/null | cut -f1)
COMPREPLY=($(compgen -W "$changes" -- "$cur"))
}
_openspec_complete_specs() {
local specs
specs=$(openspec __complete specs 2>/dev/null | cut -f1)
COMPREPLY=($(compgen -W "$specs" -- "$cur"))
}
_openspec_complete_items() {
local items
items=$(openspec __complete changes 2>/dev/null | cut -f1; openspec __complete specs 2>/dev/null | cut -f1)
COMPREPLY=($(compgen -W "$items" -- "$cur"))
}`;
@@ -1,40 +0,0 @@
/**
* Static template strings for Fish completion scripts.
* These are Fish-specific helper functions that never change.
*/
export const FISH_STATIC_HELPERS = `# Helper function to check if a subcommand is present
function __fish_openspec_using_subcommand
set -l cmd (commandline -opc)
set -e cmd[1]
for i in $argv
if contains -- $i $cmd
return 0
end
end
return 1
end
function __fish_openspec_no_subcommand
set -l cmd (commandline -opc)
test (count $cmd) -eq 1
end`;
export const FISH_DYNAMIC_HELPERS = `# Dynamic completion helpers
function __fish_openspec_changes
openspec __complete changes 2>/dev/null | while read -l id desc
printf '%s\\t%s\\n' "$id" "$desc"
end
end
function __fish_openspec_specs
openspec __complete specs 2>/dev/null | while read -l id desc
printf '%s\\t%s\\n' "$id" "$desc"
end
end
function __fish_openspec_items
__fish_openspec_changes
__fish_openspec_specs
end`;
@@ -1,25 +0,0 @@
/**
* Static template strings for PowerShell completion scripts.
* These are PowerShell-specific helper functions that never change.
*/
export const POWERSHELL_DYNAMIC_HELPERS = `# Dynamic completion helpers
function Get-OpenSpecChanges {
$output = openspec __complete changes 2>$null
if ($output) {
$output | ForEach-Object {
($_ -split "\\t")[0]
}
}
}
function Get-OpenSpecSpecs {
$output = openspec __complete specs 2>$null
if ($output) {
$output | ForEach-Object {
($_ -split "\\t")[0]
}
}
}
`;
@@ -1,36 +0,0 @@
/**
* Static template strings for Zsh completion scripts.
* These are Zsh-specific helper functions that never change.
*/
export const ZSH_DYNAMIC_HELPERS = `# Dynamic completion helpers
# Use openspec __complete to get available changes
_openspec_complete_changes() {
local -a changes
while IFS=$'\\t' read -r id desc; do
changes+=("$id:$desc")
done < <(openspec __complete changes 2>/dev/null)
_describe "change" changes
}
# Use openspec __complete to get available specs
_openspec_complete_specs() {
local -a specs
while IFS=$'\\t' read -r id desc; do
specs+=("$id:$desc")
done < <(openspec __complete specs 2>/dev/null)
_describe "spec" specs
}
# Get both changes and specs
_openspec_complete_items() {
local -a items
while IFS=$'\\t' read -r id desc; do
items+=("$id:$desc")
done < <(openspec __complete changes 2>/dev/null)
while IFS=$'\\t' read -r id desc; do
items+=("$id:$desc")
done < <(openspec __complete specs 2>/dev/null)
_describe "item" items
}`;
+9 -6
View File
@@ -10,18 +10,21 @@ const FILE_PATHS: Record<SlashCommandId, string> = {
const FRONTMATTER: Record<SlashCommandId, string> = {
proposal: `---
name: OpenSpec: Proposal
description: "Scaffold a new OpenSpec change and validate strictly."
argument-hint: "[feature description or request]"
description: Scaffold a new OpenSpec change and validate strictly.
category: OpenSpec
tags: [openspec, change]
---`,
apply: `---
name: OpenSpec: Apply
description: "Implement an approved OpenSpec change and keep tasks in sync."
argument-hint: "[change-id]"
description: Implement an approved OpenSpec change and keep tasks in sync.
category: OpenSpec
tags: [openspec, apply]
---`,
archive: `---
name: OpenSpec: Archive
description: "Archive a deployed OpenSpec change and update specs."
argument-hint: "[change-id]"
description: Archive a deployed OpenSpec change and update specs.
category: OpenSpec
tags: [openspec, archive]
---`
};
+8 -98
View File
@@ -9,78 +9,13 @@ interface ChangeInfo {
name: string;
completedTasks: number;
totalTasks: number;
lastModified: Date;
}
interface ListOptions {
sort?: 'recent' | 'name';
json?: boolean;
}
/**
* Get the most recent modification time of any file in a directory (recursive).
* Falls back to the directory's own mtime if no files are found.
*/
async function getLastModified(dirPath: string): Promise<Date> {
let latest: Date | null = null;
async function walk(dir: string): Promise<void> {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
} else {
const stat = await fs.stat(fullPath);
if (latest === null || stat.mtime > latest) {
latest = stat.mtime;
}
}
}
}
await walk(dirPath);
// If no files found, use the directory's own modification time
if (latest === null) {
const dirStat = await fs.stat(dirPath);
return dirStat.mtime;
}
return latest;
}
/**
* Format a date as relative time (e.g., "2 hours ago", "3 days ago")
*/
function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 30) {
return date.toLocaleDateString();
} else if (diffDays > 0) {
return `${diffDays}d ago`;
} else if (diffHours > 0) {
return `${diffHours}h ago`;
} else if (diffMins > 0) {
return `${diffMins}m ago`;
} else {
return 'just now';
}
}
export class ListCommand {
async execute(targetPath: string = '.', mode: 'changes' | 'specs' = 'changes', options: ListOptions = {}): Promise<void> {
const { sort = 'recent', json = false } = options;
async execute(targetPath: string = '.', mode: 'changes' | 'specs' = 'changes'): Promise<void> {
if (mode === 'changes') {
const changesDir = path.join(targetPath, 'openspec', 'changes');
// Check if changes directory exists
try {
await fs.access(changesDir);
@@ -95,48 +30,24 @@ export class ListCommand {
.map(entry => entry.name);
if (changeDirs.length === 0) {
if (json) {
console.log(JSON.stringify({ changes: [] }));
} else {
console.log('No active changes found.');
}
console.log('No active changes found.');
return;
}
// Collect information about each change
const changes: ChangeInfo[] = [];
for (const changeDir of changeDirs) {
const progress = await getTaskProgressForChange(changesDir, changeDir);
const changePath = path.join(changesDir, changeDir);
const lastModified = await getLastModified(changePath);
changes.push({
name: changeDir,
completedTasks: progress.completed,
totalTasks: progress.total,
lastModified
totalTasks: progress.total
});
}
// Sort by preference (default: recent first)
if (sort === 'recent') {
changes.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
} else {
changes.sort((a, b) => a.name.localeCompare(b.name));
}
// JSON output for programmatic use
if (json) {
const jsonOutput = changes.map(c => ({
name: c.name,
completedTasks: c.completedTasks,
totalTasks: c.totalTasks,
lastModified: c.lastModified.toISOString(),
status: c.totalTasks === 0 ? 'no-tasks' : c.completedTasks === c.totalTasks ? 'complete' : 'in-progress'
}));
console.log(JSON.stringify({ changes: jsonOutput }, null, 2));
return;
}
// Sort alphabetically by name
changes.sort((a, b) => a.name.localeCompare(b.name));
// Display results
console.log('Changes:');
@@ -145,8 +56,7 @@ export class ListCommand {
for (const change of changes) {
const paddedName = change.name.padEnd(nameWidth);
const status = formatTaskStatus({ total: change.totalTasks, completed: change.completedTasks });
const timeAgo = formatRelativeTime(change.lastModified);
console.log(`${padding}${paddedName} ${status.padEnd(12)} ${timeAgo}`);
console.log(`${padding}${paddedName} ${status}`);
}
return;
}
-483
View File
@@ -1,483 +0,0 @@
/**
* Spec Application Logic
*
* Extracted from ArchiveCommand to enable standalone spec application.
* Applies delta specs from a change to main specs without archiving.
*/
import { promises as fs } from 'fs';
import path from 'path';
import chalk from 'chalk';
import {
extractRequirementsSection,
parseDeltaSpec,
normalizeRequirementName,
type RequirementBlock,
} from './parsers/requirement-blocks.js';
import { Validator } from './validation/validator.js';
// -----------------------------------------------------------------------------
// Types
// -----------------------------------------------------------------------------
export interface SpecUpdate {
source: string;
target: string;
exists: boolean;
}
export interface ApplyResult {
capability: string;
added: number;
modified: number;
removed: number;
renamed: number;
}
export interface SpecsApplyOutput {
changeName: string;
capabilities: ApplyResult[];
totals: {
added: number;
modified: number;
removed: number;
renamed: number;
};
noChanges: boolean;
}
// -----------------------------------------------------------------------------
// Public API
// -----------------------------------------------------------------------------
/**
* Find all delta spec files that need to be applied from a change.
*/
export async function findSpecUpdates(changeDir: string, mainSpecsDir: string): Promise<SpecUpdate[]> {
const updates: SpecUpdate[] = [];
const changeSpecsDir = path.join(changeDir, 'specs');
try {
const entries = await fs.readdir(changeSpecsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const specFile = path.join(changeSpecsDir, entry.name, 'spec.md');
const targetFile = path.join(mainSpecsDir, entry.name, 'spec.md');
try {
await fs.access(specFile);
// Check if target exists
let exists = false;
try {
await fs.access(targetFile);
exists = true;
} catch {
exists = false;
}
updates.push({
source: specFile,
target: targetFile,
exists,
});
} catch {
// Source spec doesn't exist, skip
}
}
}
} catch {
// No specs directory in change
}
return updates;
}
/**
* Build an updated spec by applying delta operations.
* Returns the rebuilt content and counts of operations.
*/
export async function buildUpdatedSpec(
update: SpecUpdate,
changeName: string
): Promise<{ rebuilt: string; counts: { added: number; modified: number; removed: number; renamed: number } }> {
// Read change spec content (delta-format expected)
const changeContent = await fs.readFile(update.source, 'utf-8');
// Parse deltas from the change spec file
const plan = parseDeltaSpec(changeContent);
const specName = path.basename(path.dirname(update.target));
// Pre-validate duplicates within sections
const addedNames = new Set<string>();
for (const add of plan.added) {
const name = normalizeRequirementName(add.name);
if (addedNames.has(name)) {
throw new Error(
`${specName} validation failed - duplicate requirement in ADDED for header "### Requirement: ${add.name}"`
);
}
addedNames.add(name);
}
const modifiedNames = new Set<string>();
for (const mod of plan.modified) {
const name = normalizeRequirementName(mod.name);
if (modifiedNames.has(name)) {
throw new Error(
`${specName} validation failed - duplicate requirement in MODIFIED for header "### Requirement: ${mod.name}"`
);
}
modifiedNames.add(name);
}
const removedNamesSet = new Set<string>();
for (const rem of plan.removed) {
const name = normalizeRequirementName(rem);
if (removedNamesSet.has(name)) {
throw new Error(
`${specName} validation failed - duplicate requirement in REMOVED for header "### Requirement: ${rem}"`
);
}
removedNamesSet.add(name);
}
const renamedFromSet = new Set<string>();
const renamedToSet = new Set<string>();
for (const { from, to } of plan.renamed) {
const fromNorm = normalizeRequirementName(from);
const toNorm = normalizeRequirementName(to);
if (renamedFromSet.has(fromNorm)) {
throw new Error(
`${specName} validation failed - duplicate FROM in RENAMED for header "### Requirement: ${from}"`
);
}
if (renamedToSet.has(toNorm)) {
throw new Error(
`${specName} validation failed - duplicate TO in RENAMED for header "### Requirement: ${to}"`
);
}
renamedFromSet.add(fromNorm);
renamedToSet.add(toNorm);
}
// Pre-validate cross-section conflicts
const conflicts: Array<{ name: string; a: string; b: string }> = [];
for (const n of modifiedNames) {
if (removedNamesSet.has(n)) conflicts.push({ name: n, a: 'MODIFIED', b: 'REMOVED' });
if (addedNames.has(n)) conflicts.push({ name: n, a: 'MODIFIED', b: 'ADDED' });
}
for (const n of addedNames) {
if (removedNamesSet.has(n)) conflicts.push({ name: n, a: 'ADDED', b: 'REMOVED' });
}
// Renamed interplay: MODIFIED must reference the NEW header, not FROM
for (const { from, to } of plan.renamed) {
const fromNorm = normalizeRequirementName(from);
const toNorm = normalizeRequirementName(to);
if (modifiedNames.has(fromNorm)) {
throw new Error(
`${specName} validation failed - when a rename exists, MODIFIED must reference the NEW header "### Requirement: ${to}"`
);
}
// Detect ADDED colliding with a RENAMED TO
if (addedNames.has(toNorm)) {
throw new Error(
`${specName} validation failed - RENAMED TO header collides with ADDED for "### Requirement: ${to}"`
);
}
}
if (conflicts.length > 0) {
const c = conflicts[0];
throw new Error(
`${specName} validation failed - requirement present in multiple sections (${c.a} and ${c.b}) for header "### Requirement: ${c.name}"`
);
}
const hasAnyDelta = plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length > 0;
if (!hasAnyDelta) {
throw new Error(
`Delta parsing found no operations for ${path.basename(path.dirname(update.source))}. ` +
`Provide ADDED/MODIFIED/REMOVED/RENAMED sections in change spec.`
);
}
// Load or create base target content
let targetContent: string;
let isNewSpec = false;
try {
targetContent = await fs.readFile(update.target, 'utf-8');
} catch {
// Target spec does not exist; MODIFIED and RENAMED are not allowed for new specs
// REMOVED will be ignored with a warning since there's nothing to remove
if (plan.modified.length > 0 || plan.renamed.length > 0) {
throw new Error(
`${specName}: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.`
);
}
// Warn about REMOVED requirements being ignored for new specs
if (plan.removed.length > 0) {
console.log(
chalk.yellow(
`⚠️ Warning: ${specName} - ${plan.removed.length} REMOVED requirement(s) ignored for new spec (nothing to remove).`
)
);
}
isNewSpec = true;
targetContent = buildSpecSkeleton(specName, changeName);
}
// Extract requirements section and build name->block map
const parts = extractRequirementsSection(targetContent);
const nameToBlock = new Map<string, RequirementBlock>();
for (const block of parts.bodyBlocks) {
nameToBlock.set(normalizeRequirementName(block.name), block);
}
// Apply operations in order: RENAMED → REMOVED → MODIFIED → ADDED
// RENAMED
for (const r of plan.renamed) {
const from = normalizeRequirementName(r.from);
const to = normalizeRequirementName(r.to);
if (!nameToBlock.has(from)) {
throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.from}" - source not found`);
}
if (nameToBlock.has(to)) {
throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.to}" - target already exists`);
}
const block = nameToBlock.get(from)!;
const newHeader = `### Requirement: ${to}`;
const rawLines = block.raw.split('\n');
rawLines[0] = newHeader;
const renamedBlock: RequirementBlock = {
headerLine: newHeader,
name: to,
raw: rawLines.join('\n'),
};
nameToBlock.delete(from);
nameToBlock.set(to, renamedBlock);
}
// REMOVED
for (const name of plan.removed) {
const key = normalizeRequirementName(name);
if (!nameToBlock.has(key)) {
// For new specs, REMOVED requirements are already warned about and ignored
// For existing specs, missing requirements are an error
if (!isNewSpec) {
throw new Error(`${specName} REMOVED failed for header "### Requirement: ${name}" - not found`);
}
// Skip removal for new specs (already warned above)
continue;
}
nameToBlock.delete(key);
}
// MODIFIED
for (const mod of plan.modified) {
const key = normalizeRequirementName(mod.name);
if (!nameToBlock.has(key)) {
throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - not found`);
}
// Replace block with provided raw (ensure header line matches key)
const modHeaderMatch = mod.raw.split('\n')[0].match(/^###\s*Requirement:\s*(.+)\s*$/);
if (!modHeaderMatch || normalizeRequirementName(modHeaderMatch[1]) !== key) {
throw new Error(
`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - header mismatch in content`
);
}
nameToBlock.set(key, mod);
}
// ADDED
for (const add of plan.added) {
const key = normalizeRequirementName(add.name);
if (nameToBlock.has(key)) {
throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - already exists`);
}
nameToBlock.set(key, add);
}
// Duplicates within resulting map are implicitly prevented by key uniqueness.
// Recompose requirements section preserving original ordering where possible
const keptOrder: RequirementBlock[] = [];
const seen = new Set<string>();
for (const block of parts.bodyBlocks) {
const key = normalizeRequirementName(block.name);
const replacement = nameToBlock.get(key);
if (replacement) {
keptOrder.push(replacement);
seen.add(key);
}
}
// Append any newly added that were not in original order
for (const [key, block] of nameToBlock.entries()) {
if (!seen.has(key)) {
keptOrder.push(block);
}
}
const reqBody = [parts.preamble && parts.preamble.trim() ? parts.preamble.trimEnd() : '']
.filter(Boolean)
.concat(keptOrder.map((b) => b.raw))
.join('\n\n')
.trimEnd();
const rebuilt = [parts.before.trimEnd(), parts.headerLine, reqBody, parts.after]
.filter((s, idx) => !(idx === 0 && s === ''))
.join('\n')
.replace(/\n{3,}/g, '\n\n');
return {
rebuilt,
counts: {
added: plan.added.length,
modified: plan.modified.length,
removed: plan.removed.length,
renamed: plan.renamed.length,
},
};
}
/**
* Write an updated spec to disk.
*/
export async function writeUpdatedSpec(
update: SpecUpdate,
rebuilt: string,
counts: { added: number; modified: number; removed: number; renamed: number }
): Promise<void> {
// Create target directory if needed
const targetDir = path.dirname(update.target);
await fs.mkdir(targetDir, { recursive: true });
await fs.writeFile(update.target, rebuilt);
const specName = path.basename(path.dirname(update.target));
console.log(`Applying changes to openspec/specs/${specName}/spec.md:`);
if (counts.added) console.log(` + ${counts.added} added`);
if (counts.modified) console.log(` ~ ${counts.modified} modified`);
if (counts.removed) console.log(` - ${counts.removed} removed`);
if (counts.renamed) console.log(` → ${counts.renamed} renamed`);
}
/**
* Build a skeleton spec for new capabilities.
*/
export function buildSpecSkeleton(specFolderName: string, changeName: string): string {
const titleBase = specFolderName;
return `# ${titleBase} Specification\n\n## Purpose\nTBD - created by archiving change ${changeName}. Update Purpose after archive.\n\n## Requirements\n`;
}
/**
* Apply all delta specs from a change to main specs.
*
* @param projectRoot - The project root directory
* @param changeName - The name of the change to apply
* @param options - Options for the operation
* @returns Result of the operation with counts
*/
export async function applySpecs(
projectRoot: string,
changeName: string,
options: {
dryRun?: boolean;
skipValidation?: boolean;
silent?: boolean;
} = {}
): Promise<SpecsApplyOutput> {
const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);
const mainSpecsDir = path.join(projectRoot, 'openspec', 'specs');
// Verify change exists
try {
const stat = await fs.stat(changeDir);
if (!stat.isDirectory()) {
throw new Error(`Change '${changeName}' not found.`);
}
} catch {
throw new Error(`Change '${changeName}' not found.`);
}
// Find specs to update
const specUpdates = await findSpecUpdates(changeDir, mainSpecsDir);
if (specUpdates.length === 0) {
return {
changeName,
capabilities: [],
totals: { added: 0, modified: 0, removed: 0, renamed: 0 },
noChanges: true,
};
}
// Prepare all updates first (validation pass, no writes)
const prepared: Array<{
update: SpecUpdate;
rebuilt: string;
counts: { added: number; modified: number; removed: number; renamed: number };
}> = [];
for (const update of specUpdates) {
const built = await buildUpdatedSpec(update, changeName);
prepared.push({ update, rebuilt: built.rebuilt, counts: built.counts });
}
// Validate rebuilt specs unless validation is skipped
if (!options.skipValidation) {
const validator = new Validator();
for (const p of prepared) {
const specName = path.basename(path.dirname(p.update.target));
const report = await validator.validateSpecContent(specName, p.rebuilt);
if (!report.valid) {
const errors = report.issues
.filter((i) => i.level === 'ERROR')
.map((i) => ` ✗ ${i.message}`)
.join('\n');
throw new Error(`Validation errors in rebuilt spec for ${specName}:\n${errors}`);
}
}
}
// Build results
const capabilities: ApplyResult[] = [];
const totals = { added: 0, modified: 0, removed: 0, renamed: 0 };
for (const p of prepared) {
const capability = path.basename(path.dirname(p.update.target));
if (!options.dryRun) {
// Write the updated spec
const targetDir = path.dirname(p.update.target);
await fs.mkdir(targetDir, { recursive: true });
await fs.writeFile(p.update.target, p.rebuilt);
if (!options.silent) {
console.log(`Applying changes to openspec/specs/${capability}/spec.md:`);
if (p.counts.added) console.log(` + ${p.counts.added} added`);
if (p.counts.modified) console.log(` ~ ${p.counts.modified} modified`);
if (p.counts.removed) console.log(` - ${p.counts.removed} removed`);
if (p.counts.renamed) console.log(` → ${p.counts.renamed} renamed`);
}
} else if (!options.silent) {
console.log(`Would apply changes to openspec/specs/${capability}/spec.md:`);
if (p.counts.added) console.log(` + ${p.counts.added} added`);
if (p.counts.modified) console.log(` ~ ${p.counts.modified} modified`);
if (p.counts.removed) console.log(` - ${p.counts.removed} removed`);
if (p.counts.renamed) console.log(` → ${p.counts.renamed} renamed`);
}
capabilities.push({
capability,
...p.counts,
});
totals.added += p.counts.added;
totals.modified += p.counts.modified;
totals.removed += p.counts.removed;
totals.renamed += p.counts.renamed;
}
return {
changeName,
capabilities,
totals,
noChanges: false,
};
}
File diff suppressed because it is too large Load Diff
-171
View File
@@ -1,171 +0,0 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as yaml from 'yaml';
import { ChangeMetadataSchema, type ChangeMetadata } from '../core/artifact-graph/types.js';
import { listSchemas } from '../core/artifact-graph/resolver.js';
const METADATA_FILENAME = '.openspec.yaml';
/**
* Error thrown when change metadata validation fails.
*/
export class ChangeMetadataError extends Error {
constructor(
message: string,
public readonly metadataPath: string,
public readonly cause?: Error
) {
super(message);
this.name = 'ChangeMetadataError';
}
}
/**
* Validates that a schema name is valid (exists in available schemas).
*
* @param schemaName - The schema name to validate
* @returns The validated schema name
* @throws Error if schema is not found
*/
export function validateSchemaName(schemaName: string): string {
const availableSchemas = listSchemas();
if (!availableSchemas.includes(schemaName)) {
throw new Error(
`Unknown schema '${schemaName}'. Available: ${availableSchemas.join(', ')}`
);
}
return schemaName;
}
/**
* Writes change metadata to .openspec.yaml in the change directory.
*
* @param changeDir - The path to the change directory
* @param metadata - The metadata to write
* @throws ChangeMetadataError if validation fails or write fails
*/
export function writeChangeMetadata(
changeDir: string,
metadata: ChangeMetadata
): void {
const metaPath = path.join(changeDir, METADATA_FILENAME);
// Validate schema exists
validateSchemaName(metadata.schema);
// Validate with Zod
const parseResult = ChangeMetadataSchema.safeParse(metadata);
if (!parseResult.success) {
throw new ChangeMetadataError(
`Invalid metadata: ${parseResult.error.message}`,
metaPath
);
}
// Write YAML file
const content = yaml.stringify(parseResult.data);
try {
fs.writeFileSync(metaPath, content, 'utf-8');
} catch (err) {
const ioError = err instanceof Error ? err : new Error(String(err));
throw new ChangeMetadataError(
`Failed to write metadata: ${ioError.message}`,
metaPath,
ioError
);
}
}
/**
* Reads change metadata from .openspec.yaml in the change directory.
*
* @param changeDir - The path to the change directory
* @returns The validated metadata, or null if no metadata file exists
* @throws ChangeMetadataError if the file exists but is invalid
*/
export function readChangeMetadata(changeDir: string): ChangeMetadata | null {
const metaPath = path.join(changeDir, METADATA_FILENAME);
if (!fs.existsSync(metaPath)) {
return null;
}
let content: string;
try {
content = fs.readFileSync(metaPath, 'utf-8');
} catch (err) {
const ioError = err instanceof Error ? err : new Error(String(err));
throw new ChangeMetadataError(
`Failed to read metadata: ${ioError.message}`,
metaPath,
ioError
);
}
let parsed: unknown;
try {
parsed = yaml.parse(content);
} catch (err) {
const parseError = err instanceof Error ? err : new Error(String(err));
throw new ChangeMetadataError(
`Invalid YAML in metadata file: ${parseError.message}`,
metaPath,
parseError
);
}
// Validate with Zod
const parseResult = ChangeMetadataSchema.safeParse(parsed);
if (!parseResult.success) {
throw new ChangeMetadataError(
`Invalid metadata: ${parseResult.error.message}`,
metaPath
);
}
// Validate that the schema exists
const availableSchemas = listSchemas();
if (!availableSchemas.includes(parseResult.data.schema)) {
throw new ChangeMetadataError(
`Unknown schema '${parseResult.data.schema}'. Available: ${availableSchemas.join(', ')}`,
metaPath
);
}
return parseResult.data;
}
/**
* Resolves the schema for a change, with explicit override taking precedence.
*
* Resolution order:
* 1. Explicit schema (if provided)
* 2. Schema from .openspec.yaml metadata (if exists)
* 3. Default 'spec-driven'
*
* @param changeDir - The path to the change directory
* @param explicitSchema - Optional explicit schema override
* @returns The resolved schema name
*/
export function resolveSchemaForChange(
changeDir: string,
explicitSchema?: string
): string {
// 1. Explicit override wins
if (explicitSchema) {
return explicitSchema;
}
// 2. Try reading from metadata
try {
const metadata = readChangeMetadata(changeDir);
if (metadata?.schema) {
return metadata.schema;
}
} catch {
// If metadata read fails, fall back to default
}
// 3. Default
return 'spec-driven';
}
+3 -32
View File
@@ -1,16 +1,5 @@
import path from 'path';
import { FileSystemUtils } from './file-system.js';
import { writeChangeMetadata, validateSchemaName } from './change-metadata.js';
const DEFAULT_SCHEMA = 'spec-driven';
/**
* Options for creating a change.
*/
export interface CreateChangeOptions {
/** The workflow schema to use (default: 'spec-driven') */
schema?: string;
}
/**
* Result of validating a change name.
@@ -79,27 +68,20 @@ export function validateChangeName(name: string): ValidationResult {
}
/**
* Creates a new change directory with metadata file.
* Creates a new change directory.
*
* @param projectRoot - The root directory of the project (where `openspec/` lives)
* @param name - The change name (must be valid kebab-case)
* @param options - Optional settings for the change
* @throws Error if the change name is invalid
* @throws Error if the schema name is invalid
* @throws Error if the change directory already exists
*
* @example
* // Creates openspec/changes/add-auth/ with default schema
* // Creates openspec/changes/add-auth/
* await createChange('/path/to/project', 'add-auth')
*
* @example
* // Creates openspec/changes/add-auth/ with TDD schema
* await createChange('/path/to/project', 'add-auth', { schema: 'tdd' })
*/
export async function createChange(
projectRoot: string,
name: string,
options: CreateChangeOptions = {}
name: string
): Promise<void> {
// Validate the name first
const validation = validateChangeName(name);
@@ -107,10 +89,6 @@ export async function createChange(
throw new Error(validation.error);
}
// Determine schema (validate if provided)
const schemaName = options.schema ?? DEFAULT_SCHEMA;
validateSchemaName(schemaName);
// Build the change directory path
const changeDir = path.join(projectRoot, 'openspec', 'changes', name);
@@ -121,11 +99,4 @@ export async function createChange(
// Create the directory (including parent directories if needed)
await FileSystemUtils.createDirectory(changeDir);
// Write metadata file with schema and creation date
const today = new Date().toISOString().split('T')[0];
writeChangeMetadata(changeDir, {
schema: schemaName,
created: today,
});
}
+2 -45
View File
@@ -93,41 +93,6 @@ export class FileSystemUtils {
}
}
/**
* Finds the first existing parent directory by walking up the directory tree.
* @param dirPath Starting directory path
* @returns The first existing directory path, or null if root is reached without finding one
*/
private static async findFirstExistingDirectory(dirPath: string): Promise<string | null> {
let currentDir = dirPath;
while (true) {
try {
const stats = await fs.stat(currentDir);
if (stats.isDirectory()) {
return currentDir;
}
// Path component exists but is not a directory (edge case)
console.debug(`Path component ${currentDir} exists but is not a directory`);
return null;
} catch (error: any) {
if (error.code === 'ENOENT') {
// Directory doesn't exist, move up one level
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
// Reached filesystem root without finding existing directory
return null;
}
currentDir = parentDir;
} else {
// Unexpected error (permissions, I/O error, etc.)
console.debug(`Error checking directory ${currentDir}: ${error.message}`);
return null;
}
}
}
}
static async canWriteFile(filePath: string): Promise<boolean> {
try {
const stats = await fs.stat(filePath);
@@ -146,18 +111,10 @@ export class FileSystemUtils {
}
} catch (error: any) {
if (error.code === 'ENOENT') {
// File doesn't exist - find first existing parent directory and check its permissions
// File doesn't exist; check if we can write to the parent directory
const parentDir = path.dirname(filePath);
const existingDir = await this.findFirstExistingDirectory(parentDir);
if (existingDir === null) {
// No existing parent directory found (edge case)
return false;
}
// Check if the existing parent directory is writable
try {
await fs.access(existingDir, fsConstants.W_OK);
await fs.access(parentDir, fsConstants.W_OK);
return true;
} catch {
return false;
+1 -10
View File
@@ -1,12 +1,3 @@
// Shared utilities
export { validateChangeName, createChange } from './change-utils.js';
export type { ValidationResult, CreateChangeOptions } from './change-utils.js';
// Change metadata utilities
export {
readChangeMetadata,
writeChangeMetadata,
resolveSchemaForChange,
validateSchemaName,
ChangeMetadataError,
} from './change-metadata.js';
export type { ValidationResult } from './change-utils.js';
+74 -209
View File
@@ -187,6 +187,68 @@ describe('artifact-workflow CLI commands', () => {
});
});
describe('next command', () => {
it('shows proposal as next for scaffolded change', async () => {
// Create empty change directory (no proposal.md)
const changeDir = path.join(changesDir, 'scaffolded-change');
await fs.mkdir(changeDir, { recursive: true });
const result = await runCLI(['next', '--change', 'scaffolded-change'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Artifacts ready to create');
expect(result.stdout).toContain('proposal');
});
it('shows design and specs as next when proposal exists', async () => {
// createTestChange always creates proposal.md, so design and specs are ready
await createTestChange('minimal-change');
const result = await runCLI(['next', '--change', 'minimal-change'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Artifacts ready to create');
expect(result.stdout).toContain('design');
expect(result.stdout).toContain('specs');
});
it('shows tasks as next after proposal, design, and specs', async () => {
await createTestChange('after-specs', ['proposal', 'design', 'specs']);
const result = await runCLI(['next', '--change', 'after-specs'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('tasks');
});
it('shows complete message when all done', async () => {
await createTestChange('complete-change', ['proposal', 'design', 'specs', 'tasks']);
const result = await runCLI(['next', '--change', 'complete-change'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('All artifacts are complete!');
});
it('outputs JSON array of ready artifacts', async () => {
await createTestChange('json-next', ['proposal']);
const result = await runCLI(['next', '--change', 'json-next', '--json'], { cwd: tempDir });
expect(result.exitCode).toBe(0);
const json = JSON.parse(result.stdout);
expect(Array.isArray(json)).toBe(true);
expect(json).toContain('design');
expect(json).toContain('specs');
});
it('errors when --change is missing and lists available changes', async () => {
await createTestChange('some-change');
const result = await runCLI(['next'], { cwd: tempDir });
expect(result.exitCode).toBe(1);
const output = getOutput(result);
expect(output).toContain('Missing required option --change');
expect(output).toContain('some-change');
});
});
describe('instructions command', () => {
it('shows instructions for proposal on scaffolded change', async () => {
// Create empty change directory (no proposal.md)
@@ -197,9 +259,9 @@ describe('artifact-workflow CLI commands', () => {
cwd: tempDir,
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('<artifact id="proposal"');
expect(result.stdout).toContain('Artifact: proposal');
expect(result.stdout).toContain('proposal.md');
expect(result.stdout).toContain('<template>');
expect(result.stdout).toContain('Template:');
});
it('shows instructions for design artifact', async () => {
@@ -209,9 +271,9 @@ describe('artifact-workflow CLI commands', () => {
cwd: tempDir,
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('<artifact id="design"');
expect(result.stdout).toContain('Artifact: design');
expect(result.stdout).toContain('design.md');
expect(result.stdout).toContain('<template>');
expect(result.stdout).toContain('Template:');
});
it('shows blocked warning for artifact with unmet dependencies', async () => {
@@ -222,8 +284,8 @@ describe('artifact-workflow CLI commands', () => {
cwd: tempDir,
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('<warning>');
expect(result.stdout).toContain('status="missing"');
expect(result.stdout).toContain('Warning: This artifact has unmet dependencies');
expect(result.stdout).toContain('[missing]');
});
it('outputs JSON for instructions', async () => {
@@ -348,209 +410,6 @@ describe('artifact-workflow CLI commands', () => {
});
});
describe('instructions apply command', () => {
it('shows apply instructions for spec-driven schema with tasks', async () => {
await createTestChange('apply-change', ['proposal', 'design', 'specs', 'tasks']);
const result = await runCLI(['instructions', 'apply', '--change', 'apply-change'], {
cwd: tempDir,
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('## Apply: apply-change');
expect(result.stdout).toContain('Schema: spec-driven');
expect(result.stdout).toContain('### Context Files');
expect(result.stdout).toContain('### Instruction');
});
it('shows blocked state when required artifacts are missing', async () => {
// Only create proposal - missing tasks (required by spec-driven apply block)
await createTestChange('blocked-apply', ['proposal']);
const result = await runCLI(['instructions', 'apply', '--change', 'blocked-apply'], {
cwd: tempDir,
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Blocked');
expect(result.stdout).toContain('Missing artifacts: tasks');
});
it('outputs JSON for apply instructions', async () => {
await createTestChange('json-apply', ['proposal', 'design', 'specs', 'tasks']);
const result = await runCLI(
['instructions', 'apply', '--change', 'json-apply', '--json'],
{ cwd: tempDir }
);
expect(result.exitCode).toBe(0);
const json = JSON.parse(result.stdout);
expect(json.changeName).toBe('json-apply');
expect(json.schemaName).toBe('spec-driven');
expect(json.state).toBe('ready');
expect(json.contextFiles).toBeDefined();
expect(typeof json.contextFiles).toBe('object');
});
it('shows schema instruction from apply block', async () => {
await createTestChange('instr-apply', ['proposal', 'design', 'specs', 'tasks']);
const result = await runCLI(['instructions', 'apply', '--change', 'instr-apply'], {
cwd: tempDir,
});
expect(result.exitCode).toBe(0);
// Should show the instruction from spec-driven schema apply block
expect(result.stdout).toContain('work through pending tasks');
});
it('shows all_done state when all tasks are complete', async () => {
const changeDir = await createTestChange('done-apply', [
'proposal',
'design',
'specs',
'tasks',
]);
// Overwrite tasks with all completed
await fs.writeFile(
path.join(changeDir, 'tasks.md'),
'## Tasks\n- [x] Task 1\n- [x] Task 2'
);
const result = await runCLI(['instructions', 'apply', '--change', 'done-apply'], {
cwd: tempDir,
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('complete ✓');
expect(result.stdout).toContain('ready to be archived');
});
it('uses tdd schema apply configuration', async () => {
// Create a TDD-style change with spec and tests
const changeDir = path.join(changesDir, 'tdd-apply');
await fs.mkdir(changeDir, { recursive: true });
await fs.writeFile(path.join(changeDir, 'spec.md'), '## Feature\nTest spec.');
const testsDir = path.join(changeDir, 'tests');
await fs.mkdir(testsDir, { recursive: true });
await fs.writeFile(path.join(testsDir, 'test.test.ts'), 'test("works", () => {})');
const result = await runCLI(
['instructions', 'apply', '--change', 'tdd-apply', '--schema', 'tdd'],
{ cwd: tempDir }
);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Schema: tdd');
// TDD schema has no task tracking, so should show schema instruction
expect(result.stdout).toContain('Run tests to see failures');
});
it('spec-driven schema uses apply block configuration', async () => {
// Verify that spec-driven schema uses its apply block (requires: [tasks])
await createTestChange('apply-config-test', ['proposal', 'design', 'specs', 'tasks']);
const result = await runCLI(
['instructions', 'apply', '--change', 'apply-config-test', '--json'],
{ cwd: tempDir }
);
expect(result.exitCode).toBe(0);
const json = JSON.parse(result.stdout);
// spec-driven schema has apply block with requires: [tasks], so should be ready
expect(json.schemaName).toBe('spec-driven');
expect(json.state).toBe('ready');
});
it('fallback: requires all artifacts when schema has no apply block', async () => {
// Create a minimal schema without an apply block in user schemas dir
const userDataDir = path.join(tempDir, 'user-data');
const noApplySchemaDir = path.join(userDataDir, 'openspec', 'schemas', 'no-apply');
const templatesDir = path.join(noApplySchemaDir, 'templates');
await fs.mkdir(templatesDir, { recursive: true });
// Minimal schema with 2 artifacts, no apply block
const schemaContent = `
name: no-apply
version: 1
description: Test schema without apply block
artifacts:
- id: first
generates: first.md
description: First artifact
template: first.md
requires: []
- id: second
generates: second.md
description: Second artifact
template: second.md
requires: [first]
`;
await fs.writeFile(path.join(noApplySchemaDir, 'schema.yaml'), schemaContent);
await fs.writeFile(path.join(templatesDir, 'first.md'), '# First\n');
await fs.writeFile(path.join(templatesDir, 'second.md'), '# Second\n');
// Create a change with only the first artifact (missing second)
const changeDir = path.join(changesDir, 'no-apply-test');
await fs.mkdir(changeDir, { recursive: true });
await fs.writeFile(path.join(changeDir, 'first.md'), '# First artifact content');
// Run with XDG_DATA_HOME pointing to our temp user data dir
const result = await runCLI(
['instructions', 'apply', '--change', 'no-apply-test', '--schema', 'no-apply', '--json'],
{
cwd: tempDir,
env: { XDG_DATA_HOME: userDataDir },
}
);
expect(result.exitCode).toBe(0);
const json = JSON.parse(result.stdout);
// Without apply block, fallback requires ALL artifacts - second is missing
expect(json.schemaName).toBe('no-apply');
expect(json.state).toBe('blocked');
expect(json.missingArtifacts).toContain('second');
});
it('fallback: ready when all artifacts exist for schema without apply block', async () => {
// Create a minimal schema without an apply block
const userDataDir = path.join(tempDir, 'user-data-2');
const noApplySchemaDir = path.join(userDataDir, 'openspec', 'schemas', 'no-apply-full');
const templatesDir = path.join(noApplySchemaDir, 'templates');
await fs.mkdir(templatesDir, { recursive: true });
const schemaContent = `
name: no-apply-full
version: 1
description: Test schema without apply block
artifacts:
- id: only
generates: only.md
description: Only artifact
template: only.md
requires: []
`;
await fs.writeFile(path.join(noApplySchemaDir, 'schema.yaml'), schemaContent);
await fs.writeFile(path.join(templatesDir, 'only.md'), '# Only\n');
// Create a change with the artifact present
const changeDir = path.join(changesDir, 'no-apply-full-test');
await fs.mkdir(changeDir, { recursive: true });
await fs.writeFile(path.join(changeDir, 'only.md'), '# Content');
const result = await runCLI(
['instructions', 'apply', '--change', 'no-apply-full-test', '--schema', 'no-apply-full', '--json'],
{
cwd: tempDir,
env: { XDG_DATA_HOME: userDataDir },
}
);
expect(result.exitCode).toBe(0);
const json = JSON.parse(result.stdout);
// All artifacts exist, should be ready with default instruction
expect(json.schemaName).toBe('no-apply-full');
expect(json.state).toBe('ready');
expect(json.instruction).toContain('All required artifacts complete');
});
});
describe('help text', () => {
it('marks status command as experimental in help', async () => {
const result = await runCLI(['status', '--help']);
@@ -558,6 +417,12 @@ artifacts:
expect(result.stdout).toContain('[Experimental]');
});
it('marks next command as experimental in help', async () => {
const result = await runCLI(['next', '--help']);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('[Experimental]');
});
it('marks instructions command as experimental in help', async () => {
const result = await runCLI(['instructions', '--help']);
expect(result.exitCode).toBe(0);
+8 -8
View File
@@ -78,10 +78,10 @@ describe('CompletionCommand', () => {
});
it('should show error for unsupported shell', async () => {
await command.generate({ shell: 'tcsh' });
await command.generate({ shell: 'bash' });
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error: Shell 'tcsh' is not supported yet. Currently supported: zsh, bash, fish, powershell"
"Error: Shell 'bash' is not supported yet. Currently supported: zsh"
);
expect(process.exitCode).toBe(1);
});
@@ -135,10 +135,10 @@ describe('CompletionCommand', () => {
});
it('should show error for unsupported shell', async () => {
await command.install({ shell: 'tcsh' });
await command.install({ shell: 'fish' });
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error: Shell 'tcsh' is not supported yet. Currently supported: zsh, bash, fish, powershell"
"Error: Shell 'fish' is not supported yet. Currently supported: zsh"
);
expect(process.exitCode).toBe(1);
});
@@ -184,10 +184,10 @@ describe('CompletionCommand', () => {
});
it('should show error for unsupported shell', async () => {
await command.uninstall({ shell: 'tcsh', yes: true });
await command.uninstall({ shell: 'powershell', yes: true });
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error: Shell 'tcsh' is not supported yet. Currently supported: zsh, bash, fish, powershell"
"Error: Shell 'powershell' is not supported yet. Currently supported: zsh"
);
expect(process.exitCode).toBe(1);
});
@@ -246,12 +246,12 @@ describe('CompletionCommand', () => {
describe('shell detection integration', () => {
it('should show appropriate error when detected shell is unsupported', async () => {
vi.mocked(shellDetection.detectShell).mockReturnValue({ shell: undefined, detected: 'tcsh' });
vi.mocked(shellDetection.detectShell).mockReturnValue({ shell: undefined, detected: 'bash' });
await command.generate({});
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error: Shell 'tcsh' is not supported yet. Currently supported: zsh, bash, fish, powershell"
"Error: Shell 'bash' is not supported yet. Currently supported: zsh"
);
expect(process.exitCode).toBe(1);
});
@@ -86,42 +86,6 @@ describe('instruction-loader', () => {
expect(context.completed.size).toBe(0);
});
it('should auto-detect schema from .openspec.yaml metadata', () => {
// Create change directory with metadata file
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
fs.mkdirSync(changeDir, { recursive: true });
fs.writeFileSync(path.join(changeDir, '.openspec.yaml'), 'schema: tdd\ncreated: "2025-01-05"\n');
// Load without explicit schema - should detect from metadata
const context = loadChangeContext(tempDir, 'my-change');
expect(context.schemaName).toBe('tdd');
expect(context.graph.getName()).toBe('tdd');
});
it('should use explicit schema over metadata schema', () => {
// Create change directory with metadata file using tdd
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
fs.mkdirSync(changeDir, { recursive: true });
fs.writeFileSync(path.join(changeDir, '.openspec.yaml'), 'schema: tdd\n');
// Load with explicit schema - should override metadata
const context = loadChangeContext(tempDir, 'my-change', 'spec-driven');
expect(context.schemaName).toBe('spec-driven');
expect(context.graph.getName()).toBe('spec-driven');
});
it('should fall back to default when no metadata and no explicit schema', () => {
// Create change directory without metadata file
const changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change');
fs.mkdirSync(changeDir, { recursive: true });
const context = loadChangeContext(tempDir, 'my-change');
expect(context.schemaName).toBe('spec-driven');
});
});
describe('generateInstructions', () => {
@@ -1,525 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { BashGenerator } from '../../../../src/core/completions/generators/bash-generator.js';
import { CommandDefinition } from '../../../../src/core/completions/types.js';
describe('BashGenerator', () => {
let generator: BashGenerator;
beforeEach(() => {
generator = new BashGenerator();
});
describe('interface compliance', () => {
it('should have shell property set to "bash"', () => {
expect(generator.shell).toBe('bash');
});
it('should implement generate method', () => {
expect(typeof generator.generate).toBe('function');
});
});
describe('generate', () => {
it('should generate valid bash completion script with header', () => {
const commands: CommandDefinition[] = [
{
name: 'init',
description: 'Initialize OpenSpec',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('# Bash completion script for OpenSpec CLI');
expect(script).toContain('_openspec_completion() {');
expect(script).toContain('local cur prev words cword');
expect(script).toContain('_init_completion -n : || return');
});
it('should include all commands in the command list', () => {
const commands: CommandDefinition[] = [
{
name: 'init',
description: 'Initialize OpenSpec',
flags: [],
},
{
name: 'validate',
description: 'Validate specs',
flags: [],
},
{
name: 'show',
description: 'Show a spec',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('init');
expect(script).toContain('validate');
expect(script).toContain('show');
});
it('should handle commands with flags without short options', () => {
const commands: CommandDefinition[] = [
{
name: 'validate',
description: 'Validate specs',
flags: [
{
name: 'strict',
description: 'Enable strict mode',
},
{
name: 'json',
description: 'Output as JSON',
},
],
},
];
const script = generator.generate(commands);
expect(script).toContain('--strict');
expect(script).toContain('--json');
});
it('should handle flags with short options', () => {
const commands: CommandDefinition[] = [
{
name: 'show',
description: 'Show a spec',
flags: [
{
name: 'requirement',
short: 'r',
description: 'Show specific requirement',
takesValue: true,
},
],
},
];
const script = generator.generate(commands);
expect(script).toContain('-r');
expect(script).toContain('--requirement');
});
it('should handle boolean flags vs value-taking flags', () => {
const commands: CommandDefinition[] = [
{
name: 'validate',
description: 'Validate specs',
flags: [
{
name: 'strict',
description: 'Enable strict mode',
},
{
name: 'output',
description: 'Output file',
takesValue: true,
},
],
},
];
const script = generator.generate(commands);
expect(script).toContain('--strict');
expect(script).toContain('--output');
});
it('should handle flags with enum values', () => {
const commands: CommandDefinition[] = [
{
name: 'validate',
description: 'Validate specs',
flags: [
{
name: 'type',
description: 'Specify item type',
takesValue: true,
values: ['change', 'spec'],
},
],
},
];
const script = generator.generate(commands);
expect(script).toContain('--type');
expect(script).toContain('change');
expect(script).toContain('spec');
});
it('should handle flags with takesValue but no specific values', () => {
const commands: CommandDefinition[] = [
{
name: 'validate',
description: 'Validate specs',
flags: [
{
name: 'concurrency',
description: 'Max concurrent validations',
takesValue: true,
},
],
},
];
const script = generator.generate(commands);
expect(script).toContain('--concurrency');
});
it('should handle commands with subcommands', () => {
const commands: CommandDefinition[] = [
{
name: 'change',
description: 'Manage changes',
flags: [],
subcommands: [
{
name: 'show',
description: 'Show a change',
flags: [],
},
{
name: 'list',
description: 'List changes',
flags: [],
},
],
},
];
const script = generator.generate(commands);
expect(script).toContain('change)');
expect(script).toContain('show');
expect(script).toContain('list');
});
it('should handle positional arguments for change-id', () => {
const commands: CommandDefinition[] = [
{
name: 'archive',
description: 'Archive a change',
acceptsPositional: true,
positionalType: 'change-id',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('_openspec_complete_changes');
});
it('should handle positional arguments for spec-id', () => {
const commands: CommandDefinition[] = [
{
name: 'show-spec',
description: 'Show a spec',
acceptsPositional: true,
positionalType: 'spec-id',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('_openspec_complete_specs');
});
it('should handle positional arguments for change-or-spec-id', () => {
const commands: CommandDefinition[] = [
{
name: 'show',
description: 'Show an item',
acceptsPositional: true,
positionalType: 'change-or-spec-id',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('_openspec_complete_items');
});
it('should handle positional arguments for shell', () => {
const commands: CommandDefinition[] = [
{
name: 'generate',
description: 'Generate completions',
acceptsPositional: true,
positionalType: 'shell',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('zsh');
expect(script).toContain('bash');
expect(script).toContain('fish');
expect(script).toContain('powershell');
});
it('should handle positional arguments for paths', () => {
const commands: CommandDefinition[] = [
{
name: 'init',
description: 'Initialize OpenSpec',
acceptsPositional: true,
positionalType: 'path',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('compgen -f');
});
it('should generate dynamic completion helper for changes', () => {
const commands: CommandDefinition[] = [
{
name: 'archive',
description: 'Archive a change',
acceptsPositional: true,
positionalType: 'change-id',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('_openspec_complete_changes() {');
expect(script).toContain('openspec __complete changes 2>/dev/null');
expect(script).toContain('cut -f1');
expect(script).toContain('COMPREPLY=');
});
it('should generate dynamic completion helper for specs', () => {
const commands: CommandDefinition[] = [
{
name: 'show-spec',
description: 'Show a spec',
acceptsPositional: true,
positionalType: 'spec-id',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('_openspec_complete_specs() {');
expect(script).toContain('openspec __complete specs 2>/dev/null');
expect(script).toContain('cut -f1');
});
it('should generate dynamic completion helper for items (changes and specs)', () => {
const commands: CommandDefinition[] = [
{
name: 'show',
description: 'Show an item',
acceptsPositional: true,
positionalType: 'change-or-spec-id',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('_openspec_complete_items() {');
expect(script).toContain('openspec __complete changes 2>/dev/null');
expect(script).toContain('openspec __complete specs 2>/dev/null');
});
it('should handle complex nested subcommands with flags', () => {
const commands: CommandDefinition[] = [
{
name: 'spec',
description: 'Manage specs',
flags: [],
subcommands: [
{
name: 'validate',
description: 'Validate a spec',
acceptsPositional: true,
positionalType: 'spec-id',
flags: [
{
name: 'strict',
description: 'Enable strict mode',
},
{
name: 'json',
description: 'Output as JSON',
},
],
},
],
},
];
const script = generator.generate(commands);
expect(script).toContain('spec)');
expect(script).toContain('validate');
expect(script).toContain('--strict');
expect(script).toContain('--json');
expect(script).toContain('_openspec_complete_specs');
});
it('should generate script that ends with complete registration', () => {
const commands: CommandDefinition[] = [
{
name: 'init',
description: 'Initialize',
flags: [],
},
];
const script = generator.generate(commands);
expect(script.trim().endsWith('complete -F _openspec_completion openspec')).toBe(true);
});
it('should handle empty command list', () => {
const commands: CommandDefinition[] = [];
const script = generator.generate(commands);
expect(script).toContain('# Bash completion script');
expect(script).toContain('_openspec_completion() {');
expect(script).toContain('complete -F _openspec_completion openspec');
});
it('should handle commands with no flags', () => {
const commands: CommandDefinition[] = [
{
name: 'view',
description: 'Display dashboard',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('view)');
});
});
describe('security - command injection prevention', () => {
it('should escape command names with shell metacharacters', () => {
const commands: CommandDefinition[] = [
{
name: 'test',
description: 'Test command',
flags: [],
},
];
const script = generator.generate(commands);
// Normal command name should be in the script
expect(script).toContain('test');
});
it('should escape dollar signs in command names', () => {
// This tests that if a command name somehow contained $, it would be escaped
// In practice, command names are validated, but the escaping provides defense in depth
const maliciousName = 'test$var';
const commands: CommandDefinition[] = [
{
name: maliciousName,
description: 'Test',
flags: [],
},
];
const script = generator.generate(commands);
// Should escape the dollar sign
expect(script).toContain('test\\$var');
});
it('should escape backticks in command names', () => {
const maliciousName = 'test`cmd`';
const commands: CommandDefinition[] = [
{
name: maliciousName,
description: 'Test',
flags: [],
},
];
const script = generator.generate(commands);
// Should escape backticks
expect(script).toContain('\\`');
});
it('should escape double quotes in command names', () => {
const maliciousName = 'test"quoted"';
const commands: CommandDefinition[] = [
{
name: maliciousName,
description: 'Test',
flags: [],
},
];
const script = generator.generate(commands);
// Should escape double quotes
expect(script).toContain('\\"');
});
it('should escape backslashes in command names', () => {
const maliciousName = 'test\\path';
const commands: CommandDefinition[] = [
{
name: maliciousName,
description: 'Test',
flags: [],
},
];
const script = generator.generate(commands);
// Should escape backslashes
expect(script).toContain('\\\\');
});
it('should escape subcommand names with shell metacharacters', () => {
const commands: CommandDefinition[] = [
{
name: 'parent',
description: 'Parent command',
flags: [],
subcommands: [
{
name: 'sub$cmd',
description: 'Subcommand with metacharacter',
flags: [],
},
],
},
];
const script = generator.generate(commands);
// Should escape metacharacters in subcommand names
expect(script).toContain('sub\\$cmd');
});
});
});
@@ -1,532 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { FishGenerator } from '../../../../src/core/completions/generators/fish-generator.js';
import { CommandDefinition } from '../../../../src/core/completions/types.js';
describe('FishGenerator', () => {
let generator: FishGenerator;
beforeEach(() => {
generator = new FishGenerator();
});
describe('interface compliance', () => {
it('should have shell property set to "fish"', () => {
expect(generator.shell).toBe('fish');
});
it('should implement generate method', () => {
expect(typeof generator.generate).toBe('function');
});
});
describe('generate', () => {
it('should generate valid fish completion script with header', () => {
const commands: CommandDefinition[] = [
{
name: 'init',
description: 'Initialize OpenSpec',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('# Fish completion script for OpenSpec CLI');
expect(script).toContain('function __fish_openspec');
});
it('should generate helper functions for Fish', () => {
const commands: CommandDefinition[] = [
{
name: 'init',
description: 'Initialize OpenSpec',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('function __fish_openspec_using_subcommand');
expect(script).toContain('function __fish_openspec_no_subcommand');
expect(script).toContain('commandline -opc');
});
it('should include all commands with descriptions', () => {
const commands: CommandDefinition[] = [
{
name: 'init',
description: 'Initialize OpenSpec',
flags: [],
},
{
name: 'validate',
description: 'Validate specs',
flags: [],
},
{
name: 'show',
description: 'Show a spec',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain("complete -c openspec");
expect(script).toContain("-a 'init'");
expect(script).toContain("'Initialize OpenSpec'");
expect(script).toContain("-a 'validate'");
expect(script).toContain("'Validate specs'");
expect(script).toContain("-a 'show'");
expect(script).toContain("'Show a spec'");
});
it('should handle commands with flags without short options', () => {
const commands: CommandDefinition[] = [
{
name: 'validate',
description: 'Validate specs',
flags: [
{
name: 'strict',
description: 'Enable strict mode',
},
{
name: 'json',
description: 'Output as JSON',
},
],
},
];
const script = generator.generate(commands);
expect(script).toContain("-l strict");
expect(script).toContain("'Enable strict mode'");
expect(script).toContain("-l json");
expect(script).toContain("'Output as JSON'");
});
it('should handle flags with short options', () => {
const commands: CommandDefinition[] = [
{
name: 'show',
description: 'Show a spec',
flags: [
{
name: 'requirement',
short: 'r',
description: 'Show specific requirement',
takesValue: true,
},
],
},
];
const script = generator.generate(commands);
expect(script).toContain("-s r");
expect(script).toContain("-l requirement");
expect(script).toContain("'Show specific requirement'");
expect(script).toContain("-r");
});
it('should use -r flag for flags that require values', () => {
const commands: CommandDefinition[] = [
{
name: 'validate',
description: 'Validate specs',
flags: [
{
name: 'output',
description: 'Output file',
takesValue: true,
},
],
},
];
const script = generator.generate(commands);
expect(script).toContain("-l output");
expect(script).toContain("-r");
});
it('should not use -r flag for boolean flags', () => {
const commands: CommandDefinition[] = [
{
name: 'validate',
description: 'Validate specs',
flags: [
{
name: 'strict',
description: 'Enable strict mode',
},
],
},
];
const script = generator.generate(commands);
const lines = script.split('\n');
const strictLine = lines.find(line => line.includes('-l strict'));
expect(strictLine).toBeDefined();
expect(strictLine).not.toContain(' -r');
});
it('should handle flags with enum values', () => {
const commands: CommandDefinition[] = [
{
name: 'validate',
description: 'Validate specs',
flags: [
{
name: 'type',
description: 'Specify item type',
takesValue: true,
values: ['change', 'spec'],
},
],
},
];
const script = generator.generate(commands);
expect(script).toContain("-l type");
expect(script).toContain("change");
expect(script).toContain("spec");
});
it('should handle commands with subcommands', () => {
const commands: CommandDefinition[] = [
{
name: 'change',
description: 'Manage changes',
flags: [],
subcommands: [
{
name: 'show',
description: 'Show a change',
flags: [],
},
{
name: 'list',
description: 'List changes',
flags: [],
},
],
},
];
const script = generator.generate(commands);
expect(script).toContain("'change'");
expect(script).toContain("'show'");
expect(script).toContain("'list'");
expect(script).toContain("__fish_openspec_using_subcommand change");
});
it('should handle positional arguments for change-id', () => {
const commands: CommandDefinition[] = [
{
name: 'archive',
description: 'Archive a change',
acceptsPositional: true,
positionalType: 'change-id',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('__fish_openspec_changes');
});
it('should handle positional arguments for spec-id', () => {
const commands: CommandDefinition[] = [
{
name: 'show-spec',
description: 'Show a spec',
acceptsPositional: true,
positionalType: 'spec-id',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('__fish_openspec_specs');
});
it('should handle positional arguments for change-or-spec-id', () => {
const commands: CommandDefinition[] = [
{
name: 'show',
description: 'Show an item',
acceptsPositional: true,
positionalType: 'change-or-spec-id',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('__fish_openspec_items');
});
it('should handle positional arguments for shell with inline values', () => {
const commands: CommandDefinition[] = [
{
name: 'generate',
description: 'Generate completions',
acceptsPositional: true,
positionalType: 'shell',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('zsh');
expect(script).toContain('bash');
expect(script).toContain('fish');
expect(script).toContain('powershell');
});
it('should generate dynamic completion helper for changes', () => {
const commands: CommandDefinition[] = [
{
name: 'archive',
description: 'Archive a change',
acceptsPositional: true,
positionalType: 'change-id',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('function __fish_openspec_changes');
expect(script).toContain('openspec __complete changes 2>/dev/null');
expect(script).toContain('while read -l id desc');
expect(script).toContain('printf');
});
it('should generate dynamic completion helper for specs', () => {
const commands: CommandDefinition[] = [
{
name: 'show-spec',
description: 'Show a spec',
acceptsPositional: true,
positionalType: 'spec-id',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('function __fish_openspec_specs');
expect(script).toContain('openspec __complete specs 2>/dev/null');
});
it('should generate dynamic completion helper for items', () => {
const commands: CommandDefinition[] = [
{
name: 'show',
description: 'Show an item',
acceptsPositional: true,
positionalType: 'change-or-spec-id',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('function __fish_openspec_items');
expect(script).toContain('__fish_openspec_changes');
expect(script).toContain('__fish_openspec_specs');
});
it('should escape single quotes in descriptions', () => {
const commands: CommandDefinition[] = [
{
name: 'test',
description: "Test with 'quotes'",
flags: [
{
name: 'flag',
description: "Special chars: 'quotes'",
},
],
},
];
const script = generator.generate(commands);
expect(script).toContain("\\'quotes\\'");
});
it('should handle complex nested subcommands with flags', () => {
const commands: CommandDefinition[] = [
{
name: 'spec',
description: 'Manage specs',
flags: [],
subcommands: [
{
name: 'validate',
description: 'Validate a spec',
acceptsPositional: true,
positionalType: 'spec-id',
flags: [
{
name: 'strict',
description: 'Enable strict mode',
},
{
name: 'json',
description: 'Output as JSON',
},
],
},
],
},
];
const script = generator.generate(commands);
expect(script).toContain("'spec'");
expect(script).toContain("'validate'");
expect(script).toContain("-l strict");
expect(script).toContain("-l json");
expect(script).toContain('__fish_openspec_specs');
});
it('should handle empty command list', () => {
const commands: CommandDefinition[] = [];
const script = generator.generate(commands);
expect(script).toContain('# Fish completion script');
expect(script).toContain('function __fish_openspec');
});
it('should handle commands with no flags', () => {
const commands: CommandDefinition[] = [
{
name: 'view',
description: 'Display dashboard',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain("'view'");
expect(script).toContain("'Display dashboard'");
});
});
describe('security - command injection prevention', () => {
it('should escape $() command substitution in descriptions', () => {
const commands: CommandDefinition[] = [
{
name: 'test',
description: 'Test command $(curl evil.com)',
flags: [],
},
];
const script = generator.generate(commands);
// Should contain escaped dollar signs to prevent command substitution
expect(script).toContain('\\$');
// Should have backslash before $( to escape it
expect(script).toMatch(/\\\$\(curl/);
});
it('should escape backticks in descriptions', () => {
const commands: CommandDefinition[] = [
{
name: 'test',
description: 'Test command `whoami`',
flags: [],
},
];
const script = generator.generate(commands);
// Should not contain unescaped backticks
expect(script).not.toMatch(/`whoami`/);
// Should contain escaped version
expect(script).toContain('\\`');
});
it('should escape dollar signs in descriptions', () => {
const commands: CommandDefinition[] = [
{
name: 'test',
description: 'Test with $variable',
flags: [],
},
];
const script = generator.generate(commands);
// Should escape dollar signs
expect(script).toContain('\\$');
});
it('should escape single quotes in descriptions', () => {
const commands: CommandDefinition[] = [
{
name: 'test',
description: "Test with 'quotes'",
flags: [],
},
];
const script = generator.generate(commands);
// Should escape single quotes
expect(script).toContain("\\'");
});
it('should escape backslashes in descriptions', () => {
const commands: CommandDefinition[] = [
{
name: 'test',
description: 'Test with \\ backslash',
flags: [],
},
];
const script = generator.generate(commands);
// Should contain escaped backslashes
expect(script).toContain('\\\\');
});
it('should handle multiple shell metacharacters together', () => {
const commands: CommandDefinition[] = [
{
name: 'test',
description: "Dangerous: $(rm -rf /) `cat /etc/passwd` $HOME 'quoted'",
flags: [],
},
];
const script = generator.generate(commands);
// Should contain escaped versions of dangerous patterns
expect(script).toContain('\\$'); // Escaped dollar signs
expect(script).toContain('\\`'); // Escaped backticks
expect(script).toContain("\\'"); // Escaped single quotes
// The escaped patterns should be present (backslash before dangerous chars)
expect(script).toMatch(/\\\$\(/); // \$( instead of $(
expect(script).toMatch(/\\\`cat/); // \`cat instead of `cat
});
});
});
@@ -1,532 +0,0 @@
import {describe, it, expect, beforeEach} from 'vitest';
import {PowerShellGenerator} from '../../../../src/core/completions/generators/powershell-generator.js';
import {CommandDefinition} from '../../../../src/core/completions/types.js';
describe('PowerShellGenerator', () => {
let generator: PowerShellGenerator;
beforeEach(() => {
generator = new PowerShellGenerator();
});
describe('interface compliance', () => {
it('should have shell property set to "powershell"', () => {
expect(generator.shell).toBe('powershell');
});
it('should implement generate method', () => {
expect(typeof generator.generate).toBe('function');
});
});
describe('generate', () => {
it('should generate valid PowerShell completion script with header', () => {
const commands: CommandDefinition[] = [
{
name: 'init',
description: 'Initialize OpenSpec',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('# PowerShell completion script for OpenSpec CLI');
expect(script).toContain('$openspecCompleter = {');
expect(script).toContain('Register-ArgumentCompleter');
});
it('should register argument completer for openspec command', () => {
const commands: CommandDefinition[] = [
{
name: 'init',
description: 'Initialize OpenSpec',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('Register-ArgumentCompleter -CommandName openspec');
expect(script).toContain('-ScriptBlock $openspecCompleter');
});
it('should include all commands with descriptions', () => {
const commands: CommandDefinition[] = [
{
name: 'init',
description: 'Initialize OpenSpec',
flags: [],
},
{
name: 'validate',
description: 'Validate specs',
flags: [],
},
{
name: 'show',
description: 'Show a spec',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('"init"');
expect(script).toContain('Initialize OpenSpec');
expect(script).toContain('"validate"');
expect(script).toContain('Validate specs');
expect(script).toContain('"show"');
expect(script).toContain('Show a spec');
});
it('should use CompletionResult objects for completions', () => {
const commands: CommandDefinition[] = [
{
name: 'init',
description: 'Initialize OpenSpec',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('[System.Management.Automation.CompletionResult]::new(');
});
it('should handle commands with flags without short options', () => {
const commands: CommandDefinition[] = [
{
name: 'validate',
description: 'Validate specs',
flags: [
{
name: 'strict',
description: 'Enable strict mode',
},
{
name: 'json',
description: 'Output as JSON',
},
],
},
];
const script = generator.generate(commands);
expect(script).toContain('--strict');
expect(script).toContain('Enable strict mode');
expect(script).toContain('--json');
expect(script).toContain('Output as JSON');
});
it('should handle flags with short options', () => {
const commands: CommandDefinition[] = [
{
name: 'show',
description: 'Show a spec',
flags: [
{
name: 'requirement',
short: 'r',
description: 'Show specific requirement',
takesValue: true,
},
],
},
];
const script = generator.generate(commands);
expect(script).toContain('-r');
expect(script).toContain('--requirement');
expect(script).toContain('Show specific requirement');
});
it('should handle boolean flags vs value-taking flags', () => {
const commands: CommandDefinition[] = [
{
name: 'validate',
description: 'Validate specs',
flags: [
{
name: 'strict',
description: 'Enable strict mode',
},
{
name: 'output',
description: 'Output file',
takesValue: true,
},
],
},
];
const script = generator.generate(commands);
expect(script).toContain('--strict');
expect(script).toContain('--output');
expect(script).toContain('Enable strict mode');
expect(script).toContain('Output file');
});
it('should handle flags with enum values', () => {
const commands: CommandDefinition[] = [
{
name: 'validate',
description: 'Validate specs',
flags: [
{
name: 'type',
description: 'Specify item type',
takesValue: true,
values: ['change', 'spec'],
},
],
},
];
const script = generator.generate(commands);
expect(script).toContain('--type');
expect(script).toContain('change');
expect(script).toContain('spec');
});
it('should handle commands with subcommands', () => {
const commands: CommandDefinition[] = [
{
name: 'change',
description: 'Manage changes',
flags: [],
subcommands: [
{
name: 'show',
description: 'Show a change',
flags: [],
},
{
name: 'list',
description: 'List changes',
flags: [],
},
],
},
];
const script = generator.generate(commands);
expect(script).toContain('"change"');
expect(script).toContain('"show"');
expect(script).toContain('"list"');
expect(script).toContain('Manage changes');
expect(script).toContain('Show a change');
expect(script).toContain('List changes');
});
it('should handle positional arguments for change-id', () => {
const commands: CommandDefinition[] = [
{
name: 'archive',
description: 'Archive a change',
acceptsPositional: true,
positionalType: 'change-id',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('Get-OpenSpecChanges');
});
it('should handle positional arguments for spec-id', () => {
const commands: CommandDefinition[] = [
{
name: 'show-spec',
description: 'Show a spec',
acceptsPositional: true,
positionalType: 'spec-id',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('Get-OpenSpecSpecs');
});
it('should handle positional arguments for change-or-spec-id', () => {
const commands: CommandDefinition[] = [
{
name: 'show',
description: 'Show an item',
acceptsPositional: true,
positionalType: 'change-or-spec-id',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('Get-OpenSpecChanges');
expect(script).toContain('Get-OpenSpecSpecs');
});
it('should handle positional arguments for shell with inline values', () => {
const commands: CommandDefinition[] = [
{
name: 'generate',
description: 'Generate completions',
acceptsPositional: true,
positionalType: 'shell',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('zsh');
expect(script).toContain('bash');
expect(script).toContain('fish');
expect(script).toContain('powershell');
});
it('should not include path completion helpers (PowerShell handles natively)', () => {
const commands: CommandDefinition[] = [
{
name: 'init',
description: 'Initialize OpenSpec',
acceptsPositional: true,
positionalType: 'path',
flags: [],
},
];
const script = generator.generate(commands);
// PowerShell handles path completion natively, so we just check the command is present
expect(script).toContain('"init"');
});
it('should generate dynamic completion helper for changes', () => {
const commands: CommandDefinition[] = [
{
name: 'archive',
description: 'Archive a change',
acceptsPositional: true,
positionalType: 'change-id',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('function Get-OpenSpecChanges');
expect(script).toContain('openspec __complete changes 2>$null');
expect(script).toContain('-split');
});
it('should generate dynamic completion helper for specs', () => {
const commands: CommandDefinition[] = [
{
name: 'show-spec',
description: 'Show a spec',
acceptsPositional: true,
positionalType: 'spec-id',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('function Get-OpenSpecSpecs');
expect(script).toContain('openspec __complete specs 2>$null');
});
it('should escape double quotes in descriptions', () => {
const commands: CommandDefinition[] = [
{
name: 'test',
description: 'Test with "quotes"',
flags: [
{
name: 'flag',
description: 'Special chars: "quotes"',
},
],
},
];
const script = generator.generate(commands);
// PowerShell escapes double quotes by doubling them
expect(script).toContain('""quotes""');
});
it('should handle complex nested subcommands with flags', () => {
const commands: CommandDefinition[] = [
{
name: 'spec',
description: 'Manage specs',
flags: [],
subcommands: [
{
name: 'validate',
description: 'Validate a spec',
acceptsPositional: true,
positionalType: 'spec-id',
flags: [
{
name: 'strict',
description: 'Enable strict mode',
},
{
name: 'json',
description: 'Output as JSON',
},
],
},
],
},
];
const script = generator.generate(commands);
expect(script).toContain('"spec"');
expect(script).toContain('"validate"');
expect(script).toContain('--strict');
expect(script).toContain('--json');
expect(script).toContain('Get-OpenSpecSpecs');
});
it('should handle empty command list', () => {
const commands: CommandDefinition[] = [];
const script = generator.generate(commands);
expect(script).toContain('# PowerShell completion script');
expect(script).toContain('$openspecCompleter = {');
expect(script).toContain('Register-ArgumentCompleter');
});
it('should handle commands with no flags', () => {
const commands: CommandDefinition[] = [
{
name: 'view',
description: 'Display dashboard',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('"view"');
expect(script).toContain('Display dashboard');
});
it('should generate helper function that splits on tab character', () => {
const commands: CommandDefinition[] = [
{
name: 'archive',
description: 'Archive a change',
acceptsPositional: true,
positionalType: 'change-id',
flags: [],
},
];
const script = generator.generate(commands);
expect(script).toContain('function Get-OpenSpecChanges');
// PowerShell uses -split with \\t for tab character
expect(script).toContain('-split');
expect(script).toContain('[0]');
});
});
describe('security - command injection prevention', () => {
it('should escape $() subexpressions in descriptions', () => {
const commands: CommandDefinition[] = [
{
name: 'test',
description: 'Test command $(Get-Process)',
flags: [],
},
];
const script = generator.generate(commands);
// Should contain escaped version (backtick before $)
expect(script).toContain('`$');
// Should have backtick before $( to escape it
expect(script).toMatch(/`\$\(Get-Process\)/);
});
it('should escape backticks in descriptions', () => {
const commands: CommandDefinition[] = [
{
name: 'test',
description: 'Test with `n newline escape',
flags: [],
},
];
const script = generator.generate(commands);
// Should escape backticks (PowerShell escape character)
expect(script).toContain('``');
});
it('should escape dollar signs in descriptions', () => {
const commands: CommandDefinition[] = [
{
name: 'test',
description: 'Test with $env:PATH variable',
flags: [],
},
];
const script = generator.generate(commands);
// Should escape dollar signs
expect(script).toContain('`$');
});
it('should escape double quotes in descriptions', () => {
const commands: CommandDefinition[] = [
{
name: 'test',
description: 'Test with "quotes"',
flags: [],
},
];
const script = generator.generate(commands);
// Should escape double quotes (PowerShell string delimiter)
expect(script).toContain('""');
});
it('should handle multiple PowerShell metacharacters together', () => {
const commands: CommandDefinition[] = [
{
name: 'test',
description: 'Dangerous: $(Remove-Item -Force) `n $env:HOME "quoted"',
flags: [],
},
];
const script = generator.generate(commands);
// Should contain escaped versions of dangerous patterns
expect(script).toContain('`$'); // Escaped dollar signs
expect(script).toContain('``'); // Escaped backticks
expect(script).toContain('""'); // Escaped double quotes
// The escaped patterns should be present (backtick before $ and n)
expect(script).toMatch(/`\$\(/); // `$( instead of $(
expect(script).toMatch(/``n/); // ``n instead of `n
});
});
});
@@ -1,486 +0,0 @@
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 { BashInstaller } from '../../../../src/core/completions/installers/bash-installer.js';
describe('BashInstaller', () => {
let testHomeDir: string;
let installer: BashInstaller;
beforeEach(async () => {
// Create a temporary home directory for testing
testHomeDir = path.join(os.tmpdir(), `openspec-bash-test-${randomUUID()}`);
await fs.mkdir(testHomeDir, { recursive: true });
installer = new BashInstaller(testHomeDir);
});
afterEach(async () => {
// Clean up test directory
await fs.rm(testHomeDir, { recursive: true, force: true });
});
describe('getInstallationPath', () => {
it('should return standard bash-completion path', async () => {
const result = await installer.getInstallationPath();
expect(result).toBe(path.join(testHomeDir, '.local', 'share', 'bash-completion', 'completions', 'openspec'));
});
});
describe('backupExistingFile', () => {
it('should return undefined when file does not exist', async () => {
const nonExistentPath = path.join(testHomeDir, 'nonexistent.txt');
const backupPath = await installer.backupExistingFile(nonExistentPath);
expect(backupPath).toBeUndefined();
});
it('should create backup when file exists', async () => {
const filePath = path.join(testHomeDir, 'test.txt');
await fs.writeFile(filePath, 'original content');
const backupPath = await installer.backupExistingFile(filePath);
expect(backupPath).toBeDefined();
expect(backupPath).toContain('.backup-');
// Verify backup file exists and has correct content
const backupContent = await fs.readFile(backupPath!, 'utf-8');
expect(backupContent).toBe('original content');
});
it('should create backup with timestamp in filename', async () => {
const filePath = path.join(testHomeDir, 'test.txt');
await fs.writeFile(filePath, 'content');
const backupPath = await installer.backupExistingFile(filePath);
expect(backupPath).toMatch(/\.backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}/);
});
});
describe('install', () => {
const testScript = '# Bash completion script for OpenSpec CLI\n_openspec_completion() {\n echo "test"\n}\n';
it('should install to bash-completion path', async () => {
const result = await installer.install(testScript);
expect(result.success).toBe(true);
expect(result.installedPath).toBe(path.join(testHomeDir, '.local', 'share', 'bash-completion', 'completions', 'openspec'));
// Verify file was created with correct content
const content = await fs.readFile(result.installedPath!, 'utf-8');
expect(content).toBe(testScript);
});
it('should create necessary directories if they do not exist', async () => {
const result = await installer.install(testScript);
expect(result.success).toBe(true);
// Verify directory structure was created
const completionsDir = path.dirname(result.installedPath!);
const stat = await fs.stat(completionsDir);
expect(stat.isDirectory()).toBe(true);
});
it('should backup existing file before overwriting', async () => {
const targetPath = path.join(testHomeDir, '.local', 'share', 'bash-completion', 'completions', 'openspec');
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.writeFile(targetPath, 'old script');
const result = await installer.install(testScript);
expect(result.success).toBe(true);
expect(result.backupPath).toBeDefined();
expect(result.backupPath).toContain('.backup-');
// Verify backup has old content
const backupContent = await fs.readFile(result.backupPath!, 'utf-8');
expect(backupContent).toBe('old script');
// Verify new file has new content
const newContent = await fs.readFile(targetPath, 'utf-8');
expect(newContent).toBe(testScript);
});
it('should configure .bashrc when auto-config is enabled', async () => {
const result = await installer.install(testScript);
expect(result.success).toBe(true);
expect(result.bashrcConfigured).toBe(true);
const bashrcPath = path.join(testHomeDir, '.bashrc');
const content = await fs.readFile(bashrcPath, 'utf-8');
expect(content).toContain('# OPENSPEC:START');
expect(content).toContain('# OPENSPEC:END');
expect(content).toContain('OpenSpec shell completions configuration');
});
it('should include instructions when auto-config is disabled', async () => {
const originalEnv = process.env.OPENSPEC_NO_AUTO_CONFIG;
process.env.OPENSPEC_NO_AUTO_CONFIG = '1';
const result = await installer.install(testScript);
expect(result.instructions).toBeDefined();
expect(result.instructions!.join('\n')).toContain('.bashrc');
expect(result.bashrcConfigured).toBe(false);
// Restore env
if (originalEnv === undefined) {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
} else {
process.env.OPENSPEC_NO_AUTO_CONFIG = originalEnv;
}
});
it('should handle installation errors gracefully', async () => {
// Create installer with non-existent/invalid home directory
// Use a path that will fail on both Unix and Windows
const invalidPath = process.platform === 'win32'
? 'Z:\\nonexistent\\invalid\\path' // Non-existent drive letter on Windows
: '/root/invalid/nonexistent/path'; // Permission-denied path on Unix
const invalidInstaller = new BashInstaller(invalidPath);
const result = await invalidInstaller.install(testScript);
expect(result.success).toBe(false);
expect(result.message).toContain('Failed to install');
});
it('should detect already-installed completion with identical content', async () => {
// First installation
const firstResult = await installer.install(testScript);
expect(firstResult.success).toBe(true);
// Second installation with same script
const secondResult = await installer.install(testScript);
expect(secondResult.success).toBe(true);
expect(secondResult.message).toContain('already installed');
expect(secondResult.message).toContain('up to date');
expect(secondResult.backupPath).toBeUndefined();
});
it('should update completion when content differs', async () => {
// First installation
const firstScript = '# Bash completion v1\n_openspec_completion() {\n echo "version 1"\n}\n';
const firstResult = await installer.install(firstScript);
expect(firstResult.success).toBe(true);
// Second installation with different script
const secondScript = '# Bash completion v2\n_openspec_completion() {\n echo "version 2"\n}\n';
const secondResult = await installer.install(secondScript);
expect(secondResult.success).toBe(true);
expect(secondResult.message).toContain('updated successfully');
expect(secondResult.backupPath).toBeDefined();
// Verify new content was written
const content = await fs.readFile(secondResult.installedPath!, 'utf-8');
expect(content).toBe(secondScript);
// Verify backup has old content
const backupContent = await fs.readFile(secondResult.backupPath!, 'utf-8');
expect(backupContent).toBe(firstScript);
});
it('should handle paths with spaces in .bashrc config', async () => {
// Create a test home directory with spaces
const testHomeDirWithSpaces = path.join(os.tmpdir(), `openspec bash test ${randomUUID()}`);
await fs.mkdir(testHomeDirWithSpaces, { recursive: true });
const installerWithSpaces = new BashInstaller(testHomeDirWithSpaces);
try {
const result = await installerWithSpaces.install(testScript);
expect(result.success).toBe(true);
// Check if .bashrc was created (when auto-config is enabled)
const bashrcPath = path.join(testHomeDirWithSpaces, '.bashrc');
try {
const bashrcContent = await fs.readFile(bashrcPath, 'utf-8');
// Verify the path is quoted in config
const completionsDir = path.dirname(result.installedPath!);
expect(bashrcContent).toContain(completionsDir);
} catch {
// .bashrc might not exist if auto-config was disabled
}
} finally {
// Clean up
await fs.rm(testHomeDirWithSpaces, { recursive: true, force: true });
}
});
});
describe('uninstall', () => {
const testScript = '# Bash completion script\n_openspec_completion() {}\n';
it('should remove installed completion script', async () => {
// Install first
await installer.install(testScript);
// Uninstall
const result = await installer.uninstall();
expect(result.success).toBe(true);
expect(result.message).toContain('uninstalled successfully');
// Verify file is gone
const targetPath = await installer.getInstallationPath();
const exists = await fs.access(targetPath).then(() => true).catch(() => false);
expect(exists).toBe(false);
});
it('should return failure when not installed', async () => {
const result = await installer.uninstall();
expect(result.success).toBe(false);
expect(result.message).toContain('not installed');
});
it('should remove .bashrc configuration', async () => {
await installer.install(testScript);
const result = await installer.uninstall();
expect(result.success).toBe(true);
// Verify .bashrc markers are removed
const bashrcPath = path.join(testHomeDir, '.bashrc');
const exists = await fs.access(bashrcPath).then(() => true).catch(() => false);
if (exists) {
const content = await fs.readFile(bashrcPath, 'utf-8');
expect(content).not.toContain('# OPENSPEC:START');
expect(content).not.toContain('# OPENSPEC:END');
}
});
});
describe('configureBashrc', () => {
const completionsDir = '/test/.local/share/bash-completion/completions';
it('should create .bashrc with markers and config when file does not exist', async () => {
const result = await installer.configureBashrc(completionsDir);
expect(result).toBe(true);
const bashrcPath = path.join(testHomeDir, '.bashrc');
const content = await fs.readFile(bashrcPath, 'utf-8');
expect(content).toContain('# OPENSPEC:START');
expect(content).toContain('# OPENSPEC:END');
expect(content).toContain('# OpenSpec shell completions configuration');
expect(content).toContain(completionsDir);
});
it('should prepend markers and config when .bashrc exists without markers', async () => {
const bashrcPath = path.join(testHomeDir, '.bashrc');
await fs.writeFile(bashrcPath, '# My custom bash config\nalias ll="ls -la"\n');
const result = await installer.configureBashrc(completionsDir);
expect(result).toBe(true);
const content = await fs.readFile(bashrcPath, 'utf-8');
expect(content).toContain('# OPENSPEC:START');
expect(content).toContain('# OPENSPEC:END');
expect(content).toContain('# My custom bash config');
expect(content).toContain('alias ll="ls -la"');
// Config should be before existing content
const configIndex = content.indexOf('# OPENSPEC:START');
const aliasIndex = content.indexOf('alias ll');
expect(configIndex).toBeLessThan(aliasIndex);
});
it('should update config between markers when .bashrc has existing markers', async () => {
const bashrcPath = path.join(testHomeDir, '.bashrc');
const initialContent = [
'# OPENSPEC:START',
'# Old config',
'if [ -d "/old/path" ]; then',
' . "/old/path"',
'fi',
'# OPENSPEC:END',
'',
'# My custom config',
].join('\n');
await fs.writeFile(bashrcPath, initialContent);
const result = await installer.configureBashrc(completionsDir);
expect(result).toBe(true);
const content = await fs.readFile(bashrcPath, 'utf-8');
expect(content).toContain('# OPENSPEC:START');
expect(content).toContain('# OPENSPEC:END');
expect(content).toContain(completionsDir);
expect(content).not.toContain('# Old config');
expect(content).not.toContain('/old/path');
expect(content).toContain('# My custom config');
});
it('should preserve user content outside markers', async () => {
const bashrcPath = path.join(testHomeDir, '.bashrc');
const userContent = [
'# My bash config',
'export PATH="/custom/path:$PATH"',
'',
'# OPENSPEC:START',
'# Old OpenSpec config',
'# OPENSPEC:END',
'',
'alias ls="ls -G"',
].join('\n');
await fs.writeFile(bashrcPath, userContent);
const result = await installer.configureBashrc(completionsDir);
expect(result).toBe(true);
const content = await fs.readFile(bashrcPath, 'utf-8');
expect(content).toContain('# My bash config');
expect(content).toContain('export PATH="/custom/path:$PATH"');
expect(content).toContain('alias ls="ls -G"');
expect(content).toContain(completionsDir);
expect(content).not.toContain('# Old OpenSpec config');
});
it('should return false when OPENSPEC_NO_AUTO_CONFIG is set', async () => {
const originalEnv = process.env.OPENSPEC_NO_AUTO_CONFIG;
process.env.OPENSPEC_NO_AUTO_CONFIG = '1';
const result = await installer.configureBashrc(completionsDir);
expect(result).toBe(false);
const bashrcPath = path.join(testHomeDir, '.bashrc');
const exists = await fs.access(bashrcPath).then(() => true).catch(() => false);
expect(exists).toBe(false);
// Restore env
if (originalEnv === undefined) {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
} else {
process.env.OPENSPEC_NO_AUTO_CONFIG = originalEnv;
}
});
it('should handle write permission errors gracefully', async () => {
// Create installer with path that can't be written
// Use a path that will fail on both Unix and Windows
const invalidPath = process.platform === 'win32'
? 'Z:\\nonexistent\\invalid\\path' // Non-existent drive letter on Windows
: '/root/invalid/path'; // Permission-denied path on Unix
const invalidInstaller = new BashInstaller(invalidPath);
const result = await invalidInstaller.configureBashrc(completionsDir);
expect(result).toBe(false);
});
});
describe('removeBashrcConfig', () => {
it('should return true when .bashrc does not exist', async () => {
const result = await installer.removeBashrcConfig();
expect(result).toBe(true);
});
it('should return true when .bashrc exists but has no markers', async () => {
const bashrcPath = path.join(testHomeDir, '.bashrc');
await fs.writeFile(bashrcPath, '# My custom config\nalias ll="ls -la"\n');
const result = await installer.removeBashrcConfig();
expect(result).toBe(true);
// Content should be unchanged
const content = await fs.readFile(bashrcPath, 'utf-8');
expect(content).toBe('# My custom config\nalias ll="ls -la"\n');
});
it('should remove markers and config when present', async () => {
const bashrcPath = path.join(testHomeDir, '.bashrc');
const content = [
'# My config',
'',
'# OPENSPEC:START',
'# OpenSpec shell completions configuration',
'if [ -d ~/.local/share/bash-completion/completions ]; then',
' . ~/.local/share/bash-completion/completions/openspec',
'fi',
'# OPENSPEC:END',
'',
'alias ll="ls -la"',
].join('\n');
await fs.writeFile(bashrcPath, content);
const result = await installer.removeBashrcConfig();
expect(result).toBe(true);
const newContent = await fs.readFile(bashrcPath, 'utf-8');
expect(newContent).not.toContain('# OPENSPEC:START');
expect(newContent).not.toContain('# OPENSPEC:END');
expect(newContent).not.toContain('OpenSpec shell completions configuration');
expect(newContent).toContain('# My config');
expect(newContent).toContain('alias ll="ls -la"');
});
it('should preserve user content when removing markers', async () => {
const bashrcPath = path.join(testHomeDir, '.bashrc');
const content = [
'export PATH="/custom:$PATH"',
'',
'# OPENSPEC:START',
'# Config',
'# OPENSPEC:END',
'',
'alias g="git"',
].join('\n');
await fs.writeFile(bashrcPath, content);
const result = await installer.removeBashrcConfig();
expect(result).toBe(true);
const newContent = await fs.readFile(bashrcPath, 'utf-8');
expect(newContent).toContain('export PATH="/custom:$PATH"');
expect(newContent).toContain('alias g="git"');
expect(newContent).not.toContain('# OPENSPEC:START');
});
it('should handle permission errors gracefully', async () => {
const invalidInstaller = new BashInstaller('/root/invalid/path');
const result = await invalidInstaller.removeBashrcConfig();
expect(result).toBe(true);
});
});
describe('constructor', () => {
it('should use provided home directory', () => {
const customInstaller = new BashInstaller('/custom/home');
expect(customInstaller).toBeDefined();
});
it('should use os.homedir() by default', () => {
const defaultInstaller = new BashInstaller();
expect(defaultInstaller).toBeDefined();
});
});
});
@@ -1,321 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { FishInstaller } from '../../../../src/core/completions/installers/fish-installer.js';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { randomUUID } from 'crypto';
describe('FishInstaller', () => {
let testHomeDir: string;
let installer: FishInstaller;
beforeEach(async () => {
testHomeDir = path.join(os.tmpdir(), `openspec-fish-test-${randomUUID()}`);
await fs.mkdir(testHomeDir, { recursive: true });
installer = new FishInstaller(testHomeDir);
});
afterEach(async () => {
await fs.rm(testHomeDir, { recursive: true, force: true });
});
describe('getInstallationPath', () => {
it('should return standard fish completions path', () => {
const result = installer.getInstallationPath();
expect(result).toBe(path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish'));
});
it('should use homeDir from constructor', () => {
const customHome = '/custom/home';
const customInstaller = new FishInstaller(customHome);
const result = customInstaller.getInstallationPath();
expect(result).toBe(path.join(customHome, '.config', 'fish', 'completions', 'openspec.fish'));
});
});
describe('backupExistingFile', () => {
it('should return undefined when file does not exist', async () => {
const nonExistentPath = path.join(testHomeDir, 'does-not-exist.fish');
const backupPath = await installer.backupExistingFile(nonExistentPath);
expect(backupPath).toBeUndefined();
});
it('should create backup with timestamp in filename', async () => {
const filePath = path.join(testHomeDir, 'test.fish');
await fs.writeFile(filePath, 'test content');
const backupPath = await installer.backupExistingFile(filePath);
expect(backupPath).toBeDefined();
expect(backupPath).toMatch(/\.backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}/);
});
it('should copy file content to backup', async () => {
const filePath = path.join(testHomeDir, 'test.fish');
const originalContent = '# Original fish completion script\nfunction test_func\nend';
await fs.writeFile(filePath, originalContent);
const backupPath = await installer.backupExistingFile(filePath);
expect(backupPath).toBeDefined();
const backupContent = await fs.readFile(backupPath!, 'utf-8');
expect(backupContent).toBe(originalContent);
});
it('should create backup next to original file', async () => {
const filePath = path.join(testHomeDir, 'subdir', 'test.fish');
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, 'content');
const backupPath = await installer.backupExistingFile(filePath);
expect(backupPath).toBeDefined();
expect(path.dirname(backupPath!)).toBe(path.dirname(filePath));
});
});
describe('install', () => {
const mockCompletionScript = `# Fish completion script for OpenSpec CLI
function __fish_openspec
echo "test"
end
complete -c openspec -a 'init' -d 'Initialize OpenSpec'
`;
it('should install completion script for the first time', async () => {
const result = await installer.install(mockCompletionScript);
expect(result.success).toBe(true);
expect(result.message).toBe('Completion script installed successfully for Fish');
expect(result.installedPath).toBe(path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish'));
expect(result.backupPath).toBeUndefined();
expect(result.instructions).toHaveLength(2);
expect(result.instructions![0]).toContain('Fish automatically loads completions');
expect(result.instructions![1]).toContain('Completions are available immediately');
});
it('should create parent directories if they do not exist', async () => {
const result = await installer.install(mockCompletionScript);
expect(result.success).toBe(true);
const targetPath = path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish');
const dirExists = await fs.access(path.dirname(targetPath)).then(() => true).catch(() => false);
expect(dirExists).toBe(true);
});
it('should write completion script content correctly', async () => {
await installer.install(mockCompletionScript);
const targetPath = path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish');
const content = await fs.readFile(targetPath, 'utf-8');
expect(content).toBe(mockCompletionScript);
});
it('should detect when already installed with same content', async () => {
// First installation
await installer.install(mockCompletionScript);
// Second installation with same content
const result = await installer.install(mockCompletionScript);
expect(result.success).toBe(true);
expect(result.message).toBe('Completion script is already installed (up to date)');
expect(result.instructions![0]).toContain('already installed and up to date');
expect(result.backupPath).toBeUndefined();
});
it('should update when content is different', async () => {
// Initial installation
await installer.install(mockCompletionScript);
// Update with different content
const updatedScript = `# Fish completion script for OpenSpec CLI
function __fish_openspec_new
echo "updated"
end
complete -c openspec -a 'init' -d 'Initialize OpenSpec'
complete -c openspec -a 'validate' -d 'Validate specs'
`;
const result = await installer.install(updatedScript);
expect(result.success).toBe(true);
expect(result.message).toContain('updated successfully');
expect(result.backupPath).toBeDefined();
expect(result.backupPath).toMatch(/\.backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}/);
});
it('should create backup when updating existing installation', async () => {
const originalScript = mockCompletionScript;
await installer.install(originalScript);
const updatedScript = originalScript + '\n# Updated version';
const result = await installer.install(updatedScript);
expect(result.success).toBe(true);
expect(result.backupPath).toBeDefined();
// Verify backup contains original content
const backupContent = await fs.readFile(result.backupPath!, 'utf-8');
expect(backupContent).toBe(originalScript);
// Verify current file has updated content
const targetPath = path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish');
const currentContent = await fs.readFile(targetPath, 'utf-8');
expect(currentContent).toBe(updatedScript);
});
it('should include backup path in message when updating', async () => {
await installer.install(mockCompletionScript);
const updatedScript = mockCompletionScript + '\n# Updated';
const result = await installer.install(updatedScript);
expect(result.success).toBe(true);
expect(result.message).toBe('Completion script updated successfully (previous version backed up)');
expect(result.backupPath).toBeDefined();
});
it('should handle installation with paths containing spaces', async () => {
const spacedHomeDir = path.join(os.tmpdir(), `openspec fish test ${randomUUID()}`);
await fs.mkdir(spacedHomeDir, { recursive: true });
const spacedInstaller = new FishInstaller(spacedHomeDir);
const result = await spacedInstaller.install(mockCompletionScript);
expect(result.success).toBe(true);
expect(result.installedPath).toContain('openspec fish test');
// Cleanup
await fs.rm(spacedHomeDir, { recursive: true, force: true });
});
// Skip on Windows: fs.chmod() on directories doesn't restrict write access on Windows
// Windows uses ACLs which Node.js chmod doesn't control
it.skipIf(process.platform === 'win32')('should return failure on permission error', async () => {
// Create a read-only directory to simulate permission error
const restrictedDir = path.join(testHomeDir, '.config', 'fish', 'completions');
await fs.mkdir(restrictedDir, { recursive: true });
await fs.chmod(restrictedDir, 0o444); // Read-only
const result = await installer.install(mockCompletionScript);
// Cleanup - restore permissions before asserting
await fs.chmod(restrictedDir, 0o755);
expect(result.success).toBe(false);
expect(result.message).toContain('Failed to install completion script');
});
it('should provide appropriate instructions for Fish', async () => {
const result = await installer.install(mockCompletionScript);
expect(result.success).toBe(true);
expect(result.instructions).toBeDefined();
expect(result.instructions).toHaveLength(2);
expect(result.instructions![0]).toContain('~/.config/fish/completions/');
expect(result.instructions![1]).toContain('no shell restart needed');
});
it('should handle empty completion script', async () => {
const result = await installer.install('');
expect(result.success).toBe(true);
const targetPath = path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish');
const content = await fs.readFile(targetPath, 'utf-8');
expect(content).toBe('');
});
it('should handle completion script with special characters', async () => {
const specialScript = `# Fish completion script with special chars: ' " \` $ \\
function __fish_openspec
echo "test's \\"quoted\\" text"
end
`;
const result = await installer.install(specialScript);
expect(result.success).toBe(true);
const targetPath = path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish');
const content = await fs.readFile(targetPath, 'utf-8');
expect(content).toBe(specialScript);
});
});
describe('uninstall', () => {
const mockCompletionScript = `# Fish completion script
complete -c openspec -a 'init'
`;
it('should successfully uninstall when completion script exists', async () => {
// First install
await installer.install(mockCompletionScript);
// Then uninstall
const result = await installer.uninstall();
expect(result.success).toBe(true);
expect(result.message).toBe('Completion script uninstalled successfully');
});
it('should remove the completion file', async () => {
await installer.install(mockCompletionScript);
const targetPath = path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish');
await installer.uninstall();
const fileExists = await fs.access(targetPath).then(() => true).catch(() => false);
expect(fileExists).toBe(false);
});
it('should return failure when completion script is not installed', async () => {
const result = await installer.uninstall();
expect(result.success).toBe(false);
expect(result.message).toBe('Completion script is not installed');
});
it('should accept yes option parameter', async () => {
await installer.install(mockCompletionScript);
const result = await installer.uninstall({ yes: true });
expect(result.success).toBe(true);
expect(result.message).toBe('Completion script uninstalled successfully');
});
// Skip on Windows: fs.chmod() on directories doesn't restrict write access on Windows
// Windows uses ACLs which Node.js chmod doesn't control
it.skipIf(process.platform === 'win32')('should return failure on permission error', async () => {
await installer.install(mockCompletionScript);
const targetPath = path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish');
const parentDir = path.dirname(targetPath);
// Make parent directory read-only to simulate permission error
await fs.chmod(parentDir, 0o444);
const result = await installer.uninstall();
// Restore permissions for cleanup
await fs.chmod(parentDir, 0o755);
// On some systems, the access check fails with permission error
// which returns "not installed" rather than "failed to uninstall"
expect(result.success).toBe(false);
expect(
result.message === 'Completion script is not installed' ||
result.message.includes('Failed to uninstall completion script')
).toBe(true);
});
it('should handle uninstall when parent directory does not exist', async () => {
// Don't install anything, so directory doesn't exist
const result = await installer.uninstall();
expect(result.success).toBe(false);
expect(result.message).toBe('Completion script is not installed');
});
});
});
@@ -1,657 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { PowerShellInstaller } from '../../../../src/core/completions/installers/powershell-installer.js';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { randomUUID } from 'crypto';
describe('PowerShellInstaller', () => {
let testHomeDir: string;
let installer: PowerShellInstaller;
let originalPlatform: NodeJS.Platform;
let originalEnv: NodeJS.ProcessEnv;
beforeEach(async () => {
testHomeDir = path.join(os.tmpdir(), `openspec-powershell-test-${randomUUID()}`);
await fs.mkdir(testHomeDir, { recursive: true });
installer = new PowerShellInstaller(testHomeDir);
originalPlatform = process.platform;
originalEnv = { ...process.env };
});
afterEach(async () => {
await fs.rm(testHomeDir, { recursive: true, force: true });
// Restore platform and environment
Object.defineProperty(process, 'platform', {
value: originalPlatform,
});
process.env = originalEnv;
});
describe('getProfilePath', () => {
it('should prefer PROFILE environment variable when set', () => {
process.env.PROFILE = '/custom/profile/path.ps1';
const result = installer.getProfilePath();
expect(result).toBe('/custom/profile/path.ps1');
});
it('should return Windows default path when on win32 platform', () => {
delete process.env.PROFILE;
Object.defineProperty(process, 'platform', {
value: 'win32',
});
const result = installer.getProfilePath();
expect(result).toBe(path.join(testHomeDir, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1'));
});
it('should return Unix default path when on darwin platform', () => {
delete process.env.PROFILE;
Object.defineProperty(process, 'platform', {
value: 'darwin',
});
const result = installer.getProfilePath();
expect(result).toBe(path.join(testHomeDir, '.config', 'powershell', 'Microsoft.PowerShell_profile.ps1'));
});
it('should return Unix default path when on linux platform', () => {
delete process.env.PROFILE;
Object.defineProperty(process, 'platform', {
value: 'linux',
});
const result = installer.getProfilePath();
expect(result).toBe(path.join(testHomeDir, '.config', 'powershell', 'Microsoft.PowerShell_profile.ps1'));
});
});
describe('getInstallationPath', () => {
it('should return path relative to profile directory', () => {
delete process.env.PROFILE;
Object.defineProperty(process, 'platform', {
value: 'darwin',
});
const result = installer.getInstallationPath();
expect(result).toBe(path.join(testHomeDir, '.config', 'powershell', 'OpenSpecCompletion.ps1'));
});
it('should work with custom PROFILE environment variable', () => {
process.env.PROFILE = path.join(testHomeDir, 'custom', 'profile.ps1');
const result = installer.getInstallationPath();
expect(result).toBe(path.join(testHomeDir, 'custom', 'OpenSpecCompletion.ps1'));
});
it('should return Windows path when on Windows platform', () => {
delete process.env.PROFILE;
Object.defineProperty(process, 'platform', {
value: 'win32',
});
const result = installer.getInstallationPath();
expect(result).toBe(path.join(testHomeDir, 'Documents', 'PowerShell', 'OpenSpecCompletion.ps1'));
});
});
describe('backupExistingFile', () => {
it('should return undefined when file does not exist', async () => {
const nonExistentPath = path.join(testHomeDir, 'does-not-exist.ps1');
const backupPath = await installer.backupExistingFile(nonExistentPath);
expect(backupPath).toBeUndefined();
});
it('should create backup with timestamp in filename', async () => {
const filePath = path.join(testHomeDir, 'test.ps1');
await fs.writeFile(filePath, 'test content');
const backupPath = await installer.backupExistingFile(filePath);
expect(backupPath).toBeDefined();
expect(backupPath).toMatch(/\.backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}/);
});
it('should copy file content to backup', async () => {
const filePath = path.join(testHomeDir, 'test.ps1');
const originalContent = '# Original PowerShell completion script\n$completer = {}';
await fs.writeFile(filePath, originalContent);
const backupPath = await installer.backupExistingFile(filePath);
expect(backupPath).toBeDefined();
const backupContent = await fs.readFile(backupPath!, 'utf-8');
expect(backupContent).toBe(originalContent);
});
it('should create backup next to original file', async () => {
const filePath = path.join(testHomeDir, 'subdir', 'test.ps1');
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, 'content');
const backupPath = await installer.backupExistingFile(filePath);
expect(backupPath).toBeDefined();
expect(path.dirname(backupPath!)).toBe(path.dirname(filePath));
});
});
describe('configureProfile', () => {
const mockScriptPath = '/path/to/OpenSpecCompletion.ps1';
// Note: OPENSPEC_NO_AUTO_CONFIG check is now handled in the install() method,
// not in configureProfile() itself
it('should create profile with markers when file does not exist', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
const profilePath = installer.getProfilePath();
const result = await installer.configureProfile(mockScriptPath);
expect(result).toBe(true);
const content = await fs.readFile(profilePath, 'utf-8');
expect(content).toContain('# OPENSPEC:START');
expect(content).toContain('# OPENSPEC:END');
expect(content).toContain(`. "${mockScriptPath}"`);
});
it('should prepend markers and config when file exists without markers', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
const profilePath = installer.getProfilePath();
await fs.mkdir(path.dirname(profilePath), { recursive: true });
await fs.writeFile(profilePath, '# My custom PowerShell config\nWrite-Host "Hello"');
const result = await installer.configureProfile(mockScriptPath);
expect(result).toBe(true);
const content = await fs.readFile(profilePath, 'utf-8');
expect(content).toContain('# OPENSPEC:START');
expect(content).toContain('# OPENSPEC:END');
expect(content).toContain(mockScriptPath);
expect(content).toContain('# My custom PowerShell config');
expect(content).toContain('Write-Host "Hello"');
});
// Skip on Windows: Windows has dual profile paths (PowerShell Core + Windows PowerShell 5.1),
// so even if one profile is already configured, the second one will be configured and return true
it.skipIf(process.platform === 'win32')('should skip configuration when script line already exists', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
const profilePath = installer.getProfilePath();
await fs.mkdir(path.dirname(profilePath), { recursive: true });
const initialContent = [
'# OPENSPEC:START - OpenSpec completion (managed block, do not edit manually)',
`. "${mockScriptPath}"`,
'# OPENSPEC:END',
'',
'# My custom config',
'Write-Host "Custom"',
].join('\n');
await fs.writeFile(profilePath, initialContent);
const result = await installer.configureProfile(mockScriptPath);
// Should return false because already configured (anyConfigured = false)
expect(result).toBe(false);
const content = await fs.readFile(profilePath, 'utf-8');
// Content should be unchanged
expect(content).toBe(initialContent);
});
it('should preserve user content outside markers', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
const profilePath = installer.getProfilePath();
await fs.mkdir(path.dirname(profilePath), { recursive: true });
const initialContent = [
'# User config before',
'Set-Variable -Name "test" -Value "before"',
'',
'# OPENSPEC:START',
'# Old config',
'# OPENSPEC:END',
'',
'# User config after',
'Set-Variable -Name "test" -Value "after"',
].join('\n');
await fs.writeFile(profilePath, initialContent);
const result = await installer.configureProfile(mockScriptPath);
expect(result).toBe(true);
const content = await fs.readFile(profilePath, 'utf-8');
expect(content).toContain('# User config before');
expect(content).toContain('Set-Variable -Name "test" -Value "before"');
expect(content).toContain('# User config after');
expect(content).toContain('Set-Variable -Name "test" -Value "after"');
});
it('should generate correct PowerShell syntax in config', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
const profilePath = installer.getProfilePath();
await installer.configureProfile(mockScriptPath);
const content = await fs.readFile(profilePath, 'utf-8');
expect(content).toContain('# OPENSPEC:START');
expect(content).toContain(`. "${mockScriptPath}"`);
expect(content).toContain('# OPENSPEC:END');
});
// Skip on Windows: fs.chmod() doesn't reliably restrict write access on Windows
// (admin users can bypass read-only attribute, and CI runners often have elevated privileges)
it.skipIf(process.platform === 'win32')('should return false on write permission error', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
const profilePath = installer.getProfilePath();
await fs.mkdir(path.dirname(profilePath), { recursive: true });
await fs.writeFile(profilePath, '# Test');
// Make file read-only
await fs.chmod(profilePath, 0o444);
const result = await installer.configureProfile(mockScriptPath);
// Restore permissions for cleanup
await fs.chmod(profilePath, 0o644);
expect(result).toBe(false);
});
});
describe('removeProfileConfig', () => {
it('should return false when profile does not exist', async () => {
const result = await installer.removeProfileConfig();
expect(result).toBe(false);
});
it('should return false when profile exists but has no markers', async () => {
const profilePath = installer.getProfilePath();
await fs.mkdir(path.dirname(profilePath), { recursive: true });
await fs.writeFile(profilePath, '# My custom config\nWrite-Host "Hello"');
const result = await installer.removeProfileConfig();
expect(result).toBe(false);
const content = await fs.readFile(profilePath, 'utf-8');
expect(content).toBe('# My custom config\nWrite-Host "Hello"');
});
it('should remove content between markers', async () => {
const profilePath = installer.getProfilePath();
await fs.mkdir(path.dirname(profilePath), { recursive: true });
const initialContent = [
'# OPENSPEC:START',
'# OpenSpec completions',
'if (Test-Path "/path") {',
' . "/path"',
'}',
'# OPENSPEC:END',
'',
'# My config',
].join('\n');
await fs.writeFile(profilePath, initialContent);
const result = await installer.removeProfileConfig();
expect(result).toBe(true);
const content = await fs.readFile(profilePath, 'utf-8');
expect(content).not.toContain('# OPENSPEC:START');
expect(content).not.toContain('# OPENSPEC:END');
expect(content).not.toContain('# OpenSpec completions');
expect(content).toContain('# My config');
});
it('should remove trailing empty lines after removal', async () => {
const profilePath = installer.getProfilePath();
await fs.mkdir(path.dirname(profilePath), { recursive: true });
const initialContent = [
'# User config',
'# OPENSPEC:START',
'# Config',
'# OPENSPEC:END',
'',
'',
].join('\n');
await fs.writeFile(profilePath, initialContent);
const result = await installer.removeProfileConfig();
expect(result).toBe(true);
const content = await fs.readFile(profilePath, 'utf-8');
expect(content).toBe('# User config\n');
});
it('should preserve user content outside markers', async () => {
const profilePath = installer.getProfilePath();
await fs.mkdir(path.dirname(profilePath), { recursive: true });
const initialContent = [
'# Before',
'# OPENSPEC:START',
'# OpenSpec',
'# OPENSPEC:END',
'# After',
].join('\n');
await fs.writeFile(profilePath, initialContent);
const result = await installer.removeProfileConfig();
expect(result).toBe(true);
const content = await fs.readFile(profilePath, 'utf-8');
expect(content).toContain('# Before');
expect(content).toContain('# After');
});
it('should return false on invalid marker placement', async () => {
const profilePath = installer.getProfilePath();
await fs.mkdir(path.dirname(profilePath), { recursive: true });
const initialContent = [
'# OPENSPEC:END',
'# Config',
'# OPENSPEC:START',
].join('\n');
await fs.writeFile(profilePath, initialContent);
const result = await installer.removeProfileConfig();
expect(result).toBe(false);
});
});
describe('install', () => {
const mockCompletionScript = `# PowerShell completion script for OpenSpec
$openspecCompleter = {
param($wordToComplete, $commandAst, $cursorPosition)
# Completion logic here
}
Register-ArgumentCompleter -CommandName openspec -ScriptBlock $openspecCompleter
`;
it('should install completion script for the first time', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
const result = await installer.install(mockCompletionScript);
expect(result.success).toBe(true);
expect(result.message).toContain('installed');
expect(result.installedPath).toContain('OpenSpecCompletion.ps1');
expect(result.backupPath).toBeUndefined();
});
it('should create parent directories if they do not exist', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
const result = await installer.install(mockCompletionScript);
expect(result.success).toBe(true);
const targetPath = installer.getInstallationPath();
const fileExists = await fs.access(targetPath).then(() => true).catch(() => false);
expect(fileExists).toBe(true);
});
it('should write completion script content correctly', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
await installer.install(mockCompletionScript);
const targetPath = installer.getInstallationPath();
const content = await fs.readFile(targetPath, 'utf-8');
expect(content).toBe(mockCompletionScript);
});
it('should detect when already installed with same content', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
await installer.install(mockCompletionScript);
const result = await installer.install(mockCompletionScript);
expect(result.success).toBe(true);
expect(result.message).toBe('Completion script is already installed (up to date)');
expect(result.backupPath).toBeUndefined();
});
it('should update when content is different', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
await installer.install(mockCompletionScript);
const updatedScript = mockCompletionScript + '\n# Updated version';
const result = await installer.install(updatedScript);
expect(result.success).toBe(true);
expect(result.message).toContain('updated successfully');
expect(result.backupPath).toBeDefined();
});
it('should create backup when updating existing installation', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
await installer.install(mockCompletionScript);
const updatedScript = mockCompletionScript + '\n# Updated';
const result = await installer.install(updatedScript);
expect(result.success).toBe(true);
expect(result.backupPath).toBeDefined();
// Verify backup contains original content
const backupContent = await fs.readFile(result.backupPath!, 'utf-8');
expect(backupContent).toBe(mockCompletionScript);
});
it('should configure PowerShell profile when not disabled', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
const result = await installer.install(mockCompletionScript);
expect(result.success).toBe(true);
expect(result.profileConfigured).toBe(true);
expect(result.message).toContain('profile configured');
expect(result.instructions).toBeUndefined();
});
// Note: OPENSPEC_NO_AUTO_CONFIG support was removed from PowerShell installer
// Profile is now always auto-configured if possible
// Skip on Windows: fs.chmod() doesn't reliably restrict write access on Windows
// (admin users can bypass read-only attribute, and CI runners often have elevated privileges)
it.skipIf(process.platform === 'win32')('should provide instructions when profile cannot be configured', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
// Make profile directory read-only to prevent configuration
const profilePath = installer.getProfilePath();
await fs.mkdir(path.dirname(profilePath), { recursive: true });
await fs.writeFile(profilePath, '# Test');
await fs.chmod(profilePath, 0o444);
const result = await installer.install(mockCompletionScript);
// Restore permissions
await fs.chmod(profilePath, 0o644);
expect(result.success).toBe(true);
expect(result.profileConfigured).toBe(false);
expect(result.instructions).toBeDefined();
expect(result.instructions!.some(i => i.includes('Test-Path'))).toBe(true);
});
it('should include backup path in message when updating', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
await installer.install(mockCompletionScript);
const updatedScript = mockCompletionScript + '\n# Updated';
const result = await installer.install(updatedScript);
expect(result.success).toBe(true);
expect(result.message).toContain('backed up');
expect(result.backupPath).toBeDefined();
});
it('should handle installation with paths containing spaces', async () => {
const spacedHomeDir = path.join(os.tmpdir(), `openspec powershell test ${randomUUID()}`);
await fs.mkdir(spacedHomeDir, { recursive: true });
const spacedInstaller = new PowerShellInstaller(spacedHomeDir);
const result = await spacedInstaller.install(mockCompletionScript);
expect(result.success).toBe(true);
expect(result.installedPath).toContain('openspec powershell test');
// Cleanup
await fs.rm(spacedHomeDir, { recursive: true, force: true });
});
// Skip on Windows: fs.chmod() on directories doesn't restrict write access on Windows
// Windows uses ACLs which Node.js chmod doesn't control
it.skipIf(process.platform === 'win32')('should return failure on permission error', async () => {
const targetPath = installer.getInstallationPath();
const targetDir = path.dirname(targetPath);
await fs.mkdir(targetDir, { recursive: true });
// Make target directory read-only to simulate permission error
await fs.chmod(targetDir, 0o444);
const result = await installer.install(mockCompletionScript);
// Restore permissions for cleanup
await fs.chmod(targetDir, 0o755);
expect(result.success).toBe(false);
expect(result.message).toContain('Failed to install completion script');
});
it('should handle empty completion script', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
const result = await installer.install('');
expect(result.success).toBe(true);
const targetPath = installer.getInstallationPath();
const content = await fs.readFile(targetPath, 'utf-8');
expect(content).toBe('');
});
it('should handle completion script with special characters', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
const specialScript = `# PowerShell with special chars: ' " \` $ @\n$test = "value"`;
const result = await installer.install(specialScript);
expect(result.success).toBe(true);
const targetPath = installer.getInstallationPath();
const content = await fs.readFile(targetPath, 'utf-8');
expect(content).toBe(specialScript);
});
});
describe('uninstall', () => {
const mockCompletionScript = `# PowerShell completion script
$openspecCompleter = {}
Register-ArgumentCompleter -CommandName openspec -ScriptBlock $openspecCompleter
`;
it('should successfully uninstall when completion script exists', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
await installer.install(mockCompletionScript);
const result = await installer.uninstall();
expect(result.success).toBe(true);
expect(result.message).toBe('Completion script uninstalled successfully');
});
it('should remove the completion file', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
await installer.install(mockCompletionScript);
const targetPath = installer.getInstallationPath();
await installer.uninstall();
const fileExists = await fs.access(targetPath).then(() => true).catch(() => false);
expect(fileExists).toBe(false);
});
it('should remove profile configuration', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
await installer.install(mockCompletionScript);
const profilePath = installer.getProfilePath();
await installer.uninstall();
const content = await fs.readFile(profilePath, 'utf-8');
expect(content).not.toContain('# OPENSPEC:START');
expect(content).not.toContain('# OPENSPEC:END');
});
it('should return failure when completion script is not installed', async () => {
const result = await installer.uninstall();
expect(result.success).toBe(false);
expect(result.message).toBe('Completion script is not installed');
});
it('should accept yes option parameter', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
await installer.install(mockCompletionScript);
const result = await installer.uninstall({ yes: true });
expect(result.success).toBe(true);
expect(result.message).toBe('Completion script uninstalled successfully');
});
it('should handle both script and config removal', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
await installer.install(mockCompletionScript);
const targetPath = installer.getInstallationPath();
const profilePath = installer.getProfilePath();
// Verify both exist
const scriptExists = await fs.access(targetPath).then(() => true).catch(() => false);
const profileContent = await fs.readFile(profilePath, 'utf-8');
expect(scriptExists).toBe(true);
expect(profileContent).toContain('# OPENSPEC:START');
await installer.uninstall();
// Verify both are removed/cleaned
const scriptExistsAfter = await fs.access(targetPath).then(() => true).catch(() => false);
const profileContentAfter = await fs.readFile(profilePath, 'utf-8');
expect(scriptExistsAfter).toBe(false);
expect(profileContentAfter).not.toContain('# OPENSPEC:START');
});
// Skip on Windows: fs.chmod() on directories doesn't restrict write access on Windows
// Windows uses ACLs which Node.js chmod doesn't control
it.skipIf(process.platform === 'win32')('should return failure on permission error', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
await installer.install(mockCompletionScript);
const targetPath = installer.getInstallationPath();
const parentDir = path.dirname(targetPath);
// Make parent directory read-only
await fs.chmod(parentDir, 0o444);
const result = await installer.uninstall();
// Restore permissions
await fs.chmod(parentDir, 0o755);
// On some systems, the access check fails which returns "not installed"
// On others, the unlink fails which returns "Failed to uninstall"
expect(result.success).toBe(false);
expect(
result.message === 'Completion script is not installed' ||
result.message.includes('Failed to uninstall completion script')
).toBe(true);
});
it('should handle uninstall when parent directory does not exist', async () => {
const result = await installer.uninstall();
expect(result.success).toBe(false);
expect(result.message).toBe('Completion script is not installed');
});
});
});
+4 -4
View File
@@ -1121,21 +1121,21 @@ describe('InitCommand', () => {
const proposalContent = await fs.readFile(codeBuddyProposal, 'utf-8');
expect(proposalContent).toContain('---');
expect(proposalContent).toContain('name: OpenSpec: Proposal');
expect(proposalContent).toContain('description: "Scaffold a new OpenSpec change and validate strictly."');
expect(proposalContent).toContain('argument-hint: "[feature description or request]"');
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
expect(proposalContent).toContain('category: OpenSpec');
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
expect(proposalContent).toContain('**Guardrails**');
const applyContent = await fs.readFile(codeBuddyApply, 'utf-8');
expect(applyContent).toContain('---');
expect(applyContent).toContain('name: OpenSpec: Apply');
expect(applyContent).toContain('description: "Implement an approved OpenSpec change and keep tasks in sync."');
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(codeBuddyArchive, 'utf-8');
expect(archiveContent).toContain('---');
expect(archiveContent).toContain('name: OpenSpec: Archive');
expect(archiveContent).toContain('description: "Archive a deployed OpenSpec change and update specs."');
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
expect(archiveContent).toContain('openspec archive <id> --yes');
});
+4 -4
View File
@@ -115,19 +115,19 @@ Regular text that should be ignored
expect(logOutput.some(line => line.includes('no-tasks') && line.includes('No tasks'))).toBe(true);
});
it('should sort changes alphabetically when sort=name', async () => {
it('should sort changes alphabetically', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(path.join(changesDir, 'zebra'), { recursive: true });
await fs.mkdir(path.join(changesDir, 'alpha'), { recursive: true });
await fs.mkdir(path.join(changesDir, 'middle'), { recursive: true });
const listCommand = new ListCommand();
await listCommand.execute(tempDir, 'changes', { sort: 'name' });
await listCommand.execute(tempDir);
const changeLines = logOutput.filter(line =>
const changeLines = logOutput.filter(line =>
line.includes('alpha') || line.includes('middle') || line.includes('zebra')
);
expect(changeLines[0]).toContain('alpha');
expect(changeLines[1]).toContain('middle');
expect(changeLines[2]).toContain('zebra');
-224
View File
@@ -1,224 +0,0 @@
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 {
writeChangeMetadata,
readChangeMetadata,
resolveSchemaForChange,
validateSchemaName,
ChangeMetadataError,
} from '../../src/utils/change-metadata.js';
import { ChangeMetadataSchema } from '../../src/core/artifact-graph/types.js';
describe('ChangeMetadataSchema', () => {
describe('valid metadata', () => {
it('should accept valid schema with created date', () => {
const result = ChangeMetadataSchema.safeParse({
schema: 'spec-driven',
created: '2025-01-05',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.schema).toBe('spec-driven');
expect(result.data.created).toBe('2025-01-05');
}
});
it('should accept valid schema without created date', () => {
const result = ChangeMetadataSchema.safeParse({
schema: 'tdd',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.schema).toBe('tdd');
expect(result.data.created).toBeUndefined();
}
});
});
describe('invalid metadata', () => {
it('should reject empty schema', () => {
const result = ChangeMetadataSchema.safeParse({
schema: '',
});
expect(result.success).toBe(false);
});
it('should reject missing schema', () => {
const result = ChangeMetadataSchema.safeParse({
created: '2025-01-05',
});
expect(result.success).toBe(false);
});
it('should reject invalid date format', () => {
const result = ChangeMetadataSchema.safeParse({
schema: 'spec-driven',
created: '01/05/2025', // Wrong format
});
expect(result.success).toBe(false);
});
it('should reject non-ISO date format', () => {
const result = ChangeMetadataSchema.safeParse({
schema: 'spec-driven',
created: '2025-1-5', // Missing leading zeros
});
expect(result.success).toBe(false);
});
});
});
describe('writeChangeMetadata', () => {
let testDir: string;
let changeDir: string;
beforeEach(async () => {
testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);
changeDir = path.join(testDir, 'openspec', 'changes', 'test-change');
await fs.mkdir(changeDir, { recursive: true });
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
it('should write valid YAML metadata file', async () => {
writeChangeMetadata(changeDir, {
schema: 'spec-driven',
created: '2025-01-05',
});
const metaPath = path.join(changeDir, '.openspec.yaml');
const content = await fs.readFile(metaPath, 'utf-8');
expect(content).toContain('schema: spec-driven');
expect(content).toContain('created: 2025-01-05');
});
it('should throw error for unknown schema', () => {
expect(() =>
writeChangeMetadata(changeDir, {
schema: 'unknown-schema',
created: '2025-01-05',
})
).toThrow(/Unknown schema 'unknown-schema'/);
});
});
describe('readChangeMetadata', () => {
let testDir: string;
let changeDir: string;
beforeEach(async () => {
testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);
changeDir = path.join(testDir, 'openspec', 'changes', 'test-change');
await fs.mkdir(changeDir, { recursive: true });
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
it('should return null when no metadata file exists', () => {
const result = readChangeMetadata(changeDir);
expect(result).toBeNull();
});
it('should read valid metadata', async () => {
const metaPath = path.join(changeDir, '.openspec.yaml');
await fs.writeFile(
metaPath,
'schema: spec-driven\ncreated: "2025-01-05"\n',
'utf-8'
);
const result = readChangeMetadata(changeDir);
expect(result).toEqual({
schema: 'spec-driven',
created: '2025-01-05',
});
});
it('should throw ChangeMetadataError for invalid YAML', async () => {
const metaPath = path.join(changeDir, '.openspec.yaml');
await fs.writeFile(metaPath, '{ invalid yaml', 'utf-8');
expect(() => readChangeMetadata(changeDir)).toThrow(ChangeMetadataError);
});
it('should throw ChangeMetadataError for missing schema field', async () => {
const metaPath = path.join(changeDir, '.openspec.yaml');
await fs.writeFile(metaPath, 'created: "2025-01-05"\n', 'utf-8');
expect(() => readChangeMetadata(changeDir)).toThrow(ChangeMetadataError);
});
it('should throw ChangeMetadataError for unknown schema', async () => {
const metaPath = path.join(changeDir, '.openspec.yaml');
await fs.writeFile(metaPath, 'schema: unknown-schema\n', 'utf-8');
expect(() => readChangeMetadata(changeDir)).toThrow(/Unknown schema/);
});
});
describe('resolveSchemaForChange', () => {
let testDir: string;
let changeDir: string;
beforeEach(async () => {
testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);
changeDir = path.join(testDir, 'openspec', 'changes', 'test-change');
await fs.mkdir(changeDir, { recursive: true });
});
afterEach(async () => {
await fs.rm(testDir, { recursive: true, force: true });
});
it('should return explicit schema when provided', async () => {
// Even with metadata file, explicit schema wins
const metaPath = path.join(changeDir, '.openspec.yaml');
await fs.writeFile(metaPath, 'schema: spec-driven\n', 'utf-8');
const result = resolveSchemaForChange(changeDir, 'tdd');
expect(result).toBe('tdd');
});
it('should return schema from metadata when no explicit schema', async () => {
const metaPath = path.join(changeDir, '.openspec.yaml');
await fs.writeFile(metaPath, 'schema: spec-driven\n', 'utf-8');
const result = resolveSchemaForChange(changeDir);
expect(result).toBe('spec-driven');
});
it('should return default when no metadata and no explicit schema', () => {
const result = resolveSchemaForChange(changeDir);
expect(result).toBe('spec-driven');
});
it('should return default when metadata read fails', async () => {
// Create an invalid metadata file
const metaPath = path.join(changeDir, '.openspec.yaml');
await fs.writeFile(metaPath, '{ invalid yaml', 'utf-8');
// Should fall back to default, not throw
const result = resolveSchemaForChange(changeDir);
expect(result).toBe('spec-driven');
});
});
describe('validateSchemaName', () => {
it('should accept valid schema name', () => {
expect(() => validateSchemaName('spec-driven')).not.toThrow();
});
it('should throw for unknown schema', () => {
expect(() => validateSchemaName('unknown-schema')).toThrow(
/Unknown schema 'unknown-schema'/
);
});
});

Some files were not shown because too many files have changed in this diff Show More