Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale 1933ff8362 fix: set USERPROFILE for Windows compatibility in telemetry tests
On Windows, os.homedir() uses the USERPROFILE environment variable
instead of HOME. The telemetry config tests were only mocking HOME,
causing them to fail on Windows CI.
2026-01-09 23:35:00 -08:00
Tabish Bidiwale 940898c1c5 Add optional anonymous usage statistics (#468)
* chore: add proposal for PostHog analytics integration

Introduces the proposal artifact for adding opt-in telemetry to OpenSpec
using PostHog. Covers command tracking, feature adoption metrics, and
privacy-respecting consent management.

* feat: add optional anonymous usage statistics

Introduces privacy-first usage analytics to help understand how OpenSpec
is being used. Key privacy protections:

- Only tracks command names and version (no arguments, paths, or content)
- Opt-out via OPENSPEC_TELEMETRY=0 or DO_NOT_TRACK=1
- Auto-disabled in CI environments
- No IP address collection (explicitly disabled)
- Anonymous ID is a random UUID with no PII

Uses PostHog with a reverse proxy to avoid ad blockers. First-run shows
a one-line notice informing users about the collection.

* docs: make telemetry section collapsible and concise
2026-01-09 23:10:31 -08:00
Tabish Bidiwale d49a88c3bb feat: add /opsx:explore command for exploratory thinking (#467)
Wire up the explore skill and slash command templates to the
artifact-experimental-setup command. This adds /opsx:explore as
a thinking partner mode for exploring ideas, investigating problems,
and clarifying requirements before committing to a change.

Changes:
- Add imports for getExploreSkillTemplate and getOpsxExploreCommandTemplate
- Add explore skill to skills array (generates openspec-explore/SKILL.md)
- Add explore command to commands array (generates opsx/explore.md)
- Add /opsx:explore to CLI usage message
- Update docs/experimental-workflow.md with explore command
2026-01-09 20:15:26 -08:00
Tabish Bidiwale bb9f6ce0ea docs: add OPSX experimental workflow visibility to README (#460)
* docs: add OPSX experimental workflow visibility to README

Add a subtle banner near the top and an Experimental Features section
before Contributing to draw attention to the new OPSX workflow.

* docs: reframe OPSX messaging around fluid iteration

Update messaging to emphasize the core value proposition:
- No phases, just actions
- Dependencies are enablers, not gates
- Update artifacts as you learn during implementation

The previous "step-by-step artifact creation" framing incorrectly
suggested more bureaucracy. OPSX is about less rigidity, not more.

* docs: add architecture deep dive with ASCII diagrams

Add comprehensive comparison of standard vs OPSX workflow architecture:
- Philosophy: phases vs actions
- Component architecture diagrams
- Dependency graph model
- Information flow comparison
- Iteration model comparison
- Custom schema example

* docs: emphasize hackability and experimentation rationale

Add "Why We Built This" section explaining the meta-level motivation:
- Instructions were hardcoded, hard to improve
- Needed granular, testable artifacts
- Wanted to experiment with different workflows without code changes
- OPSX makes the instruction system itself hackable

Update README banner and experimental features section to highlight
schema-driven, hackable nature alongside fluid iteration benefits.

* docs: reframe hackability as user benefit, not just internal

OPSX isn't just for OpenSpec devs to experiment - it's for everyone:
- Teams can create workflows that match how they work
- Power users can tweak prompts to get better AI outputs
- Contributors can experiment without releases

Updated framing from "we needed" to "now anyone can".

* docs: add guidance on when to update vs. start fresh

Addresses a common question: when does "update as you learn" become
"this is different work"? Adds heuristics based on intent, scope
overlap, and completability to help users make the judgment call.
2026-01-09 20:09:53 -08:00
Tabish Bidiwale ae85a7229d fix: offer parent flags in Bash and PowerShell completions when subcommands exist (#466)
When a command has both flags and subcommands, the Bash and PowerShell
completion generators now check if the user is typing a flag (input
starts with `-`) before offering subcommand completions. This fixes the
issue where parent-level flags were never suggested.

Before: `openspec config --<TAB>` → Only showed subcommands
After: `openspec config --<TAB>` → Shows parent flags when input starts with `-`

Fixes #463
2026-01-09 19:40:13 -08:00
Tabish Bidiwale 504c93bdf1 fix: skip additional Windows-specific tests (#465)
* fix: skip additional Windows-specific tests

- fish-installer: skip uninstall permission test (chmod on directory)
- powershell-installer: skip "skip configuration when script line exists"
  test (Windows has dual profile paths so the second profile gets configured)

* refactor: use ENOTDIR approach for cross-platform install error tests

Instead of platform-specific invalid paths (Z:\ or /root), create a
temporary file and use it as homeDir. This guarantees deterministic
ENOTDIR failures when trying to create subdirectories on all platforms.
2026-01-09 16:26:26 -08:00
Tabish Bidiwale c4a54a8d54 fix: skip Windows-specific permission tests that rely on chmod() (#464)
fs.chmod() on directories doesn't restrict write access on Windows since
Windows uses ACLs that Node.js doesn't control. Additionally, admin users
and CI runners can bypass read-only attributes. Skip these tests on Windows
and use platform-specific invalid paths in cross-platform tests.

Fixes #401 (bash/pwsh completion commit breaking Windows e2e tests).
2026-01-09 16:06:15 -08:00
38d2356836 feature/bash_fish_power_shells_completions (#401)
* added CLI completions support for: bash, fish and powershell

* Add bash/fish/powershell completions

* Archive extend-shell-completions

* Archive extend-shell-completions

* Fix canWriteFile control flow and add tests

* Fix bash completion fallback and security escaping

  - Add _init_completion fallback for systems without bash-completion
  - Fix command injection escaping in Fish/PowerShell generators
  - Add Bash command name escaping for security
  - Add comprehensive security tests for all generators
  - Fix test placement issues in bash/powershell test files

* refactor: extract completion templates and standardize naming

Extract static template literals from generators into separate template files.
Standardize naming to {SHELL}_STATIC_HELPERS and {SHELL}_DYNAMIC_HELPERS.

- Create bash/fish/powershell/zsh template files
- Rename constants: BASH_HELPERS → BASH_DYNAMIC_HELPERS,
  FISH_HELPER_FUNCTIONS → FISH_STATIC_HELPERS,
  POWERSHELL_HELPERS → POWERSHELL_DYNAMIC_HELPERS
- Update generator imports
- Remove ~99 lines of boilerplate from generators

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* docs: update spec to reflect multi-shell support

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: add all shells to zsh completion suggestions

* feat: add --yes flag to completion uninstall

* fix: remove bash-completion dependency from fallback

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: use printf instead of echo for Fish tab output

Fish's echo doesn't interpret escape sequences, so \t outputs
literally instead of as a tab character. Use printf for proper
tab-separated completion output.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: make UX messages shell-aware

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: add Homebrew paths for bash-completion detection

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: support both PowerShell Core and Windows PS 5.1

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: preserve colon handling in bash completion

Add -n : option to _init_completion to prevent colons from being
treated as word separators. This is important for spec/change IDs
that may contain colons.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: update completion tests to match implementation changes

Updated bash-generator test to expect `-n :` flag in _init_completion call.
Updated powershell-installer tests to match refactored implementation that
supports both PowerShell Core and Windows PowerShell 5.1.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
2026-01-09 15:49:50 -08:00
Neroyangandneroyang 3f67debf65 feat: change the frontmatter of the Codebuddy Slash Commands (#462)
* feat: change the frontmatter of the Codebuddy Slash Commands

* fix: fix the issue mentioned by coderabbitai

* feat: change the init.test

* feat: change the init.test

---------

Co-authored-by: neroyang <neroyang@tencent.com>
2026-01-09 10:08:59 -08:00
github-actions[bot]andTabish Bidiwale 533cb0fa87 chore(release): version packages (#458)
* Version Packages

* chore: trigger CI

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tabish Bidiwale <tabishbidiwale@gmail.com>
2026-01-07 00:38:00 -08:00
Tabish Bidiwale 8dfd824477 Add changeset for OPSX experimental workflow commands (#457) 2026-01-07 00:35:04 -08:00
Tabish Bidiwale 3ed1270316 docs: add experimental workflow (OPSX) user guide (#456)
Adds documentation for the experimental artifact-based workflow:
- Setup instructions (Claude Code only for now)
- Command reference for all /opsx:* commands
- Usage examples and tips
- Comparison with standard workflow
- Feedback links to Discord and GitHub
2026-01-07 00:24:14 -08:00
Tabish Bidiwale eb15cdb983 chore: archive completed changes and clean up stale ones (#455)
Archive 5 completed changes:
- opsx-archive-command (synced specs)
- add-specs-apply-command
- add-per-change-schema-metadata
- make-apply-instructions-schema-aware (synced specs)
- add-agent-schema-selection

Delete 5 stale/abandoned changes:
- add-fingerprinting (no tasks, 7 weeks old)
- add-scaffold-command (0/7 tasks, 7 weeks old)
- add-proposal-frontmatter (no tasks, 8 weeks old)
- add-interactive-proposal-command (no tasks, 8 weeks old)
- make-validation-scope-aware (0/8 tasks, 4 months old)

Sync delta specs to main:
- Add opsx-archive-skill spec (new capability)
- Update cli-artifact-workflow spec with Schema Apply Block and
  Apply Instructions Command requirements
2026-01-06 23:52:11 -08:00
Tabish Bidiwale cd172a4427 feat: add smart sync check to /opsx:archive command (#452)
Instead of blindly asking "want to sync?", archive now performs a quick
check to see if delta specs actually need syncing:

- Extracts requirement names from delta specs
- Checks if corresponding main spec exists
- Checks if ADDED requirements appear in main spec
- Only prompts if sync appears needed

Also improves archive output to always show specs status:
- ✓ Synced to main specs
- No delta specs
- ⚠️ Not synced
2026-01-06 17:41:45 -08:00
Tabish Bidiwale b7f5a429de feat: add /opsx:archive command for archiving completed changes (#451)
Add `/opsx:archive` slash command to complete the OPSX workflow lifecycle.
This command archives completed changes in the experimental workflow with:

- Change selection prompt (if not specified)
- Artifact completion check using `openspec status --json`
- Task completion check (parsing tasks.md for `- [ ]`)
- Spec sync prompt (offers `/opsx:sync` before archiving if specs exist)
- Archive to `openspec/changes/archive/YYYY-MM-DD-<name>/`
- Clear output formatting for success, warnings, and errors

This completes the OPSX command suite:
- /opsx:new - Start a change
- /opsx:continue - Create next artifact
- /opsx:ff - Fast-forward all artifacts
- /opsx:apply - Implement tasks
- /opsx:sync - Sync delta specs
- /opsx:archive - Archive completed change (NEW)
2026-01-06 17:21:37 -08:00
Tabish Bidiwale a5c10ed5e7 feat: add /opsx:sync command for syncing delta specs to main specs (#450)
* feat: add /opsx:sync command for syncing delta specs to main specs

Add a new agent-driven skill that syncs delta specs from a change to main specs
without requiring archiving. This enables:

- Updating main specs during active development
- Intelligent merging (partial updates, adding scenarios)
- Idempotent operation (safe to run multiple times)

Implementation:
- Extract shared specs-apply logic from archive.ts to specs-apply.ts
- Add getSyncSpecsSkillTemplate and getOpsxSyncCommandTemplate
- Register /opsx:sync in artifact-experimental-setup
- Add specs-sync-skill main spec

* fix: rename specs apply to specs sync throughout change artifacts

Update all references:
- /opsx:specs → /opsx:sync
- specs-apply-skill → specs-sync-skill
- Remove cli-artifact-workflow delta spec (no CLI command)
- Update proposal, design, and tasks docs
2026-01-06 16:32:22 -08:00
Tabish Bidiwale 1bc849554c feat: add /opsx:ff command for fast-forward artifact creation (#448)
Adds a new fast-forward command that creates all artifacts needed for
implementation in one go, instead of stepping through them individually.

Changes:
- Add `applyRequires` field to status JSON output (shows which artifacts
  are required before the apply phase can begin)
- Add skill template for openspec-ff-change
- Add command template for /opsx:ff slash command
- Register templates in artifact-workflow setup command

The fast-forward command uses the schema's `apply.requires` configuration
to determine when to stop creating artifacts, making it schema-agnostic.
2026-01-06 11:49:03 -08:00
87 changed files with 10121 additions and 701 deletions
+31
View File
@@ -1,5 +1,36 @@
# @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
+51
View File
@@ -26,6 +26,10 @@
Follow <a href="https://x.com/0xTab">@0xTab on X</a> for updates · Join the <a href="https://discord.gg/YctCnvvshC">OpenSpec Discord</a> for help and questions.
</p>
<p align="center">
<sub>🧪 <strong>New:</strong> <a href="docs/experimental-workflow.md">Experimental Workflow (OPSX)</a> — schema-driven, hackable, fluid. Iterate on workflows without code changes.</sub>
</p>
# OpenSpec
OpenSpec aligns humans and AI coding assistants with spec-driven development so you agree on what to build before any code is written. **No API keys required.**
@@ -368,6 +372,53 @@ Run `openspec update` whenever someone switches tools so your agents pick up the
2. **Refresh agent instructions**
- Run `openspec update` inside each project to regenerate AI guidance and ensure the latest slash commands are active.
## Experimental Features
<details>
<summary><strong>🧪 OPSX: Fluid, Iterative Workflow</strong> (Claude Code only)</summary>
**Why this exists:**
- Standard workflow is locked down — you can't tweak instructions or customize
- When AI output is bad, you can't improve the prompts yourself
- Same workflow for everyone, no way to match how your team works
**What's different:**
- **Hackable** — edit templates and schemas yourself, test immediately, no rebuild
- **Granular** — each artifact has its own instructions, test and tweak individually
- **Customizable** — define your own workflows, artifacts, and dependencies
- **Fluid** — no phase gates, update any artifact anytime
```
You can always go back:
proposal ──→ specs ──→ design ──→ tasks ──→ implement
▲ ▲ ▲ │
└───────────┴──────────┴────────────────────┘
```
| Command | What it does |
|---------|--------------|
| `/opsx:new` | Start a new change |
| `/opsx:continue` | Create the next artifact (based on what's ready) |
| `/opsx:ff` | Fast-forward (all planning artifacts at once) |
| `/opsx:apply` | Implement tasks, updating artifacts as needed |
| `/opsx:archive` | Archive when done |
**Setup:** `openspec artifact-experimental-setup`
[Full documentation →](docs/experimental-workflow.md)
</details>
<details>
<summary><strong>Telemetry</strong> – OpenSpec collects anonymous usage stats (opt-out: <code>OPENSPEC_TELEMETRY=0</code>)</summary>
We collect only command names and version to understand usage patterns. No arguments, paths, content, or PII. Automatically disabled in CI.
**Opt-out:** `export OPENSPEC_TELEMETRY=0` or `export DO_NOT_TRACK=1`
</details>
## Contributing
- Install dependencies: `pnpm install`
+540
View File
@@ -0,0 +1,540 @@
# 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 **fluid, iterative workflow** for OpenSpec changes. No more rigid phases — just actions you can take anytime.
## Why This Exists
The standard OpenSpec workflow works, but it's **locked down**:
- **Instructions are hardcoded** — buried in TypeScript, you can't change them
- **All-or-nothing** — one big command creates everything, can't test individual pieces
- **Fixed structure** — same workflow for everyone, no customization
- **Black box** — when AI output is bad, you can't tweak the prompts
**OPSX opens it up.** Now anyone can:
1. **Experiment with instructions** — edit a template, see if the AI does better
2. **Test granularly** — validate each artifact's instructions independently
3. **Customize workflows** — define your own artifacts and dependencies
4. **Iterate quickly** — change a template, test immediately, no rebuild
```
Standard workflow: OPSX:
┌────────────────────────┐ ┌────────────────────────┐
│ Hardcoded in package │ │ schema.yaml │◄── You edit this
│ (can't change) │ │ templates/*.md │◄── Or this
│ ↓ │ │ ↓ │
│ Wait for new release │ │ Instant effect │
│ ↓ │ │ ↓ │
│ Hope it's better │ │ Test it yourself │
└────────────────────────┘ └────────────────────────┘
```
**This is for everyone:**
- **Teams** — create workflows that match how you actually work
- **Power users** — tweak prompts to get better AI outputs for your codebase
- **OpenSpec contributors** — experiment with new approaches without releases
We're all still learning what works best. OPSX lets us learn together.
## The User Experience
**The problem with linear workflows:**
You're "in planning phase", then "in implementation phase", then "done". But real work doesn't work that way. You implement something, realize your design was wrong, need to update specs, continue implementing. Linear phases fight against how work actually happens.
**OPSX approach:**
- **Actions, not phases** — create, implement, update, archive — do any of them anytime
- **Dependencies are enablers** — they show what's possible, not what's required next
- **Update as you learn** — halfway through implementation? Go back and fix the design. That's normal.
```
You can always go back:
┌────────────────────────────────────┐
│ │
▼ │
proposal ──→ specs ──→ design ──→ tasks ──→ implement
▲ ▲ ▲ │
│ │ │ │
└───────────┴──────────┴───────────────┘
update as you learn
```
## 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:explore` | Think through ideas, investigate problems, clarify requirements |
| `/opsx:new` | Start a new change |
| `/opsx:continue` | Create the next artifact (based on what's ready) |
| `/opsx:ff` | Fast-forward — create all planning artifacts at once |
| `/opsx:apply` | Implement tasks, updating artifacts as needed |
| `/opsx:sync` | Sync delta specs to main specs |
| `/opsx:archive` | Archive when done |
## Usage
### Explore an idea
```
/opsx:explore
```
Think through ideas, investigate problems, compare options. No structure required - just a thinking partner. When insights crystallize, transition to `/opsx:new` or `/opsx:ff`.
### Start a new change
```
/opsx:new
```
You'll be asked what you want to build and which workflow schema to use.
### Create artifacts
```
/opsx:continue
```
Shows what's ready to create based on dependencies, then creates one artifact. Use repeatedly to build up your change incrementally.
```
/opsx:ff add-dark-mode
```
Creates all planning artifacts at once. Use when you have a clear picture of what you're building.
### Implement (the fluid part)
```
/opsx:apply
```
Works through tasks, checking them off as you go. **Key difference:** if you discover issues during implementation, you can update your specs, design, or tasks — then continue. No phase gates.
### Finish up
```
/opsx:sync # Update main specs with your delta specs
/opsx:archive # Move to archive when done
```
## When to Update vs. Start Fresh
OPSX lets you update artifacts anytime. But when does "update as you learn" become "this is different work"?
### What a Proposal Captures
A proposal defines three things:
1. **Intent** — What problem are you solving?
2. **Scope** — What's in/out of bounds?
3. **Approach** — How will you solve it?
The question is: which changed, and by how much?
### Update the Existing Change When:
**Same intent, refined execution**
- You discover edge cases you didn't consider
- The approach needs tweaking but the goal is unchanged
- Implementation reveals the design was slightly off
**Scope narrows**
- You realize full scope is too big, want to ship MVP first
- "Add dark mode" → "Add dark mode toggle (system preference in v2)"
**Learning-driven corrections**
- Codebase isn't structured how you thought
- A dependency doesn't work as expected
- "Use CSS variables" → "Use Tailwind's dark: prefix instead"
### Start a New Change When:
**Intent fundamentally changed**
- The problem itself is different now
- "Add dark mode" → "Add comprehensive theme system with custom colors, fonts, spacing"
**Scope exploded**
- Change grew so much it's essentially different work
- Original proposal would be unrecognizable after updates
- "Fix login bug" → "Rewrite auth system"
**Original is completable**
- The original change can be marked "done"
- New work stands alone, not a refinement
- Complete "Add dark mode MVP" → Archive → New change "Enhance dark mode"
### The Heuristics
```
┌─────────────────────────────────────┐
│ Is this the same work? │
└──────────────┬──────────────────────┘
│
┌──────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
Same intent? >50% overlap? Can original
Same problem? Same scope? be "done" without
│ │ these changes?
│ │ │
┌────────┴────────┐ ┌──────┴──────┐ ┌───────┴───────┐
│ │ │ │ │ │
YES NO YES NO NO YES
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
UPDATE NEW UPDATE NEW UPDATE NEW
```
| Test | Update | New Change |
|------|--------|------------|
| **Identity** | "Same thing, refined" | "Different work" |
| **Scope overlap** | >50% overlaps | <50% overlaps |
| **Completion** | Can't be "done" without changes | Can finish original, new work stands alone |
| **Story** | Update chain tells coherent story | Patches would confuse more than clarify |
### The Principle
> **Update preserves context. New change provides clarity.**
>
> Choose update when the history of your thinking is valuable.
> Choose new when starting fresh would be clearer than patching.
Think of it like git branches:
- Keep committing while working on the same feature
- Start a new branch when it's genuinely new work
- Sometimes merge a partial feature and start fresh for phase 2
## What's Different?
| | Standard (`/openspec:proposal`) | Experimental (`/opsx:*`) |
|---|---|---|
| **Structure** | One big proposal document | Discrete artifacts with dependencies |
| **Workflow** | Linear phases: plan → implement → archive | Fluid actions — do anything anytime |
| **Iteration** | Awkward to go back | Update artifacts as you learn |
| **Customization** | Fixed structure | Schema-driven (define your own artifacts) |
**The key insight:** work isn't linear. OPSX stops pretending it is.
## Architecture Deep Dive
This section explains how OPSX works under the hood and how it compares to the standard workflow.
### Philosophy: Phases vs Actions
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ STANDARD WORKFLOW │
│ (Phase-Locked, All-or-Nothing) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ PLANNING │ ───► │ IMPLEMENTING │ ───► │ ARCHIVING │ │
│ │ PHASE │ │ PHASE │ │ PHASE │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ /openspec:proposal /openspec:apply /openspec:archive │
│ │
│ • Creates ALL artifacts at once │
│ • Can't go back to update specs during implementation │
│ • Phase gates enforce linear progression │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ OPSX WORKFLOW │
│ (Fluid Actions, Iterative) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────┐ │
│ │ ACTIONS (not phases) │ │
│ │ │ │
│ │ new ◄──► continue ◄──► apply ◄──► sync │ │
│ │ │ │ │ │ │ │
│ │ └──────────┴───────────┴──────────┘ │ │
│ │ any order │ │
│ └────────────────────────────────────────────┘ │
│ │
│ • Create artifacts one at a time OR fast-forward │
│ • Update specs/design/tasks during implementation │
│ • Dependencies enable progress, phases don't exist │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Component Architecture
**Standard workflow** uses hardcoded templates in TypeScript:
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ STANDARD WORKFLOW COMPONENTS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Hardcoded Templates (TypeScript strings) │
│ │ │
│ ▼ │
│ Configurators (18+ classes, one per editor) │
│ │ │
│ ▼ │
│ Generated Command Files (.claude/commands/openspec/*.md) │
│ │
│ • Fixed structure, no artifact awareness │
│ • Change requires code modification + rebuild │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
**OPSX** uses external schemas and a dependency graph engine:
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ OPSX COMPONENTS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Schema Definitions (YAML) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ name: spec-driven │ │
│ │ artifacts: │ │
│ │ - id: proposal │ │
│ │ generates: proposal.md │ │
│ │ requires: [] ◄── Dependencies │ │
│ │ - id: specs │ │
│ │ generates: specs/**/*.md ◄── Glob patterns │ │
│ │ requires: [proposal] ◄── Enables after proposal │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Artifact Graph Engine │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ • Topological sort (dependency ordering) │ │
│ │ • State detection (filesystem existence) │ │
│ │ • Rich instruction generation (templates + context) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Skill Files (.claude/skills/openspec-*/SKILL.md) │
│ │
│ • Cross-editor compatible (Claude Code, Cursor, Windsurf) │
│ • Skills query CLI for structured data │
│ • Fully customizable via schema files │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Dependency Graph Model
Artifacts form a directed acyclic graph (DAG). Dependencies are **enablers**, not gates:
```
proposal
(root node)
│
┌─────────────┴─────────────┐
│ │
▼ ▼
specs design
(requires: (requires:
proposal) proposal)
│ │
└─────────────┬─────────────┘
│
▼
tasks
(requires:
specs, design)
│
▼
┌──────────────┐
│ APPLY PHASE │
│ (requires: │
│ tasks) │
└──────────────┘
```
**State transitions:**
```
BLOCKED ────────────────► READY ────────────────► DONE
│ │ │
Missing All deps File exists
dependencies are DONE on filesystem
```
### Information Flow
**Standard workflow** — agent receives static instructions:
```
User: "/openspec:proposal"
│
▼
┌─────────────────────────────────────────┐
│ Static instructions: │
│ • Create proposal.md │
│ • Create tasks.md │
│ • Create design.md │
│ • Create specs/*.md │
│ │
│ No awareness of what exists or │
│ dependencies between artifacts │
└─────────────────────────────────────────┘
│
▼
Agent creates ALL artifacts in one go
```
**OPSX** — agent queries for rich context:
```
User: "/opsx:continue"
│
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ Step 1: Query current state │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ $ openspec status --change "add-auth" --json │ │
│ │ │ │
│ │ { │ │
│ │ "artifacts": [ │ │
│ │ {"id": "proposal", "status": "done"}, │ │
│ │ {"id": "specs", "status": "ready"}, ◄── First ready │ │
│ │ {"id": "design", "status": "ready"}, │ │
│ │ {"id": "tasks", "status": "blocked", "missingDeps": ["specs"]}│ │
│ │ ] │ │
│ │ } │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ Step 2: Get rich instructions for ready artifact │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ $ openspec instructions specs --change "add-auth" --json │ │
│ │ │ │
│ │ { │ │
│ │ "template": "# Specification\n\n## ADDED Requirements...", │ │
│ │ "dependencies": [{"id": "proposal", "path": "...", "done": true}│ │
│ │ "unlocks": ["tasks"] │ │
│ │ } │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
│ Step 3: Read dependencies → Create ONE artifact → Show what's unlocked │
└──────────────────────────────────────────────────────────────────────────┘
```
### Iteration Model
**Standard workflow** — awkward to iterate:
```
┌─────────┐ ┌─────────┐ ┌─────────┐
│/proposal│ ──► │ /apply │ ──► │/archive │
└─────────┘ └─────────┘ └─────────┘
│ │
│ ├── "Wait, the design is wrong"
│ │
│ ├── Options:
│ │ • Edit files manually (breaks context)
│ │ • Abandon and start over
│ │ • Push through and fix later
│ │
│ └── No official "go back" mechanism
│
└── Creates ALL artifacts at once
```
**OPSX** — natural iteration:
```
/opsx:new ───► /opsx:continue ───► /opsx:apply ───► /opsx:archive
│ │ │
│ │ ├── "The design is wrong"
│ │ │
│ │ ▼
│ │ Just edit design.md
│ │ and continue!
│ │ │
│ │ ▼
│ │ /opsx:apply picks up
│ │ where you left off
│ │
│ └── Creates ONE artifact, shows what's unlocked
│
└── Scaffolds change, waits for direction
```
### Custom Schemas
Create your own workflow by adding a schema to `~/.local/share/openspec/schemas/`:
```
~/.local/share/openspec/schemas/research-first/
├── schema.yaml
└── templates/
├── research.md
├── proposal.md
└── tasks.md
schema.yaml:
┌─────────────────────────────────────────────────────────────────┐
│ name: research-first │
│ artifacts: │
│ - id: research # Added before proposal │
│ generates: research.md │
│ requires: [] │
│ │
│ - id: proposal │
│ generates: proposal.md │
│ requires: [research] # Now depends on research │
│ │
│ - id: tasks │
│ generates: tasks.md │
│ requires: [proposal] │
└─────────────────────────────────────────────────────────────────┘
Dependency Graph:
research ──► proposal ──► tasks
```
### Summary
| Aspect | Standard | OPSX |
|--------|----------|------|
| **Templates** | Hardcoded TypeScript | External YAML + Markdown |
| **Dependencies** | None (all at once) | DAG with topological sort |
| **State** | Phase-based mental model | Filesystem existence |
| **Customization** | Edit source, rebuild | Create schema.yaml |
| **Iteration** | Phase-locked | Fluid, edit anything |
| **Editor Support** | 18+ configurator classes | Single skills directory |
## 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:explore` to think through an idea before committing to a change
- `/opsx:ff` when you know what you want, `/opsx:continue` when exploring
- During `/opsx:apply`, if something's wrong — fix the artifact, then continue
- Tasks track progress via checkboxes in `tasks.md`
- Check status anytime: `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).
@@ -1,11 +0,0 @@
## 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/`
@@ -1,36 +0,0 @@
## 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
@@ -1,12 +0,0 @@
## 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.
@@ -0,0 +1,15 @@
# 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
@@ -0,0 +1,328 @@
# 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
@@ -0,0 +1,49 @@
# 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
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-06
@@ -0,0 +1,77 @@
## 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
@@ -0,0 +1,32 @@
## 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
@@ -0,0 +1,67 @@
## 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"
@@ -0,0 +1,40 @@
## 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)
@@ -0,0 +1,60 @@
## 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
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-07
@@ -0,0 +1,84 @@
## 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.
@@ -0,0 +1,28 @@
## 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
@@ -0,0 +1,122 @@
## 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
@@ -0,0 +1,23 @@
## 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)
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-01-10
@@ -0,0 +1,175 @@
## Context
OpenSpec needs usage analytics to understand adoption and inform product decisions. PostHog provides a privacy-conscious analytics platform suitable for open source projects.
## Goals / Non-Goals
**Goals:**
- Track daily/weekly/monthly active usage
- Understand command usage patterns
- Keep implementation minimal and privacy-respecting
- Enable opt-out with minimal friction
**Non-Goals:**
- Detailed error tracking or diagnostics
- User identification or profiling
- Complex event hierarchies
- Full CLI command for telemetry management (env var sufficient for now)
## Decisions
### Opt-Out Model
**Decision:** Telemetry enabled by default, opt-out via environment variable.
```bash
OPENSPEC_TELEMETRY=0 # Disable telemetry
DO_NOT_TRACK=1 # Industry standard, also respected
```
Auto-disabled when `CI=true` is detected.
**Rationale:**
- Opt-in typically yields ~3% participation—not enough for meaningful data
- Understanding usage patterns requires statistically significant sample sizes
- Environment variable opt-out is simple and immediate
- Respecting `DO_NOT_TRACK` follows industry convention
**Alternatives considered:**
- Opt-in only - Insufficient data for product decisions
- Config file setting - More complex, env var sufficient for MVP
- Full `openspec telemetry` command - Can add later if users request
### Event Design
**Decision:** Single event type with minimal properties.
```typescript
{
event: 'command_executed',
properties: {
command: 'init', // Command name only
version: '1.2.3' // OpenSpec version
}
}
```
**Rationale:**
- Answers the core questions: how much usage, which commands are popular
- PostHog derives DAU/WAU/MAU from anonymous user counts over time
- No arguments, paths, or content—clean privacy story
- Easy to explain in disclosure notice
**Not tracked:**
- Command arguments
- File paths or contents
- Error messages or stack traces
- Project names or spec content
- IP addresses (`$ip: null` explicitly set)
### Anonymous ID
**Decision:** Random UUID, lazily generated on first telemetry send, stored in global config.
```typescript
// ~/.config/openspec/config.json
{
"telemetry": {
"anonymousId": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}
}
```
**Rationale:**
- Random UUID has no relation to the person—can't be reversed
- Stored in config so same user = same ID across sessions (needed for DAU/WAU/MAU)
- Lazy generation means no ID created if user opts out before first command
- User can delete config to reset identity
**Alternatives considered:**
- Machine-derived hash (hostname, MAC) - Feels invasive, fingerprint-like
- Per-session UUID - Breaks user counting metrics entirely
### SDK Configuration
**Decision:** PostHog Node SDK with immediate flush, shutdown on exit.
```typescript
const posthog = new PostHog(API_KEY, {
flushAt: 1, // Send immediately, don't batch
flushInterval: 0 // No timer-based flushing
});
// Before CLI exits
await posthog.shutdown();
```
**Rationale:**
- CLI processes are short-lived; batching would lose events
- `flushAt: 1` ensures each event sends immediately
- `shutdown()` guarantees flush before process exit
- Adds ~100-300ms to exit—negligible for typical CLI workflows
**Error handling:**
- Network failures silently ignored (telemetry shouldn't break CLI)
- `shutdown()` wrapped in try/catch
### Hook Location
**Decision:** Commander.js `preAction` and `postAction` hooks.
```typescript
program
.hook('preAction', (thisCommand) => {
maybeShowTelemetryNotice();
trackCommand(thisCommand.name(), VERSION);
})
.hook('postAction', async () => {
await shutdown();
});
```
**Rationale:**
- Centralized—one place for all telemetry logic
- Automatic—new commands get tracked without code changes
- Clean separation—command handlers don't know about telemetry
**Subcommand handling:**
- Track full command path for nested commands (e.g., `change:apply`)
### First-Run Notice
**Decision:** One-liner on first command ever, stored "seen" flag in config.
```
Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0
```
**Rationale:**
- First command (not just `init`) ensures notice is always seen
- Non-blocking—no prompt, just informational
- One-liner is visible but not intrusive
- Storing "seen" in config prevents repeated display
**Config after first run:**
```json
{
"telemetry": {
"anonymousId": "...",
"noticeSeen": true
}
}
```
## Risks / Trade-offs
| Risk | Mitigation |
|------|------------|
| Users prefer opt-in | Clear disclosure, trivial opt-out, transparent about what's collected |
| GDPR concerns | No personal data, no IP, user can delete config |
| Slows CLI exit by ~200ms | Negligible for most workflows; can optimize if needed |
| PostHog outage affects CLI | Fire-and-forget with timeout; failures are silent |
## Open Questions
None—design is intentionally minimal. Future enhancements (dedicated command, workflow tracking) can be added based on user feedback.
@@ -0,0 +1,37 @@
## Why
OpenSpec currently has no visibility into how the tool is being used. Without analytics, we cannot:
- Understand which commands and features are most valuable to users
- Measure adoption and usage patterns
- Make data-driven decisions about product development
Adding PostHog analytics enables product insights while respecting user privacy through transparent, opt-out telemetry.
## What Changes
- Add PostHog Node.js SDK as a dependency
- Implement telemetry system with environment variable opt-out
- Track command usage (command name and version only)
- Show first-run notice informing users about telemetry
- Store anonymous ID in global config (`~/.config/openspec/config.json`)
- Respect `DO_NOT_TRACK` and `OPENSPEC_TELEMETRY=0` environment variables
- Auto-disable in CI environments
## Capabilities
### New Capabilities
- `telemetry`: Anonymous usage analytics using PostHog. Covers command tracking, opt-out controls, and first-run disclosure notice.
### Modified Capabilities
- `global-config`: Add telemetry state storage (anonymous ID, notice seen flag)
## Impact
- **Dependencies**: Add `posthog-node` package
- **Privacy**: Opt-out via env var, no personal data collected, clear disclosure
- **Configuration**: New global config fields for telemetry state
- **Network**: Async event sending with flush on exit (~100-300ms added)
- **CI/CD**: Telemetry auto-disabled when `CI=true`
- **Documentation**: Update README with telemetry disclosure
@@ -0,0 +1,21 @@
## MODIFIED Requirements
### Requirement: Global configuration storage
The system SHALL store global configuration in `~/.config/openspec/config.json`, including telemetry state with `anonymousId` and `noticeSeen` fields.
#### Scenario: Initial config creation
- **WHEN** no global config file exists
- **AND** the first telemetry event is about to be sent
- **THEN** the system creates `~/.config/openspec/config.json` with telemetry configuration
#### Scenario: Telemetry config structure
- **WHEN** reading or writing telemetry configuration
- **THEN** the config contains a `telemetry` object with `anonymousId` (string UUID) and `noticeSeen` (boolean) fields
#### Scenario: Config file format
- **WHEN** storing configuration
- **THEN** the system writes valid JSON that can be read and modified by users
#### Scenario: Existing config preservation
- **WHEN** adding telemetry fields to an existing config file
- **THEN** the system preserves all existing configuration fields
@@ -0,0 +1,116 @@
## ADDED Requirements
### Requirement: Command execution tracking
The system SHALL send a `command_executed` event to PostHog when any CLI command executes, including only the command name and OpenSpec version as properties.
#### Scenario: Standard command execution
- **WHEN** a user runs any openspec command
- **THEN** the system sends a `command_executed` event with `command` and `version` properties
#### Scenario: Subcommand execution
- **WHEN** a user runs a nested command like `openspec change apply`
- **THEN** the system sends a `command_executed` event with the full command path (e.g., `change:apply`)
### Requirement: Privacy-preserving event design
The system SHALL NOT include command arguments, file paths, project names, spec content, error messages, or IP addresses in telemetry events.
#### Scenario: Command with arguments
- **WHEN** a user runs `openspec init my-project --force`
- **THEN** the telemetry event contains only `command: "init"` and `version: "<version>"` without arguments
#### Scenario: IP address exclusion
- **WHEN** the system sends a telemetry event
- **THEN** the event explicitly sets `$ip: null` to prevent IP tracking
### Requirement: Environment variable opt-out
The system SHALL disable telemetry when `OPENSPEC_TELEMETRY=0` or `DO_NOT_TRACK=1` environment variables are set.
#### Scenario: OPENSPEC_TELEMETRY opt-out
- **WHEN** `OPENSPEC_TELEMETRY=0` is set in the environment
- **THEN** the system sends no telemetry events
#### Scenario: DO_NOT_TRACK opt-out
- **WHEN** `DO_NOT_TRACK=1` is set in the environment
- **THEN** the system sends no telemetry events
#### Scenario: Environment variable takes precedence
- **WHEN** the user has previously used the CLI (config exists)
- **AND** the user sets `OPENSPEC_TELEMETRY=0`
- **THEN** telemetry is disabled regardless of config state
### Requirement: CI environment auto-disable
The system SHALL automatically disable telemetry when `CI=true` environment variable is detected.
#### Scenario: CI environment detection
- **WHEN** `CI=true` is set in the environment
- **THEN** the system sends no telemetry events
#### Scenario: CI with explicit enable
- **WHEN** `CI=true` is set
- **AND** `OPENSPEC_TELEMETRY=1` is explicitly set
- **THEN** telemetry remains disabled (CI takes precedence for privacy)
### Requirement: First-run telemetry notice
The system SHALL display a one-line telemetry disclosure notice on the first command execution, before any telemetry is sent.
#### Scenario: First command execution
- **WHEN** a user runs their first openspec command
- **AND** telemetry is enabled
- **THEN** the system displays: "Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0"
#### Scenario: Subsequent command execution
- **WHEN** a user has already seen the notice (noticeSeen: true in config)
- **THEN** the system does not display the notice
#### Scenario: Notice before telemetry
- **WHEN** displaying the first-run notice
- **THEN** the notice appears before any telemetry event is sent
### Requirement: Anonymous user identification
The system SHALL generate a random UUID as an anonymous identifier on first telemetry send, stored in global config.
#### Scenario: First telemetry event
- **WHEN** the first telemetry event is sent
- **AND** no anonymousId exists in config
- **THEN** the system generates a random UUID v4 and stores it in config
#### Scenario: Persistent identity
- **WHEN** a user runs multiple commands across sessions
- **THEN** the same anonymousId is used for all events
#### Scenario: Lazy generation with opt-out
- **WHEN** a user opts out before running any command
- **THEN** no anonymousId is ever generated or stored
### Requirement: Immediate event sending
The system SHALL send telemetry events immediately without batching, using `flushAt: 1` and `flushInterval: 0` configuration.
#### Scenario: Event transmission timing
- **WHEN** a command executes
- **THEN** the telemetry event is sent immediately, not queued for batch transmission
### Requirement: Graceful shutdown
The system SHALL call `posthog.shutdown()` before CLI exit to ensure pending events are flushed.
#### Scenario: Normal exit
- **WHEN** a command completes successfully
- **THEN** the system awaits `shutdown()` before exiting
#### Scenario: Error exit
- **WHEN** a command fails with an error
- **THEN** the system still awaits `shutdown()` before exiting
### Requirement: Silent failure handling
The system SHALL silently ignore telemetry failures without affecting CLI functionality.
#### Scenario: Network failure
- **WHEN** the telemetry request fails due to network error
- **THEN** the CLI command completes normally without error message
#### Scenario: PostHog outage
- **WHEN** PostHog service is unavailable
- **THEN** the CLI command completes normally without error message
#### Scenario: Shutdown failure
- **WHEN** `shutdown()` fails or times out
- **THEN** the CLI exits normally without error message
@@ -0,0 +1,47 @@
## 1. Setup
- [x] 1.1 Add `posthog-node` package as a dependency
- [x] 1.2 Create `src/telemetry/` module directory
- [x] 1.3 Add PostHog API key configuration (environment variable or embedded)
## 2. Global Config
- [x] 2.1 Create or extend global config module for `~/.config/openspec/config.json`
- [x] 2.2 Implement read/write functions that preserve existing config fields
- [x] 2.3 Define telemetry config structure (`anonymousId`, `noticeSeen`)
## 3. Core Telemetry Module
- [x] 3.1 Implement `isTelemetryEnabled()` checking `OPENSPEC_TELEMETRY`, `DO_NOT_TRACK`, and `CI` env vars
- [x] 3.2 Implement `getOrCreateAnonymousId()` with lazy UUID generation
- [x] 3.3 Initialize PostHog client with `flushAt: 1` and `flushInterval: 0`
- [x] 3.4 Implement `trackCommand(commandName, version)` with `$ip: null`
- [x] 3.5 Implement `shutdown()` with try/catch for silent failure handling
## 4. First-Run Notice
- [x] 4.1 Implement `maybeShowTelemetryNotice()` function
- [x] 4.2 Check `noticeSeen` flag before displaying notice
- [x] 4.3 Display notice text: "Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0"
- [x] 4.4 Update `noticeSeen` in config after first display
## 5. CLI Integration
- [x] 5.1 Add Commander.js `preAction` hook to show notice and track command
- [x] 5.2 Add Commander.js `postAction` hook to call shutdown
- [x] 5.3 Handle subcommand path extraction (e.g., `change:apply`)
## 6. Testing
- [x] 6.1 Test opt-out via `OPENSPEC_TELEMETRY=0`
- [x] 6.2 Test opt-out via `DO_NOT_TRACK=1`
- [x] 6.3 Test auto-disable in CI environment
- [x] 6.4 Test first-run notice display and noticeSeen persistence
- [x] 6.5 Test anonymous ID generation and persistence
- [x] 6.6 Test silent failure on network error (mock PostHog)
## 7. Documentation
- [x] 7.1 Add telemetry disclosure section to README
- [x] 7.2 Document opt-out methods (`OPENSPEC_TELEMETRY=0`, `DO_NOT_TRACK=1`)
- [x] 7.3 Document what data is collected and not collected
@@ -0,0 +1,16 @@
## 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
@@ -0,0 +1,75 @@
## 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
@@ -0,0 +1,56 @@
## 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
@@ -0,0 +1,6 @@
## 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`
@@ -1,12 +0,0 @@
## 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`
@@ -1,25 +0,0 @@
## 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
@@ -1,16 +0,0 @@
## 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
@@ -27,6 +27,13 @@ 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
@@ -159,6 +166,52 @@ 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
+187 -42
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) in supported shells. Currently supports Zsh with architecture designed for future shell expansion.
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.
## Requirements
### Requirement: Native Shell Behavior Integration
The completion system SHALL respect and integrate with Zsh's native completion patterns and user interaction model.
The completion system SHALL respect and integrate with each supported shell's native completion patterns and user interaction model.
#### Scenario: Zsh native completion
@@ -15,12 +15,36 @@ The completion system SHALL respect and integrate with Zsh's native completion p
- **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 Zsh completion
- **WHEN** implementing completion for any shell
- **THEN** do NOT attempt to customize completion trigger behavior
- **AND** do NOT override Zsh-specific navigation patterns
- **AND** ensure completions feel native to experienced Zsh users
- **AND** do NOT override shell-specific navigation patterns
- **AND** ensure completions feel native to experienced users of that shell
### Requirement: Command Structure
@@ -43,17 +67,35 @@ 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 `zsh`
- **AND** throw an error if the shell is not `zsh`, with message indicating only Zsh is currently supported
- **AND** validate the shell is one of: `zsh`, `bash`, `fish`, `powershell`
- **AND** throw an error if the shell is not supported
#### Scenario: Non-Zsh shell detection
#### Scenario: Detecting Bash from environment
- **WHEN** shell path indicates bash, fish, powershell, or other non-Zsh shell
- **THEN** throw error: "Shell '<name>' is not supported yet. Currently supported: zsh"
- **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 Zsh completion scripts on demand.
The completion command SHALL generate completion scripts for all supported shells on demand.
#### Scenario: Generating Zsh completion
@@ -64,6 +106,33 @@ The completion command SHALL generate Zsh completion scripts on demand.
- **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.
@@ -98,7 +167,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.
The completion command SHALL automatically install completion scripts into shell configuration files for all supported shells.
#### Scenario: Installing for Oh My Zsh
@@ -118,12 +187,37 @@ 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: Auto-detecting Zsh for installation
#### 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 if detected shell is Zsh
- **AND** throw error if detected shell is not Zsh
- **AND** install completion for the detected shell (zsh, bash, fish, or powershell)
- **AND** display which shell was detected
#### Scenario: Already installed
@@ -135,23 +229,45 @@ The completion command SHALL automatically install completion scripts into shell
### Requirement: Uninstallation
The completion command SHALL remove installed completion scripts and configuration.
The completion command SHALL remove installed completion scripts and configuration for all supported shells.
#### Scenario: Uninstalling Oh My Zsh completion
#### 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`
- **AND** remove fpath modifications from `~/.zshrc` using marker-based removal
- **AND** display success message
#### Scenario: Auto-detecting Zsh for uninstallation
#### 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 if shell is Zsh
- **AND** throw error if detected shell is not Zsh
- **THEN** detect current shell and uninstall completion for that shell
#### Scenario: Not installed
@@ -161,17 +277,34 @@ 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.
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 `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)
- **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
@@ -190,18 +323,18 @@ The completion implementation SHALL follow clean architecture principles with Ty
- `name: string` - Command name
- `description: string` - Help text
- `flags: FlagDefinition[]` - Available flags
- `acceptsChangeId: boolean` - Whether command takes change ID argument
- `acceptsSpecId: boolean` - Whether command takes spec ID argument
- `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** generators consume this registry to ensure consistency
- **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'`
- **AND** implement `detectShell()` function that returns 'zsh' or throws error
- **AND** design type to be extensible (e.g., future: `'bash' | 'zsh' | 'fish' | 'powershell'`)
- **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: Error Handling
@@ -209,8 +342,8 @@ The completion command SHALL provide clear error messages for common failure sce
#### Scenario: Unsupported shell
- **WHEN** user requests completion for unsupported shell (bash, fish, powershell, etc.)
- **THEN** display error message: "Shell '<name>' is not supported yet. Currently supported: zsh"
- **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"
- **AND** exit with code 1
#### Scenario: Permission errors during installation
@@ -228,7 +361,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 or detects non-Zsh shell
- **WHEN** `openspec completion install` cannot detect current 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
@@ -263,25 +396,37 @@ 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.
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` environment variable
- **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** verify generated scripts contain expected patterns
- **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: Installation simulation
#### Scenario: Installer simulation
- **WHEN** testing installation logic
- **THEN** use temporary test directories instead of actual home directories
- **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
+2 -1
View File
@@ -187,7 +187,8 @@ 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 so command text matches other tools
- **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
+4 -2
View File
@@ -50,6 +50,7 @@ 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
@@ -64,8 +65,9 @@ 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 shared templates
- **AND** ensure templates include instructions for the relevant workflow stage
- **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`
+20
View File
@@ -4,6 +4,26 @@
This spec defines how OpenSpec resolves, reads, and writes user-level global configuration. It governs the `src/core/global-config.ts` module, which provides the foundation for storing user preferences, feature flags, and settings that persist across projects. The spec ensures cross-platform compatibility by following XDG Base Directory Specification with platform-specific fallbacks, and guarantees forward/backward compatibility through schema evolution rules.
## Requirements
### Requirement: Global configuration storage
The system SHALL store global configuration in `~/.config/openspec/config.json`, including telemetry state with `anonymousId` and `noticeSeen` fields.
#### Scenario: Initial config creation
- **WHEN** no global config file exists
- **AND** the first telemetry event is about to be sent
- **THEN** the system creates `~/.config/openspec/config.json` with telemetry configuration
#### Scenario: Telemetry config structure
- **WHEN** reading or writing telemetry configuration
- **THEN** the config contains a `telemetry` object with `anonymousId` (string UUID) and `noticeSeen` (boolean) fields
#### Scenario: Config file format
- **WHEN** storing configuration
- **THEN** the system writes valid JSON that can be read and modified by users
#### Scenario: Existing config preservation
- **WHEN** adding telemetry fields to an existing config file
- **THEN** the system preserves all existing configuration fields
### Requirement: Global Config Directory Path
The system SHALL resolve the global configuration directory path following XDG Base Directory Specification with platform-specific fallbacks.
+122
View File
@@ -0,0 +1,122 @@
# 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
@@ -0,0 +1,72 @@
# 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"
+122
View File
@@ -0,0 +1,122 @@
# telemetry Specification
## Purpose
This spec defines how OpenSpec collects anonymous usage telemetry to help improve the tool. It governs the `src/telemetry/` module, which handles PostHog integration, privacy-preserving event design, user opt-out mechanisms, and first-run notice display. The spec ensures telemetry is minimal, transparent, and respects user privacy.
## Requirements
### Requirement: Command execution tracking
The system SHALL send a `command_executed` event to PostHog when any CLI command executes, including only the command name and OpenSpec version as properties.
#### Scenario: Standard command execution
- **WHEN** a user runs any openspec command
- **THEN** the system sends a `command_executed` event with `command` and `version` properties
#### Scenario: Subcommand execution
- **WHEN** a user runs a nested command like `openspec change apply`
- **THEN** the system sends a `command_executed` event with the full command path (e.g., `change:apply`)
### Requirement: Privacy-preserving event design
The system SHALL NOT include command arguments, file paths, project names, spec content, error messages, or IP addresses in telemetry events.
#### Scenario: Command with arguments
- **WHEN** a user runs `openspec init my-project --force`
- **THEN** the telemetry event contains only `command: "init"` and `version: "<version>"` without arguments
#### Scenario: IP address exclusion
- **WHEN** the system sends a telemetry event
- **THEN** the event explicitly sets `$ip: null` to prevent IP tracking
### Requirement: Environment variable opt-out
The system SHALL disable telemetry when `OPENSPEC_TELEMETRY=0` or `DO_NOT_TRACK=1` environment variables are set.
#### Scenario: OPENSPEC_TELEMETRY opt-out
- **WHEN** `OPENSPEC_TELEMETRY=0` is set in the environment
- **THEN** the system sends no telemetry events
#### Scenario: DO_NOT_TRACK opt-out
- **WHEN** `DO_NOT_TRACK=1` is set in the environment
- **THEN** the system sends no telemetry events
#### Scenario: Environment variable takes precedence
- **WHEN** the user has previously used the CLI (config exists)
- **AND** the user sets `OPENSPEC_TELEMETRY=0`
- **THEN** telemetry is disabled regardless of config state
### Requirement: CI environment auto-disable
The system SHALL automatically disable telemetry when `CI=true` environment variable is detected.
#### Scenario: CI environment detection
- **WHEN** `CI=true` is set in the environment
- **THEN** the system sends no telemetry events
#### Scenario: CI with explicit enable
- **WHEN** `CI=true` is set
- **AND** `OPENSPEC_TELEMETRY=1` is explicitly set
- **THEN** telemetry remains disabled (CI takes precedence for privacy)
### Requirement: First-run telemetry notice
The system SHALL display a one-line telemetry disclosure notice on the first command execution, before any telemetry is sent.
#### Scenario: First command execution
- **WHEN** a user runs their first openspec command
- **AND** telemetry is enabled
- **THEN** the system displays: "Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0"
#### Scenario: Subsequent command execution
- **WHEN** a user has already seen the notice (noticeSeen: true in config)
- **THEN** the system does not display the notice
#### Scenario: Notice before telemetry
- **WHEN** displaying the first-run notice
- **THEN** the notice appears before any telemetry event is sent
### Requirement: Anonymous user identification
The system SHALL generate a random UUID as an anonymous identifier on first telemetry send, stored in global config.
#### Scenario: First telemetry event
- **WHEN** the first telemetry event is sent
- **AND** no anonymousId exists in config
- **THEN** the system generates a random UUID v4 and stores it in config
#### Scenario: Persistent identity
- **WHEN** a user runs multiple commands across sessions
- **THEN** the same anonymousId is used for all events
#### Scenario: Lazy generation with opt-out
- **WHEN** a user opts out before running any command
- **THEN** no anonymousId is ever generated or stored
### Requirement: Immediate event sending
The system SHALL send telemetry events immediately without batching, using `flushAt: 1` and `flushInterval: 0` configuration.
#### Scenario: Event transmission timing
- **WHEN** a command executes
- **THEN** the telemetry event is sent immediately, not queued for batch transmission
### Requirement: Graceful shutdown
The system SHALL call `posthog.shutdown()` before CLI exit to ensure pending events are flushed.
#### Scenario: Normal exit
- **WHEN** a command completes successfully
- **THEN** the system awaits `shutdown()` before exiting
#### Scenario: Error exit
- **WHEN** a command fails with an error
- **THEN** the system still awaits `shutdown()` before exiting
### Requirement: Silent failure handling
The system SHALL silently ignore telemetry failures without affecting CLI functionality.
#### Scenario: Network failure
- **WHEN** the telemetry request fails due to network error
- **THEN** the CLI command completes normally without error message
#### Scenario: PostHog outage
- **WHEN** PostHog service is unavailable
- **THEN** the CLI command completes normally without error message
#### Scenario: Shutdown failure
- **WHEN** `shutdown()` fails or times out
- **THEN** the CLI exits normally without error message
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@fission-ai/openspec",
"version": "0.17.2",
"version": "0.18.0",
"description": "AI-native system for spec-driven development",
"keywords": [
"openspec",
@@ -76,6 +76,7 @@
"commander": "^14.0.0",
"fast-glob": "^3.3.3",
"ora": "^8.2.0",
"posthog-node": "^5.20.0",
"yaml": "^2.8.2",
"zod": "^4.0.17"
}
+18
View File
@@ -26,6 +26,9 @@ importers:
ora:
specifier: ^8.2.0
version: 8.2.0
posthog-node:
specifier: ^5.20.0
version: 5.20.0
yaml:
specifier: ^2.8.2
version: 2.8.2
@@ -484,6 +487,9 @@ packages:
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@posthog/core@1.9.1':
resolution: {integrity: sha512-kRb1ch2dhQjsAapZmu6V66551IF2LnCbc1rnrQqnR7ArooVyJN9KOPXre16AJ3ObJz2eTfuP7x25BMyS2Y5Exw==}
'@rollup/rollup-android-arm-eabi@4.46.2':
resolution: {integrity: sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==}
cpu: [arm]
@@ -1284,6 +1290,10 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
posthog-node@5.20.0:
resolution: {integrity: sha512-LkR5KfrvEQTnUtNKN97VxFB00KcYG1Iz8iKg8r0e/i7f1eQhg1WSZO+Jp1B4bvtHCmdpIE4HwYbvCCzFoCyjVg==}
engines: {node: '>=20'}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -2038,6 +2048,10 @@ snapshots:
'@polka/url@1.0.0-next.29': {}
'@posthog/core@1.9.1':
dependencies:
cross-spawn: 7.0.6
'@rollup/rollup-android-arm-eabi@4.46.2':
optional: true
@@ -2808,6 +2822,10 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
posthog-node@5.20.0:
dependencies:
'@posthog/core': 1.9.1
prelude-ls@1.2.1: {}
prettier@2.8.8: {}
+35 -2
View File
@@ -15,11 +15,32 @@ import { ShowCommand } from '../commands/show.js';
import { CompletionCommand } from '../commands/completion.js';
import { registerConfigCommand } from '../commands/config.js';
import { registerArtifactWorkflowCommands } from '../commands/artifact-workflow.js';
import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js';
const program = new Command();
const require = createRequire(import.meta.url);
const { version } = require('../../package.json');
/**
* Get the full command path for nested commands.
* For example: 'change show' -> 'change:show'
*/
function getCommandPath(command: Command): string {
const names: string[] = [];
let current: Command | null = command;
while (current) {
const name = current.name();
// Skip the root 'openspec' command
if (name && name !== 'openspec') {
names.unshift(name);
}
current = current.parent;
}
return names.join(':') || 'openspec';
}
program
.name('openspec')
.description('AI-native system for spec-driven development')
@@ -28,12 +49,24 @@ program
// Global options
program.option('--no-color', 'Disable color output');
// Apply global flags before any command runs
program.hook('preAction', (thisCommand) => {
// Apply global flags and telemetry before any command runs
program.hook('preAction', async (thisCommand) => {
const opts = thisCommand.opts();
if (opts.color === false) {
process.env.NO_COLOR = '1';
}
// Show first-run telemetry notice (if not seen)
await maybeShowTelemetryNotice();
// Track command execution
const commandPath = getCommandPath(thisCommand);
await trackCommand(commandPath, version);
});
// Shutdown telemetry after command completes
program.hook('postAction', async () => {
await shutdown();
});
const availableToolIds = AI_TOOLS.filter((tool) => tool.available).map((tool) => tool.value);
+21 -1
View File
@@ -28,7 +28,7 @@ import {
type SchemaInfo,
} from '../core/artifact-graph/index.js';
import { createChange, validateChangeName } from '../utils/change-utils.js';
import { getNewChangeSkillTemplate, getContinueChangeSkillTemplate, getApplyChangeSkillTemplate, getOpsxNewCommandTemplate, getOpsxContinueCommandTemplate, getOpsxApplyCommandTemplate } from '../core/templates/skill-templates.js';
import { getExploreSkillTemplate, getNewChangeSkillTemplate, getContinueChangeSkillTemplate, getApplyChangeSkillTemplate, getFfChangeSkillTemplate, getSyncSpecsSkillTemplate, getArchiveChangeSkillTemplate, getOpsxExploreCommandTemplate, getOpsxNewCommandTemplate, getOpsxContinueCommandTemplate, getOpsxApplyCommandTemplate, getOpsxFfCommandTemplate, getOpsxSyncCommandTemplate, getOpsxArchiveCommandTemplate } from '../core/templates/skill-templates.js';
import { FileSystemUtils } from '../utils/file-system.js';
// -----------------------------------------------------------------------------
@@ -793,20 +793,32 @@ async function artifactExperimentalSetupCommand(): Promise<void> {
const commandsDir = path.join(projectRoot, '.claude', 'commands', 'opsx');
// Get skill templates
const exploreSkill = getExploreSkillTemplate();
const newChangeSkill = getNewChangeSkillTemplate();
const continueChangeSkill = getContinueChangeSkillTemplate();
const applyChangeSkill = getApplyChangeSkillTemplate();
const ffChangeSkill = getFfChangeSkillTemplate();
const syncSpecsSkill = getSyncSpecsSkillTemplate();
const archiveChangeSkill = getArchiveChangeSkillTemplate();
// Get command templates
const exploreCommand = getOpsxExploreCommandTemplate();
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: exploreSkill, dirName: 'openspec-explore' },
{ 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[] = [];
@@ -831,9 +843,13 @@ ${template.instructions}
// Create slash command files
const commands = [
{ template: exploreCommand, fileName: 'explore.md' },
{ 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[] = [];
@@ -886,9 +902,13 @@ ${template.content}
console.log(' • "Implement the tasks for this change"');
console.log();
console.log(' ' + chalk.cyan('Slash Commands') + ' for explicit invocation:');
console.log(' • /opsx:explore - Think through ideas, investigate problems');
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');
+50 -6
View File
@@ -144,8 +144,27 @@ export class CompletionCommand {
if (result.backupPath) {
console.log(` Backup created: ${result.backupPath}`);
}
if (result.zshrcConfigured) {
console.log(` ~/.zshrc configured automatically`);
// 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);
}
}
@@ -155,9 +174,24 @@ export class CompletionCommand {
for (const instruction of result.instructions) {
console.log(instruction);
}
} else if (result.zshrcConfigured) {
console.log('');
console.log('Restart your shell or run: exec zsh');
} 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 {
console.error(`✗ ${result.message}`);
@@ -179,8 +213,18 @@ 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 ~/.zshrc?',
message: `Remove OpenSpec configuration from ${configPath}?`,
default: false,
});
+8 -331
View File
@@ -4,17 +4,11 @@ import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progre
import { Validator } from './validation/validator.js';
import chalk from 'chalk';
import {
extractRequirementsSection,
parseDeltaSpec,
normalizeRequirementName,
type RequirementBlock,
} from './parsers/requirement-blocks.js';
interface SpecUpdate {
source: string;
target: string;
exists: boolean;
}
findSpecUpdates,
buildUpdatedSpec,
writeUpdatedSpec,
type SpecUpdate,
} from './specs-apply.js';
export class ArchiveCommand {
async execute(
@@ -167,7 +161,7 @@ export class ArchiveCommand {
console.log('Skipping spec updates (--skip-specs flag provided).');
} else {
// Find specs to update
const specUpdates = await this.findSpecUpdates(changeDir, mainSpecsDir);
const specUpdates = await findSpecUpdates(changeDir, mainSpecsDir);
if (specUpdates.length > 0) {
console.log('\nSpecs to update:');
@@ -194,7 +188,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 this.buildUpdatedSpec(update, changeName!);
const built = await buildUpdatedSpec(update, changeName!);
prepared.push({ update, rebuilt: built.rebuilt, counts: built.counts });
}
} catch (err: any) {
@@ -219,7 +213,7 @@ export class ArchiveCommand {
return;
}
}
await this.writeUpdatedSpec(p.update, p.rebuilt, p.counts);
await writeUpdatedSpec(p.update, p.rebuilt, p.counts);
totals.added += p.counts.added;
totals.modified += p.counts.modified;
totals.removed += p.counts.removed;
@@ -301,323 +295,6 @@ 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];
@@ -99,6 +99,8 @@ 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[];
}
@@ -252,6 +254,10 @@ 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);
@@ -290,6 +296,7 @@ export function formatChangeStatus(context: ChangeContext): ChangeStatus {
changeName: context.changeName,
schemaName: context.schemaName,
isComplete: context.graph.isComplete(context.completed),
applyRequires,
artifacts: artifactStatuses,
};
}
+7 -1
View File
@@ -284,7 +284,13 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
description: 'Uninstall completion script for a shell',
acceptsPositional: true,
positionalType: 'shell',
flags: [],
flags: [
{
name: 'yes',
short: 'y',
description: 'Skip confirmation prompts',
},
],
},
],
},
+37 -5
View File
@@ -1,8 +1,31 @@
import { CompletionGenerator } from './types.js';
import { ZshGenerator } from './generators/zsh-generator.js';
import { ZshInstaller, InstallationResult } from './installers/zsh-installer.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 { 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
*/
@@ -11,15 +34,12 @@ 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'];
private static readonly SUPPORTED_SHELLS: SupportedShell[] = ['zsh', 'bash', 'fish', 'powershell'];
/**
* Create a completion generator for the specified shell
@@ -32,6 +52,12 @@ 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}`);
}
@@ -48,6 +74,12 @@ 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}`);
}
@@ -0,0 +1,191 @@
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) {
// First, check if user is typing a flag for the parent command
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('');
}
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, '\\$&');
}
}
@@ -0,0 +1,188 @@
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
}
}
@@ -0,0 +1,214 @@
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) {
// First, check if user is typing a flag for the parent command
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('');
}
// 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
}
}
+48 -141
View File
@@ -1,4 +1,5 @@
import { CompletionGenerator, CommandDefinition, FlagDefinition } from '../types.js';
import { ZSH_DYNAMIC_HELPERS } from '../templates/zsh-templates.js';
/**
* Generates Zsh completion scripts for the OpenSpec CLI.
@@ -14,163 +15,69 @@ export class ZshGenerator implements CompletionGenerator {
* @returns Zsh completion script as a string
*/
generate(commands: CommandDefinition[]): 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=(');
// Build command list using push() for loop clarity
const commandLines: string[] = [];
for (const cmd of commands) {
const escapedDesc = this.escapeDescription(cmd.description);
script.push(` '${cmd.name}:${escapedDesc}'`);
commandLines.push(` '${cmd.name}:${escapedDesc}'`);
}
script.push(' )');
script.push('');
const commandList = commandLines.join('\n');
// 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
// Build command cases using push() for loop clarity
const commandCaseLines: string[] = [];
for (const cmd of commands) {
script.push(` ${cmd.name})`);
script.push(` _openspec_${this.sanitizeFunctionName(cmd.name)}`);
script.push(' ;;');
commandCaseLines.push(` ${cmd.name})`);
commandCaseLines.push(` _openspec_${this.sanitizeFunctionName(cmd.name)}`);
commandCaseLines.push(' ;;');
}
const commandCases = commandCaseLines.join('\n');
script.push(' esac');
script.push(' ;;');
script.push(' esac');
script.push('}');
script.push('');
// Generate individual command completion functions
// Build command functions using push() for loop clarity
const commandFunctionLines: string[] = [];
for (const cmd of commands) {
script.push(...this.generateCommandFunction(cmd));
script.push('');
commandFunctionLines.push(...this.generateCommandFunction(cmd));
commandFunctionLines.push('');
}
const commandFunctions = commandFunctionLines.join('\n');
// Add dynamic completion helper functions
script.push(...this.generateDynamicCompletionHelpers());
// Dynamic completion helpers from template
const helpers = ZSH_DYNAMIC_HELPERS;
// Register the completion function
script.push('compdef _openspec openspec');
script.push('');
// Assemble final script with template literal
return `#compdef openspec
return script.join('\n');
}
# Zsh completion script for OpenSpec CLI
# Auto-generated - do not edit manually
/**
* 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[] = [];
_openspec() {
local context state line
typeset -A opt_args
if (comment) {
lines.push(comment);
}
local -a commands
commands=(
${commandList}
)
lines.push(`${functionName}() {`);
lines.push(` local -a ${varName}`);
_arguments -C \\
"1: :->command" \\
"*::arg:->args"
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(' )');
}
case $state in
command)
_describe "openspec command" commands
;;
args)
case $words[1] in
${commandCases}
esac
;;
esac
}
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;
${commandFunctions}
${helpers}
compdef _openspec openspec
`;
}
/**
@@ -337,7 +244,7 @@ export class ZshGenerator implements CompletionGenerator {
case 'path':
return "'*:path:_files'";
case 'shell':
return "'*:shell:(zsh)'";
return "'*:shell:(zsh bash fish powershell)'";
default:
return "'*: :_default'";
}
@@ -0,0 +1,366 @@
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)}`,
};
}
}
}
@@ -0,0 +1,152 @@
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)}`,
};
}
}
}
@@ -0,0 +1,358 @@
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,19 +2,7 @@ import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { FileSystemUtils } from '../../../utils/file-system.js';
/**
* Installation result information
*/
export interface InstallationResult {
success: boolean;
installedPath?: string;
backupPath?: string;
isOhMyZsh: boolean;
zshrcConfigured?: boolean;
message: string;
instructions?: string[];
}
import { InstallationResult } from '../factory.js';
/**
* Installer for Zsh completion scripts.
@@ -0,0 +1,24 @@
/**
* 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"))
}`;
@@ -0,0 +1,40 @@
/**
* 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`;
@@ -0,0 +1,25 @@
/**
* 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]
}
}
}
`;
@@ -0,0 +1,36 @@
/**
* 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
}`;
+6 -9
View File
@@ -10,21 +10,18 @@ const FILE_PATHS: Record<SlashCommandId, string> = {
const FRONTMATTER: Record<SlashCommandId, string> = {
proposal: `---
name: OpenSpec: Proposal
description: Scaffold a new OpenSpec change and validate strictly.
category: OpenSpec
tags: [openspec, change]
description: "Scaffold a new OpenSpec change and validate strictly."
argument-hint: "[feature description or request]"
---`,
apply: `---
name: OpenSpec: Apply
description: Implement an approved OpenSpec change and keep tasks in sync.
category: OpenSpec
tags: [openspec, apply]
description: "Implement an approved OpenSpec change and keep tasks in sync."
argument-hint: "[change-id]"
---`,
archive: `---
name: OpenSpec: Archive
description: Archive a deployed OpenSpec change and update specs.
category: OpenSpec
tags: [openspec, archive]
description: "Archive a deployed OpenSpec change and update specs."
argument-hint: "[change-id]"
---`
};
+483
View File
@@ -0,0 +1,483 @@
/**
* 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
+85
View File
@@ -0,0 +1,85 @@
/**
* Global configuration for telemetry state.
* Stores anonymous ID and notice-seen flag in ~/.config/openspec/config.json
*/
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
export interface TelemetryConfig {
anonymousId?: string;
noticeSeen?: boolean;
}
export interface GlobalConfig {
telemetry?: TelemetryConfig;
[key: string]: unknown; // Preserve other fields
}
/**
* Get the path to the global config file.
* Uses ~/.config/openspec/config.json on all platforms.
*/
export function getConfigPath(): string {
const configDir = path.join(os.homedir(), '.config', 'openspec');
return path.join(configDir, 'config.json');
}
/**
* Read the global config file.
* Returns an empty object if the file doesn't exist.
*/
export async function readConfig(): Promise<GlobalConfig> {
const configPath = getConfigPath();
try {
const content = await fs.readFile(configPath, 'utf-8');
return JSON.parse(content) as GlobalConfig;
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return {};
}
// If parse fails or other error, return empty config
return {};
}
}
/**
* Write to the global config file.
* Preserves existing fields and merges in new values.
*/
export async function writeConfig(updates: Partial<GlobalConfig>): Promise<void> {
const configPath = getConfigPath();
const configDir = path.dirname(configPath);
// Ensure directory exists
await fs.mkdir(configDir, { recursive: true });
// Read existing config and merge
const existing = await readConfig();
const merged = { ...existing, ...updates };
// Deep merge for telemetry object
if (updates.telemetry && existing.telemetry) {
merged.telemetry = { ...existing.telemetry, ...updates.telemetry };
}
await fs.writeFile(configPath, JSON.stringify(merged, null, 2) + '\n');
}
/**
* Get the telemetry config section.
*/
export async function getTelemetryConfig(): Promise<TelemetryConfig> {
const config = await readConfig();
return config.telemetry ?? {};
}
/**
* Update the telemetry config section.
*/
export async function updateTelemetryConfig(updates: Partial<TelemetryConfig>): Promise<void> {
const existing = await getTelemetryConfig();
await writeConfig({
telemetry: { ...existing, ...updates },
});
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Telemetry module for anonymous usage analytics.
*
* Privacy-first design:
* - Only tracks command name and version
* - No arguments, file paths, or content
* - Opt-out via OPENSPEC_TELEMETRY=0 or DO_NOT_TRACK=1
* - Auto-disabled in CI environments
* - Anonymous ID is a random UUID with no relation to the user
*/
import { PostHog } from 'posthog-node';
import { randomUUID } from 'crypto';
import { getTelemetryConfig, updateTelemetryConfig } from './config.js';
// PostHog API key - public key for client-side analytics
// This is safe to embed as it only allows sending events, not reading data
const POSTHOG_API_KEY = 'phc_Hthu8YvaIJ9QaFKyTG4TbVwkbd5ktcAFzVTKeMmoW2g';
// Using reverse proxy to avoid ad blockers and keep traffic on our domain
const POSTHOG_HOST = 'https://edge.openspec.dev';
let posthogClient: PostHog | null = null;
let anonymousId: string | null = null;
/**
* Check if telemetry is enabled.
*
* Disabled when:
* - OPENSPEC_TELEMETRY=0
* - DO_NOT_TRACK=1
* - CI=true (any CI environment)
*/
export function isTelemetryEnabled(): boolean {
// Check explicit opt-out
if (process.env.OPENSPEC_TELEMETRY === '0') {
return false;
}
// Respect DO_NOT_TRACK standard
if (process.env.DO_NOT_TRACK === '1') {
return false;
}
// Auto-disable in CI environments
if (process.env.CI === 'true') {
return false;
}
return true;
}
/**
* Get or create the anonymous user ID.
* Lazily generates a UUID on first call and persists it.
*/
export async function getOrCreateAnonymousId(): Promise<string> {
// Return cached value if available
if (anonymousId) {
return anonymousId;
}
// Try to load from config
const config = await getTelemetryConfig();
if (config.anonymousId) {
anonymousId = config.anonymousId;
return anonymousId;
}
// Generate new UUID and persist
anonymousId = randomUUID();
await updateTelemetryConfig({ anonymousId });
return anonymousId;
}
/**
* Get the PostHog client instance.
* Creates it on first call with CLI-optimized settings.
*/
function getClient(): PostHog {
if (!posthogClient) {
posthogClient = new PostHog(POSTHOG_API_KEY, {
host: POSTHOG_HOST,
flushAt: 1, // Send immediately, don't batch
flushInterval: 0, // No timer-based flushing
});
}
return posthogClient;
}
/**
* Track a command execution.
*
* @param commandName - The command name (e.g., 'init', 'change:apply')
* @param version - The OpenSpec version
*/
export async function trackCommand(commandName: string, version: string): Promise<void> {
if (!isTelemetryEnabled()) {
return;
}
try {
const userId = await getOrCreateAnonymousId();
const client = getClient();
client.capture({
distinctId: userId,
event: 'command_executed',
properties: {
command: commandName,
version: version,
surface: 'cli',
$ip: null, // Explicitly disable IP tracking
},
});
} catch {
// Silent failure - telemetry should never break CLI
}
}
/**
* Show first-run telemetry notice if not already seen.
*/
export async function maybeShowTelemetryNotice(): Promise<void> {
if (!isTelemetryEnabled()) {
return;
}
try {
const config = await getTelemetryConfig();
if (config.noticeSeen) {
return;
}
// Display notice
console.log(
'Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0'
);
// Mark as seen
await updateTelemetryConfig({ noticeSeen: true });
} catch {
// Silent failure - telemetry should never break CLI
}
}
/**
* Shutdown the PostHog client and flush pending events.
* Call this before CLI exit.
*/
export async function shutdown(): Promise<void> {
if (!posthogClient) {
return;
}
try {
await posthogClient.shutdown();
} catch {
// Silent failure - telemetry should never break CLI exit
} finally {
posthogClient = null;
}
}
+45 -2
View File
@@ -93,6 +93,41 @@ 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);
@@ -111,10 +146,18 @@ export class FileSystemUtils {
}
} catch (error: any) {
if (error.code === 'ENOENT') {
// File doesn't exist; check if we can write to the parent directory
// File doesn't exist - find first existing parent directory and check its permissions
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(parentDir, fsConstants.W_OK);
await fs.access(existingDir, fsConstants.W_OK);
return true;
} catch {
return false;
+8 -8
View File
@@ -78,10 +78,10 @@ describe('CompletionCommand', () => {
});
it('should show error for unsupported shell', async () => {
await command.generate({ shell: 'bash' });
await command.generate({ shell: 'tcsh' });
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error: Shell 'bash' is not supported yet. Currently supported: zsh"
"Error: Shell 'tcsh' is not supported yet. Currently supported: zsh, bash, fish, powershell"
);
expect(process.exitCode).toBe(1);
});
@@ -135,10 +135,10 @@ describe('CompletionCommand', () => {
});
it('should show error for unsupported shell', async () => {
await command.install({ shell: 'fish' });
await command.install({ shell: 'tcsh' });
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error: Shell 'fish' is not supported yet. Currently supported: zsh"
"Error: Shell 'tcsh' is not supported yet. Currently supported: zsh, bash, fish, powershell"
);
expect(process.exitCode).toBe(1);
});
@@ -184,10 +184,10 @@ describe('CompletionCommand', () => {
});
it('should show error for unsupported shell', async () => {
await command.uninstall({ shell: 'powershell', yes: true });
await command.uninstall({ shell: 'tcsh', yes: true });
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error: Shell 'powershell' is not supported yet. Currently supported: zsh"
"Error: Shell 'tcsh' is not supported yet. Currently supported: zsh, bash, fish, powershell"
);
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: 'bash' });
vi.mocked(shellDetection.detectShell).mockReturnValue({ shell: undefined, detected: 'tcsh' });
await command.generate({});
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Error: Shell 'bash' is not supported yet. Currently supported: zsh"
"Error: Shell 'tcsh' is not supported yet. Currently supported: zsh, bash, fish, powershell"
);
expect(process.exitCode).toBe(1);
});
@@ -0,0 +1,569 @@
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 offer parent flags when command has both flags and subcommands', () => {
const commands: CommandDefinition[] = [
{
name: 'config',
description: 'Manage configuration',
flags: [
{
name: 'scope',
short: 's',
description: 'Configuration scope',
},
{
name: 'json',
description: 'Output as JSON',
},
],
subcommands: [
{
name: 'set',
description: 'Set a config value',
flags: [],
},
{
name: 'get',
description: 'Get a config value',
flags: [],
},
],
},
];
const script = generator.generate(commands);
// Should check for flag prefix before offering subcommands
expect(script).toContain('if [[ "$cur" == -* ]]; then');
// Should include parent command flags
expect(script).toContain('-s');
expect(script).toContain('--scope');
expect(script).toContain('--json');
// Should also include subcommands
expect(script).toContain('set');
expect(script).toContain('get');
});
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');
});
});
});
@@ -0,0 +1,532 @@
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
});
});
});
@@ -0,0 +1,576 @@
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 offer parent flags when command has both flags and subcommands', () => {
const commands: CommandDefinition[] = [
{
name: 'config',
description: 'Manage configuration',
flags: [
{
name: 'scope',
short: 's',
description: 'Configuration scope',
},
{
name: 'json',
description: 'Output as JSON',
},
],
subcommands: [
{
name: 'set',
description: 'Set a config value',
flags: [],
},
{
name: 'get',
description: 'Get a config value',
flags: [],
},
],
},
];
const script = generator.generate(commands);
// Should check for flag prefix before offering subcommands
expect(script).toContain('if ($wordToComplete -like "-*")');
// Should include parent command flags
expect(script).toContain('-s');
expect(script).toContain('--scope');
expect(script).toContain('--json');
// Should also include subcommands
expect(script).toContain('"set"');
expect(script).toContain('"get"');
});
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
});
});
});
@@ -0,0 +1,484 @@
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 a temporary file and use its path as homeDir
// This guarantees ENOTDIR when trying to create subdirectories (cross-platform)
const blockingFile = path.join(testHomeDir, 'blocking-file');
await fs.writeFile(blockingFile, 'blocking content');
const invalidInstaller = new BashInstaller(blockingFile);
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 a temporary file and use its path as homeDir
// This guarantees ENOTDIR when trying to write .bashrc (cross-platform)
const blockingFile = path.join(testHomeDir, 'blocking-file');
await fs.writeFile(blockingFile, 'blocking content');
const invalidInstaller = new BashInstaller(blockingFile);
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();
});
});
});
@@ -0,0 +1,321 @@
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');
});
});
});
@@ -0,0 +1,657 @@
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('category: OpenSpec');
expect(proposalContent).toContain('description: "Scaffold a new OpenSpec change and validate strictly."');
expect(proposalContent).toContain('argument-hint: "[feature description or request]"');
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');
});
+185
View File
@@ -0,0 +1,185 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {
getConfigPath,
readConfig,
writeConfig,
getTelemetryConfig,
updateTelemetryConfig,
} from '../../src/telemetry/config.js';
describe('telemetry/config', () => {
let tempDir: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
beforeEach(() => {
// Create temp directory for tests
tempDir = path.join(os.tmpdir(), `openspec-telemetry-test-${Date.now()}`);
fs.mkdirSync(tempDir, { recursive: true });
// Mock HOME/USERPROFILE to point to temp dir
// On POSIX, os.homedir() uses HOME; on Windows it uses USERPROFILE
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
process.env.HOME = tempDir;
process.env.USERPROFILE = tempDir;
});
afterEach(() => {
// Restore HOME/USERPROFILE
process.env.HOME = originalHome;
process.env.USERPROFILE = originalUserProfile;
// Clean up temp directory
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('getConfigPath', () => {
it('should return path to config.json in .config/openspec', () => {
const result = getConfigPath();
expect(result).toBe(path.join(tempDir, '.config', 'openspec', 'config.json'));
});
});
describe('readConfig', () => {
it('should return empty object when config file does not exist', async () => {
const config = await readConfig();
expect(config).toEqual({});
});
it('should load valid config from file', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
const configPath = path.join(configDir, 'config.json');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
telemetry: { anonymousId: 'test-id', noticeSeen: true }
}));
const config = await readConfig();
expect(config.telemetry).toEqual({ anonymousId: 'test-id', noticeSeen: true });
});
it('should return empty object for invalid JSON', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
const configPath = path.join(configDir, 'config.json');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, '{ invalid json }');
const config = await readConfig();
expect(config).toEqual({});
});
});
describe('writeConfig', () => {
it('should create directory if it does not exist', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
await writeConfig({ telemetry: { noticeSeen: true } });
expect(fs.existsSync(configDir)).toBe(true);
});
it('should write config to file', async () => {
const configPath = path.join(tempDir, '.config', 'openspec', 'config.json');
await writeConfig({ telemetry: { anonymousId: 'test-123' } });
const content = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.telemetry.anonymousId).toBe('test-123');
});
it('should preserve existing fields when updating', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
const configPath = path.join(configDir, 'config.json');
// Create initial config with other fields
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
existingField: 'preserved',
telemetry: { anonymousId: 'old-id' }
}));
// Update telemetry
await writeConfig({ telemetry: { noticeSeen: true } });
const content = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.existingField).toBe('preserved');
expect(parsed.telemetry.noticeSeen).toBe(true);
});
it('should deep merge telemetry fields', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
const configPath = path.join(configDir, 'config.json');
// Create initial config
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
telemetry: { anonymousId: 'existing-id' }
}));
// Update with noticeSeen only
await writeConfig({ telemetry: { noticeSeen: true } });
const content = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.telemetry.anonymousId).toBe('existing-id');
expect(parsed.telemetry.noticeSeen).toBe(true);
});
});
describe('getTelemetryConfig', () => {
it('should return empty object when no config exists', async () => {
const config = await getTelemetryConfig();
expect(config).toEqual({});
});
it('should return telemetry section from config', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
const configPath = path.join(configDir, 'config.json');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
telemetry: { anonymousId: 'my-id', noticeSeen: false }
}));
const config = await getTelemetryConfig();
expect(config).toEqual({ anonymousId: 'my-id', noticeSeen: false });
});
});
describe('updateTelemetryConfig', () => {
it('should create telemetry config when none exists', async () => {
await updateTelemetryConfig({ anonymousId: 'new-id' });
const configPath = path.join(tempDir, '.config', 'openspec', 'config.json');
const content = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.telemetry.anonymousId).toBe('new-id');
});
it('should merge with existing telemetry config', async () => {
const configDir = path.join(tempDir, '.config', 'openspec');
const configPath = path.join(configDir, 'config.json');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({
telemetry: { anonymousId: 'existing-id' }
}));
await updateTelemetryConfig({ noticeSeen: true });
const content = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(content);
expect(parsed.telemetry.anonymousId).toBe('existing-id');
expect(parsed.telemetry.noticeSeen).toBe(true);
});
});
});
+135
View File
@@ -0,0 +1,135 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { randomUUID } from 'node:crypto';
// Mock posthog-node before importing the module
vi.mock('posthog-node', () => {
return {
PostHog: vi.fn().mockImplementation(() => ({
capture: vi.fn(),
shutdown: vi.fn().mockResolvedValue(undefined),
})),
};
});
// Import after mocking
import { isTelemetryEnabled, maybeShowTelemetryNotice, shutdown, trackCommand } from '../../src/telemetry/index.js';
import { PostHog } from 'posthog-node';
describe('telemetry/index', () => {
let tempDir: string;
let originalEnv: NodeJS.ProcessEnv;
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Create unique temp directory for each test using UUID
tempDir = path.join(os.tmpdir(), `openspec-telemetry-test-${randomUUID()}`);
fs.mkdirSync(tempDir, { recursive: true });
// Save original env
originalEnv = { ...process.env };
// Mock HOME to point to temp dir
process.env.HOME = tempDir;
// Clear all mocks
vi.clearAllMocks();
// Spy on console.log for notice tests
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
// Restore original env
process.env = originalEnv;
// Clean up temp directory
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
// Restore all mocks
vi.restoreAllMocks();
});
describe('isTelemetryEnabled', () => {
it('should return false when OPENSPEC_TELEMETRY=0', () => {
process.env.OPENSPEC_TELEMETRY = '0';
expect(isTelemetryEnabled()).toBe(false);
});
it('should return false when DO_NOT_TRACK=1', () => {
process.env.DO_NOT_TRACK = '1';
expect(isTelemetryEnabled()).toBe(false);
});
it('should return false when CI=true', () => {
process.env.CI = 'true';
expect(isTelemetryEnabled()).toBe(false);
});
it('should return true when no opt-out is set', () => {
delete process.env.OPENSPEC_TELEMETRY;
delete process.env.DO_NOT_TRACK;
delete process.env.CI;
expect(isTelemetryEnabled()).toBe(true);
});
it('should prioritize OPENSPEC_TELEMETRY=0 over other settings', () => {
process.env.OPENSPEC_TELEMETRY = '0';
delete process.env.DO_NOT_TRACK;
delete process.env.CI;
expect(isTelemetryEnabled()).toBe(false);
});
});
describe('maybeShowTelemetryNotice', () => {
it('should not show notice when telemetry is disabled', async () => {
process.env.OPENSPEC_TELEMETRY = '0';
await maybeShowTelemetryNotice();
expect(consoleLogSpy).not.toHaveBeenCalled();
});
});
describe('trackCommand', () => {
it('should not track when telemetry is disabled', async () => {
process.env.OPENSPEC_TELEMETRY = '0';
await trackCommand('test', '1.0.0');
expect(PostHog).not.toHaveBeenCalled();
});
it('should track when telemetry is enabled', async () => {
delete process.env.OPENSPEC_TELEMETRY;
delete process.env.DO_NOT_TRACK;
delete process.env.CI;
await trackCommand('test', '1.0.0');
expect(PostHog).toHaveBeenCalled();
});
});
describe('shutdown', () => {
it('should not throw when no client exists', async () => {
await expect(shutdown()).resolves.not.toThrow();
});
it('should handle shutdown errors silently', async () => {
const mockPostHog = {
capture: vi.fn(),
shutdown: vi.fn().mockRejectedValue(new Error('Network error')),
};
(PostHog as any).mockImplementation(() => mockPostHog);
await expect(shutdown()).resolves.not.toThrow();
});
});
});
+93
View File
@@ -161,6 +161,99 @@ describe('FileSystemUtils', () => {
});
});
describe('canWriteFile', () => {
it('should return true for existing writable file', async () => {
const filePath = path.join(testDir, 'writable.txt');
await fs.writeFile(filePath, 'content');
const canWrite = await FileSystemUtils.canWriteFile(filePath);
expect(canWrite).toBe(true);
});
it('should return false for existing read-only file', async () => {
const filePath = path.join(testDir, 'readonly.txt');
await fs.writeFile(filePath, 'content');
await fs.chmod(filePath, 0o444); // Read-only
const canWrite = await FileSystemUtils.canWriteFile(filePath);
expect(canWrite).toBe(false);
// Cleanup: restore permissions so afterEach can delete
await fs.chmod(filePath, 0o644);
});
it('should return true for non-existent file in writable directory', async () => {
const filePath = path.join(testDir, 'new-file.txt');
const canWrite = await FileSystemUtils.canWriteFile(filePath);
expect(canWrite).toBe(true);
});
it('should return true for non-existent file in non-existent nested directories', async () => {
const filePath = path.join(testDir, 'deep', 'nested', 'path', 'file.txt');
const canWrite = await FileSystemUtils.canWriteFile(filePath);
expect(canWrite).toBe(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 false for non-existent file in read-only directory', async () => {
const readOnlyDir = path.join(testDir, 'readonly-dir');
await fs.mkdir(readOnlyDir);
await fs.chmod(readOnlyDir, 0o555); // Read-only + execute
const filePath = path.join(readOnlyDir, 'file.txt');
const canWrite = await FileSystemUtils.canWriteFile(filePath);
expect(canWrite).toBe(false);
// Cleanup
await fs.chmod(readOnlyDir, 0o755);
});
it('should return true when path points to existing directory', async () => {
const dirPath = path.join(testDir, 'some-dir');
await fs.mkdir(dirPath);
const canWrite = await FileSystemUtils.canWriteFile(dirPath);
expect(canWrite).toBe(true);
});
it('should traverse multiple non-existent parent directories', async () => {
const filePath = path.join(testDir, 'a', 'b', 'c', 'd', 'e', 'file.txt');
const canWrite = await FileSystemUtils.canWriteFile(filePath);
expect(canWrite).toBe(true);
});
it('should return false when intermediate path component is a file', async () => {
// Create a file where a directory should be
const fileInPath = path.join(testDir, 'blocking-file.txt');
await fs.writeFile(fileInPath, 'content');
// Try to check a path that goes "through" this file
const filePath = path.join(fileInPath, 'nested', 'file.txt');
const canWrite = await FileSystemUtils.canWriteFile(filePath);
expect(canWrite).toBe(false);
});
it('should follow symbolic links to files', async () => {
const realFile = path.join(testDir, 'real-file.txt');
const linkFile = path.join(testDir, 'link-file.txt');
await fs.writeFile(realFile, 'content');
await fs.symlink(realFile, linkFile);
const canWrite = await FileSystemUtils.canWriteFile(linkFile);
expect(canWrite).toBe(true);
});
it('should handle platform-specific path separators', async () => {
const filePath = FileSystemUtils.joinPath(testDir, 'subdir', 'file.txt');
const canWrite = await FileSystemUtils.canWriteFile(filePath);
expect(canWrite).toBe(true);
});
});
describe('joinPath', () => {
it('should join POSIX-style paths', () => {
const result = FileSystemUtils.joinPath(