mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-03 22:13:19 +08:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03ea32369a | ||
|
|
f39cc5c1fb | ||
|
|
5129a8cf96 | ||
|
|
4ff893048d | ||
|
|
cefb4719aa | ||
|
|
5e1cef3b3b | ||
|
|
1adf3cea88 | ||
|
|
6d3cfe0443 | ||
|
|
17d1e5db3f | ||
|
|
3f5a66d3e4 | ||
|
|
c08fbc1ba0 | ||
|
|
938d03be9a | ||
|
|
19ccaabfc7 | ||
|
|
2e382b9898 | ||
|
|
b5a7d096f0 | ||
|
|
c54079a0cd | ||
|
|
1050e57ae4 | ||
|
|
17d7e59343 | ||
|
|
4758c5c68d | ||
|
|
c4b0826da7 | ||
|
|
537e6078b7 | ||
|
|
5439ab0833 | ||
|
|
9b6a763eb8 | ||
|
|
d32e50fe36 | ||
|
|
8386b91a71 | ||
|
|
8f9c3c7d0b | ||
|
|
9cdb0743f2 | ||
|
|
4e93d7a881 | ||
|
|
c4b6be41c1 | ||
|
|
a66580735c | ||
|
|
fb1d37e56e | ||
|
|
cf0de5e569 | ||
|
|
92b45462c6 | ||
|
|
5ab438f5fd | ||
|
|
5855fa2353 | ||
|
|
668a125d4d | ||
|
|
fef961f6e3 | ||
|
|
3677e0175f | ||
|
|
3ddf2586b4 | ||
|
|
ece61a6d68 | ||
|
|
ecddffc22e |
@@ -0,0 +1,11 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
# Minimal configuration for getting started
|
||||
language: "en-US"
|
||||
reviews:
|
||||
profile: "chill"
|
||||
high_level_summary: true
|
||||
auto_review:
|
||||
enabled: true
|
||||
drafts: false
|
||||
base_branches:
|
||||
- ".*"
|
||||
+1
-1
@@ -149,4 +149,4 @@ CLAUDE.md
|
||||
.DS_Store
|
||||
|
||||
# Pnpm
|
||||
.pnpm-store/
|
||||
.pnpm-store/
|
||||
|
||||
@@ -1,5 +1,80 @@
|
||||
# @fission-ai/openspec
|
||||
|
||||
## 0.16.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c08fbc1: Add new AI tool integrations and enhancements:
|
||||
|
||||
- **feat(iflow-cli)**: Add iFlow-cli integration with slash command support and documentation
|
||||
- **feat(init)**: Add IDE restart instruction after init to inform users about slash command availability
|
||||
**feat(antigravity)**: Add Antigravity slash command support
|
||||
- **fix**: Generate TOML commands for Qwen Code (fixes #293)
|
||||
- Clarify scaffold proposal documentation and enhance proposal guidelines
|
||||
- Update proposal guidelines to emphasize design-first approach before implementation
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add Antigravity slash command support so `openspec init` can generate `.agent/workflows/openspec-*.md` files with description-only frontmatter and `openspec update` refreshes existing workflows alongside Windsurf.
|
||||
|
||||
## 0.15.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 4758c5c: Add support for new AI tools with native slash command integration
|
||||
|
||||
- **Gemini CLI**: Add native TOML-based slash command support for Gemini CLI with `.gemini/commands/openspec/` integration
|
||||
- **RooCode**: Add RooCode integration with configurator, slash commands, and templates
|
||||
- **Cline**: Fix Cline to use workflows instead of rules for slash commands (`.clinerules/workflows/` paths)
|
||||
- **Documentation**: Update documentation to reflect new integrations and workflow changes
|
||||
|
||||
## 0.14.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 8386b91: Add support for new AI assistants and configuration improvements
|
||||
|
||||
- feat: add Qwen Code support with slash command integration
|
||||
- feat: add $ARGUMENTS support to apply slash command for dynamic variable passing
|
||||
- feat: add Qoder CLI support to configuration and documentation
|
||||
- feat: add CoStrict AI assistant support
|
||||
- fix: recreate missing openspec template files in extend mode
|
||||
- fix: prevent false 'already configured' detection for tools
|
||||
- fix: use change-id as fallback title instead of "Untitled Change"
|
||||
- docs: add guidance for populating project-level context
|
||||
- docs: add Crush to supported AI tools in README
|
||||
|
||||
## 0.13.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 668a125: Add support for multiple AI assistants and improve validation
|
||||
|
||||
This release adds support for several new AI coding assistants:
|
||||
|
||||
- CodeBuddy Code - AI-powered coding assistant
|
||||
- CodeRabbit - AI code review assistant
|
||||
- Cline - Claude-powered CLI assistant
|
||||
- Crush AI - AI assistant platform
|
||||
- Auggie (Augment CLI) - Code augmentation tool
|
||||
|
||||
New features:
|
||||
|
||||
- Archive slash command now supports arguments for more flexible workflows
|
||||
|
||||
Bug fixes:
|
||||
|
||||
- Delta spec validation now handles case-insensitive headers and properly detects empty sections
|
||||
- Archive validation now correctly honors --no-validate flag and ignores metadata
|
||||
|
||||
Documentation improvements:
|
||||
|
||||
- Added VS Code dev container configuration for easier development setup
|
||||
- Updated AGENTS.md with explicit change-id notation
|
||||
- Enhanced slash commands documentation with restart notes
|
||||
|
||||
## 0.12.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -85,30 +85,48 @@ See the full comparison in [How OpenSpec Compares](#how-openspec-compares).
|
||||
|
||||
### Supported AI Tools
|
||||
|
||||
#### Native Slash Commands
|
||||
<details>
|
||||
<summary><strong>Native Slash Commands</strong> (click to expand)</summary>
|
||||
|
||||
These tools have built-in OpenSpec commands. Select the OpenSpec integration when prompted.
|
||||
|
||||
| Tool | Commands |
|
||||
|------|----------|
|
||||
| **Amazon Q Developer** | `@openspec-proposal`, `@openspec-apply`, `@openspec-archive` (`.amazonq/prompts/`) |
|
||||
| **Antigravity** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.agent/workflows/`) |
|
||||
| **Auggie (Augment CLI)** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.augment/commands/`) |
|
||||
| **Claude Code** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` |
|
||||
| **Cline** | Workflows in `.clinerules/workflows/` directory (`.clinerules/workflows/openspec-*.md`) |
|
||||
| **CodeBuddy Code (CLI)** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.codebuddy/commands/`) — see [docs](https://www.codebuddy.ai/cli) |
|
||||
| **Codex** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (global: `~/.codex/prompts`, auto-installed) |
|
||||
| **CoStrict** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.cospec/openspec/commands/`) — see [docs](https://costrict.ai)|
|
||||
| **Crush** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.crush/commands/openspec/`) |
|
||||
| **Cursor** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
|
||||
| **Factory Droid** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.factory/commands/`) |
|
||||
| **OpenCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
|
||||
| **Kilo Code** | `/openspec-proposal.md`, `/openspec-apply.md`, `/openspec-archive.md` (`.kilocode/workflows/`) |
|
||||
| **Windsurf** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.windsurf/workflows/`) |
|
||||
| **Codex** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (global: `~/.codex/prompts`, auto-installed) |
|
||||
| **Gemini CLI** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.gemini/commands/openspec/`) |
|
||||
| **GitHub Copilot** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.github/prompts/`) |
|
||||
| **Amazon Q Developer** | `@openspec-proposal`, `@openspec-apply`, `@openspec-archive` (`.amazonq/prompts/`) |
|
||||
| **Auggie (Augment CLI)** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.augment/commands/`) |
|
||||
| **iFlow (iflow-cli)** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.iflow/commands/`) |
|
||||
| **Kilo Code** | `/openspec-proposal.md`, `/openspec-apply.md`, `/openspec-archive.md` (`.kilocode/workflows/`) |
|
||||
| **OpenCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
|
||||
| **Qoder (CLI)** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.qoder/commands/openspec/`) — see [docs](https://qoder.com/cli) |
|
||||
| **Qwen Code** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.qwen/commands/`) |
|
||||
| **RooCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.roo/commands/`) |
|
||||
| **Windsurf** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.windsurf/workflows/`) |
|
||||
|
||||
Kilo Code discovers team workflows automatically. Save the generated files under `.kilocode/workflows/` and trigger them from the command palette with `/openspec-proposal.md`, `/openspec-apply.md`, or `/openspec-archive.md`.
|
||||
|
||||
#### AGENTS.md Compatible
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>AGENTS.md Compatible</strong> (click to expand)</summary>
|
||||
|
||||
These tools automatically read workflow instructions from `openspec/AGENTS.md`. Ask them to follow the OpenSpec workflow if they need a reminder. Learn more about the [AGENTS.md convention](https://agents.md/).
|
||||
|
||||
| Tools |
|
||||
|-------|
|
||||
| Amp • Jules • Gemini CLI • Others |
|
||||
| Amp • Jules • Others |
|
||||
|
||||
</details>
|
||||
|
||||
### Install & Initialize
|
||||
|
||||
@@ -139,7 +157,7 @@ openspec init
|
||||
```
|
||||
|
||||
**What happens during initialization:**
|
||||
- You'll be prompted to pick any natively supported AI tools (Claude Code, Cursor, OpenCode, etc.); other assistants always rely on the shared `AGENTS.md` stub
|
||||
- You'll be prompted to pick any natively supported AI tools (Claude Code, CodeBuddy, Cursor, OpenCode, Qoder,etc.); other assistants always rely on the shared `AGENTS.md` stub
|
||||
- OpenSpec automatically configures slash commands for the tools you choose and always writes a managed `AGENTS.md` hand-off at the project root
|
||||
- A new `openspec/` directory structure is created in your project
|
||||
|
||||
@@ -147,7 +165,18 @@ openspec init
|
||||
- Primary AI tools can trigger `/openspec` workflows without additional configuration
|
||||
- Run `openspec list` to verify the setup and view any active changes
|
||||
- If your coding assistant doesn't surface the new slash commands right away, restart it. Slash commands are loaded at startup,
|
||||
so a fresh launch ensures they appear.
|
||||
so a fresh launch ensures they appear
|
||||
|
||||
### Optional: Populate Project Context
|
||||
|
||||
After `openspec init` completes, you'll receive a suggested prompt to help populate your project context:
|
||||
|
||||
```text
|
||||
Populate your project context:
|
||||
"Please read openspec/project.md and help me fill it out with details about my project, tech stack, and conventions"
|
||||
```
|
||||
|
||||
Use `openspec/project.md` to define project-level conventions, standards, architectural patterns, and other guidelines that should be followed across all changes.
|
||||
|
||||
### Create Your First Change
|
||||
|
||||
@@ -214,7 +243,7 @@ Or run the command yourself in terminal:
|
||||
$ openspec archive add-profile-filters --yes # Archive the completed change without prompts
|
||||
```
|
||||
|
||||
**Note:** Tools with native slash commands (Claude Code, Cursor, Codex) can use the shortcuts shown. All other tools work with natural language requests to "create an OpenSpec proposal", "apply the OpenSpec change", or "archive the change".
|
||||
**Note:** Tools with native slash commands (Claude Code, CodeBuddy, Cursor, Codex, Qoder, RooCode) can use the shortcuts shown. All other tools work with natural language requests to "create an OpenSpec proposal", "apply the OpenSpec change", or "archive the change".
|
||||
|
||||
## Command Reference
|
||||
|
||||
@@ -326,7 +355,7 @@ Without specs, AI coding assistants generate code from vague prompts, often miss
|
||||
1. **Initialize OpenSpec** – Run `openspec init` in your repo.
|
||||
2. **Start with new features** – Ask your AI to capture upcoming work as change proposals.
|
||||
3. **Grow incrementally** – Each change archives into living specs that document your system.
|
||||
4. **Stay flexible** – Different teammates can use Claude Code, Cursor, or any AGENTS.md-compatible tool while sharing the same specs.
|
||||
4. **Stay flexible** – Different teammates can use Claude Code, CodeBuddy, Cursor, or any AGENTS.md-compatible tool while sharing the same specs.
|
||||
|
||||
Run `openspec update` whenever someone switches tools so your agents pick up the latest instructions and slash-command bindings.
|
||||
|
||||
|
||||
@@ -49,9 +49,7 @@ _Outcome:_ We prevent data loss immediately while we work on a richer merge stor
|
||||
- On conflict, write conflict markers inside the change delta (similar to Git) and require the author to hand-edit before re-running validation.
|
||||
2. **Enrich validator messages.**
|
||||
- `openspec validate` should flag unresolved conflict markers or fingerprint mismatches so errors appear early in the workflow.
|
||||
3. **Improve diff tooling.**
|
||||
- Extend `openspec diff` to compare change deltas against the live spec and highlight pending merges.
|
||||
4. **Optional:** Offer a `--rewrite-scenarios` helper that merges bullet lists of scenarios to reduce manual editing noise.
|
||||
3. **Optional:** Offer a `--rewrite-scenarios` helper that merges bullet lists of scenarios to reduce manual editing noise.
|
||||
|
||||
_Outcome:_ Contributors can safely reconcile their work with the latest spec before archiving, restoring true parallel development.
|
||||
|
||||
|
||||
@@ -95,7 +95,6 @@ After deployment, create separate PR to:
|
||||
openspec list # List active changes
|
||||
openspec list --specs # List specifications
|
||||
openspec show [item] # Display change or spec
|
||||
openspec diff [change] # Show spec differences
|
||||
openspec validate [item] # Validate changes or specs
|
||||
openspec archive <change-id> [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
|
||||
|
||||
@@ -448,7 +447,6 @@ Only add complexity with:
|
||||
```bash
|
||||
openspec list # What's in progress?
|
||||
openspec show [item] # View details
|
||||
openspec diff [change] # What's changing?
|
||||
openspec validate --strict # Is it correct?
|
||||
openspec archive <change-id> [--yes|-y] # Mark complete (add --yes for automation)
|
||||
```
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
## Why
|
||||
Google is rolling out Antigravity, a Windsurf-derived IDE that discovers workflows from `.agent/workflows/*.md`. Today OpenSpec can only scaffold slash commands for Windsurf directories, so Antigravity users cannot run the proposal/apply/archive flows from the IDE.
|
||||
|
||||
## What Changes
|
||||
- Add Antigravity as a selectable native tool in `openspec init` so it creates `.agent/workflows/openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` with YAML frontmatter containing only a `description` field plus the standard OpenSpec-managed body.
|
||||
- Ensure `openspec update` refreshes the body of any existing Antigravity workflows inside `.agent/workflows/` without creating missing files, mirroring the Windsurf behavior.
|
||||
- Share e2e/template coverage confirming the generator writes the proper directory, filename casing, and frontmatter format so Antigravity picks up the workflows.
|
||||
|
||||
## Impact
|
||||
- Affected specs: `specs/cli-init`, `specs/cli-update`
|
||||
- Expected code: CLI init/update tool registries, slash-command templates, associated tests
|
||||
@@ -0,0 +1,9 @@
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Slash Command Configuration
|
||||
The init command SHALL generate slash command files for supported editors using shared templates.
|
||||
|
||||
#### Scenario: Generating slash commands for Antigravity
|
||||
- **WHEN** the user selects Antigravity during initialization
|
||||
- **THEN** create `.agent/workflows/openspec-proposal.md`, `.agent/workflows/openspec-apply.md`, and `.agent/workflows/openspec-archive.md`
|
||||
- **AND** ensure each file begins with YAML frontmatter that contains only a `description: <stage summary>` field followed by the shared OpenSpec workflow instructions wrapped in managed markers
|
||||
- **AND** populate the workflow body with the same proposal/apply/archive guidance used for other tools so Antigravity behaves like Windsurf while pointing to the `.agent/workflows/` directory
|
||||
@@ -0,0 +1,8 @@
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Slash Command Updates
|
||||
The update command SHALL refresh existing slash command files for configured tools without creating new ones, and ensure the OpenCode archive command accepts change ID arguments.
|
||||
|
||||
#### Scenario: Updating slash commands for Antigravity
|
||||
- **WHEN** `.agent/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh the OpenSpec-managed portion of each file so the workflow copy matches other tools while preserving the existing single-field `description` frontmatter
|
||||
- **AND** skip creating any missing workflow files during update, mirroring the behavior for Windsurf and other IDEs
|
||||
@@ -0,0 +1,12 @@
|
||||
## 1. CLI init support
|
||||
- [x] 1.1 Surface Antigravity in the native-tool picker (interactive + `--tools`) so it toggles alongside other IDEs.
|
||||
- [x] 1.2 Generate `.agent/workflows/openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` with YAML frontmatter restricted to a single `description` field for each stage and wrap the body in OpenSpec markers.
|
||||
- [x] 1.3 Confirm workspace scaffolding covers missing directory creation and re-run scenarios so repeated init refreshes the managed block.
|
||||
|
||||
## 2. CLI update support
|
||||
- [x] 2.1 Detect existing Antigravity workflow files during `openspec update` and refresh only the managed body, skipping creation when files are missing.
|
||||
- [x] 2.2 Ensure update logic preserves the `description` frontmatter block exactly as written by init, including case and spacing, and refreshes body templates alongside other tools.
|
||||
|
||||
## 3. Templates and tests
|
||||
- [x] 3.1 Add shared template entries for Antigravity that reuse the Windsurf copy but target `.agent/workflows` plus the description-only frontmatter requirement.
|
||||
- [x] 3.2 Expand automated coverage (unit or integration) verifying init and update produce the expected file paths and frontmatter + body markers for Antigravity.
|
||||
@@ -0,0 +1,39 @@
|
||||
## Why
|
||||
|
||||
Users need a way to view and modify their global OpenSpec settings without manually editing JSON files. The `add-global-config-dir` change provides the foundation, but there's no user-facing interface to interact with the config. A dedicated `openspec config` command provides discoverability and ease of use.
|
||||
|
||||
## What Changes
|
||||
|
||||
Add `openspec config` subcommand with the following operations:
|
||||
|
||||
```bash
|
||||
openspec config path # Show config file location
|
||||
openspec config list # Show all current settings
|
||||
openspec config get <key> # Get a specific value
|
||||
openspec config set <key> <value> # Set a value
|
||||
openspec config reset [key] # Reset to defaults (all or specific key)
|
||||
```
|
||||
|
||||
**Example usage:**
|
||||
```bash
|
||||
$ openspec config path
|
||||
/Users/me/.config/openspec/config.json
|
||||
|
||||
$ openspec config list
|
||||
enableTelemetry: true
|
||||
featureFlags: {}
|
||||
|
||||
$ openspec config set enableTelemetry false
|
||||
Set enableTelemetry = false
|
||||
|
||||
$ openspec config get enableTelemetry
|
||||
false
|
||||
```
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: New `cli-config` capability
|
||||
- Affected code:
|
||||
- New `src/commands/config.ts`
|
||||
- Update CLI entry point to register config command
|
||||
- Dependencies: Requires `add-global-config-dir` to be implemented first
|
||||
@@ -1,21 +0,0 @@
|
||||
## ADDED Requirements
|
||||
### Requirement: Crush Tool Support
|
||||
The system SHALL provide Crush AI assistant as a supported tool option during OpenSpec initialization.
|
||||
|
||||
#### Scenario: Initialize project with Crush support
|
||||
- **WHEN** user runs `openspec init --tool crush`
|
||||
- **THEN** Crush-specific slash commands are configured in `.crush/commands/openspec/`
|
||||
- **AND** Crush AGENTS.md includes OpenSpec workflow instructions
|
||||
- **AND** Crush is registered as available configurator
|
||||
|
||||
#### Scenario: Crush proposal command generation
|
||||
- **WHEN** Crush slash commands are configured
|
||||
- **THEN** `.crush/commands/openspec/proposal.md` contains proposal workflow with guardrails
|
||||
- **AND** Includes Crush-specific frontmatter with OpenSpec category and tags
|
||||
- **AND** Follows established slash command template pattern
|
||||
|
||||
#### Scenario: Crush apply and archive commands
|
||||
- **WHEN** Crush slash commands are configured
|
||||
- **THEN** `.crush/commands/openspec/apply.md` contains implementation workflow
|
||||
- **AND** `.crush/commands/openspec/archive.md` contains archiving workflow
|
||||
- **AND** Both commands include appropriate frontmatter and references
|
||||
@@ -2,9 +2,9 @@
|
||||
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 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.
|
||||
- Add automated coverage (unit/integ tests) to ensure the command respects existing naming rules and generated Markdown passes validation.
|
||||
- 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`
|
||||
|
||||
@@ -9,13 +9,13 @@ The CLI SHALL expose an `openspec scaffold <change-id>` command that validates t
|
||||
- **AND** exit with code 0 after successful scaffolding
|
||||
|
||||
### Requirement: Change Directory Structure
|
||||
The scaffold command SHALL create the standard change workspace with proposal, tasks, optional design, and delta directories laid out according to OpenSpec conventions.
|
||||
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/`
|
||||
- **AND** generate `proposal.md`, `tasks.md`, and `design.md` (commented placeholder content) in that directory when missing
|
||||
- **AND** create `openspec/changes/add-user-notifications/specs/` ready for capability-specific deltas
|
||||
- **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.
|
||||
@@ -25,15 +25,7 @@ The scaffold command SHALL populate generated Markdown files with OpenSpec-compl
|
||||
- **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
|
||||
|
||||
### Requirement: Delta Spec Creation
|
||||
The scaffold command SHALL create at least one capability delta file with correctly formatted requirement and scenario placeholders that guide authors to enter the actual behavior.
|
||||
|
||||
#### Scenario: Creating spec delta skeleton
|
||||
- **WHEN** scaffolding a change and the capability `cli-scaffold` is provided interactively or via flags
|
||||
- **THEN** generate `openspec/changes/add-user-notifications/specs/cli-scaffold/spec.md`
|
||||
- **AND** include `## ADDED Requirements` with at least one `### Requirement:` block and matching `#### Scenario:` entries that remind the author to replace placeholder text
|
||||
- **AND** ensure the generated delta passes `openspec validate add-user-notifications --strict` until the author edits it
|
||||
- **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.
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
## 1. CLI scaffolding command
|
||||
- [ ] 1.1 Register an `openspec scaffold` command in the CLI entrypoint with `change-id` argument validation.
|
||||
- [ ] 1.2 Implement generator logic that creates the change directory structure plus default `proposal.md`, `tasks.md`, and delta spec skeletons without overwriting existing populated files.
|
||||
- [ ] 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 Surface copy/paste templates and scaffold usage in the top-level quick reference for `openspec/AGENTS.md`.
|
||||
- [ ] 2.2 Refresh other CLI docs (`docs/`, README) to mention the scaffold workflow and link to instructions.
|
||||
- [ ] 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, file generation, and idempotent reruns.
|
||||
- [ ] 3.2 Add integration coverage ensuring generated files pass `openspec validate --strict` without manual edits.
|
||||
- [ ] 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.
|
||||
|
||||
-2
@@ -11,8 +11,6 @@ The update command SHALL refresh existing slash command files for configured too
|
||||
- **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
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Archive Command Argument Support
|
||||
The archive slash command template SHALL support optional change ID arguments for tools that support `$ARGUMENTS` placeholder.
|
||||
|
||||
-6
@@ -13,9 +13,3 @@
|
||||
## 3. Update Documentation
|
||||
- [x] 3.1 Update AGENTS.md archive examples to show argument usage
|
||||
- [x] 3.2 Document that OpenCode now supports `/openspec:archive <change-id>`
|
||||
|
||||
## 4. Validation and Testing
|
||||
- [ ] 4.1 Run `openspec update` to regenerate OpenCode slash commands
|
||||
- [ ] 4.2 Manually test with OpenCode using `/openspec:archive <change-id>`
|
||||
- [ ] 4.3 Test backward compatibility (archive command without arguments)
|
||||
- [ ] 4.4 Run `openspec validate --strict` to ensure no issues
|
||||
@@ -0,0 +1,15 @@
|
||||
## Why
|
||||
Add support for Cline (VS Code extension) in OpenSpec to enable developers to use Cline's AI-powered coding capabilities for spec-driven development workflows.
|
||||
|
||||
## What Changes
|
||||
- Add Cline slash command configurator for proposal, apply, and archive operations
|
||||
- Add Cline root CLINE.md configurator for project-level instructions
|
||||
- Add Cline template exports
|
||||
- Update tool and slash command registries to include Cline
|
||||
- Add comprehensive test coverage
|
||||
- **BREAKING**: None - this is additive functionality
|
||||
|
||||
## Impact
|
||||
- Affected specs: cli-init (new tool option)
|
||||
- Affected code: src/core/configurators/slash/cline.ts, src/core/configurators/cline.ts, registry files
|
||||
- New files: .clinerules/openspec-*.md, CLINE.md
|
||||
@@ -0,0 +1,97 @@
|
||||
## MODIFIED Requirements
|
||||
### Requirement: AI Tool Configuration Details
|
||||
|
||||
The command SHALL properly configure selected AI tools with OpenSpec-specific instructions using a marker system.
|
||||
|
||||
#### Scenario: Configuring Claude Code
|
||||
|
||||
- **WHEN** Claude Code is selected
|
||||
- **THEN** create or update `CLAUDE.md` in the project root directory (not inside openspec/)
|
||||
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
|
||||
|
||||
#### Scenario: Configuring CodeBuddy Code
|
||||
|
||||
- **WHEN** CodeBuddy Code is selected
|
||||
- **THEN** create or update `CODEBUDDY.md` in the project root directory (not inside openspec/)
|
||||
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
|
||||
|
||||
#### Scenario: Configuring Cline
|
||||
|
||||
- **WHEN** Cline is selected
|
||||
- **THEN** create or update `CLINE.md` in the project root directory (not inside openspec/)
|
||||
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
|
||||
|
||||
#### Scenario: Creating new CLAUDE.md
|
||||
|
||||
- **WHEN** CLAUDE.md does not exist
|
||||
- **THEN** create new file with stub instructions wrapped in markers so the full workflow stays in `openspec/AGENTS.md`:
|
||||
```markdown
|
||||
<!-- OPENSPEC:START -->
|
||||
# OpenSpec Instructions
|
||||
|
||||
This project uses OpenSpec to manage AI assistant workflows.
|
||||
|
||||
- Full guidance lives in '@/openspec/AGENTS.md'.
|
||||
- Keep this managed block so 'openspec update' can refresh the instructions.
|
||||
<!-- OPENSPEC:END -->
|
||||
```
|
||||
|
||||
### Requirement: Slash Command Configuration
|
||||
The init command SHALL generate slash command files for supported editors using shared templates.
|
||||
|
||||
#### Scenario: Generating slash commands for Claude Code
|
||||
- **WHEN** the user selects Claude Code during initialization
|
||||
- **THEN** create `.claude/commands/openspec/proposal.md`, `.claude/commands/openspec/apply.md`, and `.claude/commands/openspec/archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for CodeBuddy Code
|
||||
- **WHEN** the user selects CodeBuddy Code during initialization
|
||||
- **THEN** create `.codebuddy/commands/openspec/proposal.md`, `.codebuddy/commands/openspec/apply.md`, and `.codebuddy/commands/openspec/archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Cline
|
||||
- **WHEN** the user selects Cline during initialization
|
||||
- **THEN** create `.clinerules/openspec-proposal.md`, `.clinerules/openspec-apply.md`, and `.clinerules/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** include Cline-specific Markdown heading frontmatter
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Cursor
|
||||
- **WHEN** the user selects Cursor during initialization
|
||||
- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for OpenCode
|
||||
- **WHEN** the user selects OpenCode during initialization
|
||||
- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Windsurf
|
||||
- **WHEN** the user selects Windsurf during initialization
|
||||
- **THEN** create `.windsurf/workflows/openspec-proposal.md`, `.windsurf/workflows/openspec-apply.md`, and `.windsurf/workflows/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Kilo Code
|
||||
- **WHEN** the user selects Kilo Code during initialization
|
||||
- **THEN** create `.kilocode/workflows/openspec-proposal.md`, `.kilocode/workflows/openspec-apply.md`, and `.kilocode/workflows/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Codex
|
||||
- **WHEN** the user selects Codex during initialization
|
||||
- **THEN** create global prompt files at `~/.codex/prompts/openspec-proposal.md`, `~/.codex/prompts/openspec-apply.md`, and `~/.codex/prompts/openspec-archive.md` (or under `$CODEX_HOME/prompts` if set)
|
||||
- **AND** populate each file from shared templates that map the first numbered placeholder (`$1`) to the primary user input (e.g., change identifier or question text)
|
||||
- **AND** wrap the generated content in OpenSpec markers so `openspec update` can refresh the prompts without touching surrounding custom notes
|
||||
|
||||
#### Scenario: Generating slash commands for GitHub Copilot
|
||||
- **WHEN** the user selects GitHub Copilot during initialization
|
||||
- **THEN** create `.github/prompts/openspec-proposal.prompt.md`, `.github/prompts/openspec-apply.prompt.md`, and `.github/prompts/openspec-archive.prompt.md`
|
||||
- **AND** populate each file with YAML frontmatter containing a `description` field that summarizes the workflow stage
|
||||
- **AND** include `$ARGUMENTS` placeholder to capture user input
|
||||
- **AND** wrap the shared template body with OpenSpec markers so `openspec update` can refresh the content
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
@@ -0,0 +1,19 @@
|
||||
## 1. Implementation
|
||||
- [x] 1.1 Create ClineSlashCommandConfigurator class in src/core/configurators/slash/cline.ts
|
||||
- [x] 1.2 Create ClineConfigurator class in src/core/configurators/cline.ts
|
||||
- [x] 1.3 Create cline-template.ts for template exports
|
||||
- [x] 1.4 Define file paths for Cline rules (.clinerules/)
|
||||
- [x] 1.5 Create Cline-specific frontmatter (Markdown heading format)
|
||||
- [x] 1.6 Register Cline in slash/registry.ts
|
||||
- [x] 1.7 Register Cline in configurators/registry.ts
|
||||
- [x] 1.8 Add Cline to AI_TOOLS in config.ts
|
||||
- [x] 1.9 Add getClineTemplate() to templates/index.ts
|
||||
- [x] 1.10 Update README with Cline documentation
|
||||
|
||||
## 2. Testing
|
||||
- [x] 2.1 Add init tests for CLINE.md creation and updates
|
||||
- [x] 2.2 Add init tests for .clinerules/ file creation
|
||||
- [x] 2.3 Add update tests for CLINE.md updates
|
||||
- [x] 2.4 Add update tests for .clinerules/ file refreshes
|
||||
- [x] 2.5 Test integration with openspec init --tools cline
|
||||
- [x] 2.6 Verify all 225 tests pass
|
||||
@@ -0,0 +1,67 @@
|
||||
## 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 Claude Code
|
||||
- **WHEN** the user selects Claude Code during initialization
|
||||
- **THEN** create `.claude/commands/openspec/proposal.md`, `.claude/commands/openspec/apply.md`, and `.claude/commands/openspec/archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for CodeBuddy Code
|
||||
- **WHEN** the user selects CodeBuddy Code during initialization
|
||||
- **THEN** create `.codebuddy/commands/openspec/proposal.md`, `.codebuddy/commands/openspec/apply.md`, and `.codebuddy/commands/openspec/archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Cline
|
||||
- **WHEN** the user selects Cline during initialization
|
||||
- **THEN** create `.clinerules/openspec-proposal.md`, `.clinerules/openspec-apply.md`, and `.clinerules/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** include Cline-specific Markdown heading frontmatter
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Crush
|
||||
- **WHEN** the user selects Crush during initialization
|
||||
- **THEN** create `.crush/commands/openspec/proposal.md`, `.crush/commands/openspec/apply.md`, and `.crush/commands/openspec/archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** include Crush-specific frontmatter with OpenSpec category and tags
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Cursor
|
||||
- **WHEN** the user selects Cursor during initialization
|
||||
- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for OpenCode
|
||||
- **WHEN** the user selects OpenCode during initialization
|
||||
- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Windsurf
|
||||
- **WHEN** the user selects Windsurf during initialization
|
||||
- **THEN** create `.windsurf/workflows/openspec-proposal.md`, `.windsurf/workflows/openspec-apply.md`, and `.windsurf/workflows/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Kilo Code
|
||||
- **WHEN** the user selects Kilo Code during initialization
|
||||
- **THEN** create `.kilocode/workflows/openspec-proposal.md`, `.kilocode/workflows/openspec-apply.md`, and `.kilocode/workflows/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Codex
|
||||
- **WHEN** the user selects Codex during initialization
|
||||
- **THEN** create global prompt files at `~/.codex/prompts/openspec-proposal.md`, `~/.codex/prompts/openspec-apply.md`, and `~/.codex/prompts/openspec-archive.md` (or under `$CODEX_HOME/prompts` if set)
|
||||
- **AND** populate each file from shared templates that map the first numbered placeholder (`$1`) to the primary user input (e.g., change identifier or question text)
|
||||
- **AND** wrap the generated content in OpenSpec markers so `openspec update` can refresh the prompts without touching surrounding custom notes
|
||||
|
||||
#### Scenario: Generating slash commands for GitHub Copilot
|
||||
- **WHEN** the user selects GitHub Copilot during initialization
|
||||
- **THEN** create `.github/prompts/openspec-proposal.prompt.md`, `.github/prompts/openspec-apply.prompt.md`, and `.github/prompts/openspec-archive.prompt.md`
|
||||
- **AND** populate each file with YAML frontmatter containing a `description` field that summarizes the workflow stage
|
||||
- **AND** include `$ARGUMENTS` placeholder to capture user input
|
||||
- **AND** wrap the shared template body with OpenSpec markers so `openspec update` can refresh the content
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
@@ -0,0 +1,525 @@
|
||||
# Shell Completions Design
|
||||
|
||||
## Overview
|
||||
|
||||
This design establishes a plugin-based architecture for shell completions that prioritizes clean TypeScript patterns, scalability, and maintainability. The system separates concerns between shell-specific generation logic, dynamic completion data providers, and installation automation.
|
||||
|
||||
**Scope:** This proposal implements **Zsh completion only** (with Oh My Zsh priority). The architecture is designed to support bash, fish, and PowerShell in future proposals.
|
||||
|
||||
## Native Shell Completion Behaviors
|
||||
|
||||
**Design Philosophy:** We integrate with each shell's native completion system rather than attempting to customize or unify behaviors. This ensures familiar UX for users and reduces maintenance complexity.
|
||||
|
||||
**Note:** While all four shell behaviors are documented below for architectural reference, **only Zsh is implemented in this proposal**. Bash, Fish, and PowerShell are documented to guide future implementations.
|
||||
|
||||
### Bash Completion Behavior
|
||||
|
||||
**Interaction Pattern:**
|
||||
- **Single TAB:** Completes if only one match exists, otherwise does nothing
|
||||
- **Double TAB (TAB TAB):** Displays all possible completions as a list
|
||||
- **Type more characters + TAB:** Narrows matches and completes or shows refined list
|
||||
|
||||
**OpenSpec Integration:**
|
||||
```bash
|
||||
# After installing: openspec completion install bash
|
||||
openspec val<TAB> # Completes to "openspec validate"
|
||||
openspec validate <TAB><TAB> # Shows: --all --changes --specs --strict --json [change-ids] [spec-ids]
|
||||
openspec show add-<TAB><TAB> # Shows all changes starting with "add-"
|
||||
```
|
||||
|
||||
**Implementation:** Uses bash-completion framework with `_init_completion`, `compgen`, and `COMPREPLY` array.
|
||||
|
||||
### Zsh Completion Behavior (with Oh My Zsh)
|
||||
|
||||
**Interaction Pattern:**
|
||||
- **Single TAB:** Shows interactive menu with all matches immediately
|
||||
- **TAB / Arrow Keys:** Navigate through completion options
|
||||
- **Enter:** Selects highlighted option
|
||||
- **Ctrl+C / Esc:** Cancels completion menu
|
||||
|
||||
**OpenSpec Integration:**
|
||||
```zsh
|
||||
# After installing: openspec completion install zsh
|
||||
openspec val<TAB> # Shows menu with "validate" and "view" highlighted
|
||||
openspec show <TAB> # Shows menu with all change IDs and spec IDs, categorized
|
||||
```
|
||||
|
||||
**Implementation:** Uses Zsh completion system with `_arguments`, `_describe`, and `compadd` built-ins. Oh My Zsh provides enhanced menu styling automatically.
|
||||
|
||||
### Fish Completion Behavior
|
||||
|
||||
**Interaction Pattern:**
|
||||
- **As-you-type:** Gray suggestions appear automatically in real-time
|
||||
- **Right Arrow / Ctrl+F:** Accepts the suggestion
|
||||
- **TAB:** Shows menu with all matches if multiple exist
|
||||
- **TAB again:** Cycles through options or navigates menu
|
||||
- **Enter:** Accepts current selection
|
||||
|
||||
**OpenSpec Integration:**
|
||||
```fish
|
||||
# After installing: openspec completion install fish
|
||||
openspec val # Gray suggestion shows "validate" immediately
|
||||
openspec show a # Real-time suggestions for changes starting with "a"
|
||||
openspec <TAB> # Shows all commands with descriptions in paged menu
|
||||
```
|
||||
|
||||
**Implementation:** Uses Fish's declarative `complete -c` syntax. Completions are auto-loaded from `~/.config/fish/completions/`.
|
||||
|
||||
### PowerShell Completion Behavior
|
||||
|
||||
**Interaction Pattern:**
|
||||
- **TAB:** Cycles forward through completions one at a time (inline replacement)
|
||||
- **Shift+TAB:** Cycles backward through completions
|
||||
- **Ctrl+Space:** Shows IntelliSense-style menu (PSReadLine v2.2+)
|
||||
- **Arrow Keys:** Navigate menu if shown
|
||||
|
||||
**OpenSpec Integration:**
|
||||
```powershell
|
||||
# After installing: openspec completion install powershell
|
||||
openspec val<TAB> # Cycles: validate → view → validate
|
||||
openspec show <TAB> # Cycles through change IDs one by one
|
||||
openspec <Ctrl+Space> # Shows IntelliSense menu with all commands
|
||||
```
|
||||
|
||||
**Implementation:** Uses `Register-ArgumentCompleter` with custom script block that returns `[System.Management.Automation.CompletionResult]` objects.
|
||||
|
||||
### Comparison Table
|
||||
|
||||
| Shell | Trigger | Display Style | Navigation | Selection |
|
||||
|-------------|-----------------|------------------------|----------------------|----------------|
|
||||
| Bash | TAB TAB | List (printed once) | Type more + TAB | Auto-complete |
|
||||
| Zsh | TAB | Interactive menu | TAB/Arrows | Enter |
|
||||
| Fish | TAB/Auto | Real-time + menu | TAB/Arrows | Enter/Right |
|
||||
| PowerShell | TAB | Inline cycling | TAB/Shift+TAB | Stop cycling |
|
||||
|
||||
**Key Insight:** Each shell's completion UX reflects its design philosophy. We respect these conventions rather than forcing uniformity.
|
||||
|
||||
## Architectural Principles
|
||||
|
||||
### 1. Plugin-Based Generator System
|
||||
|
||||
Each shell has unique completion syntax and conventions. Rather than creating a monolithic generator with branching logic, we use a plugin pattern where each shell implements a common interface:
|
||||
|
||||
```typescript
|
||||
interface CompletionGenerator {
|
||||
generate(): string;
|
||||
getInstallPath(): string;
|
||||
getConfigFile(): string;
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- New shells can be added without modifying existing generators
|
||||
- Shell-specific logic is isolated and testable
|
||||
- Type safety ensures all generators implement required methods
|
||||
- Easy to maintain and understand (single responsibility per generator)
|
||||
|
||||
**Implementation Classes:**
|
||||
- `ZshCompletionGenerator` - Uses Zsh's `_arguments` and `_describe` functions
|
||||
- `BashCompletionGenerator` - Uses `_init_completion` and `compgen` built-ins
|
||||
- `FishCompletionGenerator` - Uses `complete -c` declarative syntax
|
||||
- `PowerShellCompletionGenerator` - Uses `Register-ArgumentCompleter` cmdlet
|
||||
|
||||
### 2. Centralized Command Registry
|
||||
|
||||
Shell completions must stay synchronized with actual CLI commands. To avoid duplication and drift, we maintain a single source of truth:
|
||||
|
||||
```typescript
|
||||
type CommandDefinition = {
|
||||
name: string;
|
||||
description: string;
|
||||
flags: FlagDefinition[];
|
||||
acceptsChangeId: boolean;
|
||||
acceptsSpecId: boolean;
|
||||
subcommands?: CommandDefinition[];
|
||||
};
|
||||
|
||||
const COMMAND_REGISTRY: CommandDefinition[] = [
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize OpenSpec in your project',
|
||||
flags: [
|
||||
{ name: '--tools', description: 'Configure AI tools non-interactively', hasValue: true }
|
||||
],
|
||||
acceptsChangeId: false,
|
||||
acceptsSpecId: false
|
||||
},
|
||||
// ... all other commands
|
||||
];
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- All generators consume the same command definitions
|
||||
- Adding a new command automatically propagates to all shells
|
||||
- Flag changes only need to be made in one place
|
||||
- Type safety prevents typos and missing fields
|
||||
- Easier to test (mock the registry)
|
||||
|
||||
**TypeScript Sugar:**
|
||||
- Use `const` assertions for readonly registry
|
||||
- Leverage discriminated unions for command types
|
||||
- Use `satisfies` operator to ensure registry matches interface
|
||||
|
||||
### 3. Dynamic Completion Provider
|
||||
|
||||
Change and spec IDs are project-specific and discovered at runtime. A dedicated provider encapsulates this logic:
|
||||
|
||||
```typescript
|
||||
class CompletionProvider {
|
||||
private changeCache: { ids: string[]; timestamp: number } | null = null;
|
||||
private specCache: { ids: string[]; timestamp: number } | null = null;
|
||||
private readonly CACHE_TTL_MS = 2000;
|
||||
|
||||
async getChangeIds(): Promise<string[]> {
|
||||
if (this.changeCache && Date.now() - this.changeCache.timestamp < this.CACHE_TTL_MS) {
|
||||
return this.changeCache.ids;
|
||||
}
|
||||
|
||||
const ids = await discoverActiveChangeIds();
|
||||
this.changeCache = { ids, timestamp: Date.now() };
|
||||
return ids;
|
||||
}
|
||||
|
||||
async getSpecIds(): Promise<string[]> {
|
||||
// Similar caching logic
|
||||
}
|
||||
|
||||
isOpenSpecProject(): boolean {
|
||||
// Check for openspec/ directory
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Caching reduces file system overhead during rapid tab completion
|
||||
- Encapsulates project detection logic
|
||||
- Easy to test with mocked file system
|
||||
- Shared across all shell generators
|
||||
|
||||
**Design Decisions:**
|
||||
- 2-second cache TTL balances freshness with performance
|
||||
- Cache per-process (not persistent) to avoid stale data across sessions
|
||||
- Graceful degradation when outside OpenSpec projects
|
||||
|
||||
### 4. Separate Installation Logic
|
||||
|
||||
Installation involves shell configuration file manipulation, which differs from generation. We separate this concern:
|
||||
|
||||
```typescript
|
||||
interface CompletionInstaller {
|
||||
install(): Promise<InstallResult>;
|
||||
uninstall(): Promise<UninstallResult>;
|
||||
isInstalled(): Promise<boolean>;
|
||||
}
|
||||
```
|
||||
|
||||
**Shell-Specific Installers:**
|
||||
- `ZshInstaller` - Handles both Oh My Zsh (custom completions) and standard Zsh (fpath)
|
||||
- `BashInstaller` - Detects completion directories and sources from `.bashrc`
|
||||
- `FishInstaller` - Writes to `~/.config/fish/completions/` (auto-loaded)
|
||||
- `PowerShellInstaller` - Appends to PowerShell profile
|
||||
|
||||
**Benefits:**
|
||||
- Installation logic doesn't pollute generator code
|
||||
- Can test installation without generating completion scripts
|
||||
- Easier to handle edge cases (missing directories, permissions, already installed)
|
||||
|
||||
### 5. Type-Safe Shell Detection
|
||||
|
||||
We use TypeScript's literal types and type guards for shell detection:
|
||||
|
||||
```typescript
|
||||
type SupportedShell = 'bash' | 'zsh' | 'fish' | 'powershell';
|
||||
|
||||
function detectShell(): SupportedShell {
|
||||
const shellPath = process.env.SHELL || '';
|
||||
const shellName = path.basename(shellPath).toLowerCase();
|
||||
|
||||
// PowerShell normalization
|
||||
if (shellName === 'pwsh' || shellName === 'powershell') {
|
||||
return 'powershell';
|
||||
}
|
||||
|
||||
const supported: SupportedShell[] = ['bash', 'zsh', 'fish', 'powershell'];
|
||||
if (supported.includes(shellName as SupportedShell)) {
|
||||
return shellName as SupportedShell;
|
||||
}
|
||||
|
||||
throw new Error(`Shell '${shellName}' is not supported. Supported: ${supported.join(', ')}`);
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Compile-time type checking prevents invalid shell names
|
||||
- Easy to add new shells (add to union type)
|
||||
- Type narrowing works in switch statements
|
||||
- Clear error messages for unsupported shells
|
||||
|
||||
### 6. Factory Pattern for Instantiation
|
||||
|
||||
A factory function selects the appropriate generator/installer based on shell type:
|
||||
|
||||
```typescript
|
||||
function createGenerator(shell: SupportedShell, provider: CompletionProvider): CompletionGenerator {
|
||||
switch (shell) {
|
||||
case 'bash': return new BashCompletionGenerator(COMMAND_REGISTRY, provider);
|
||||
case 'zsh': return new ZshCompletionGenerator(COMMAND_REGISTRY, provider);
|
||||
case 'fish': return new FishCompletionGenerator(COMMAND_REGISTRY, provider);
|
||||
case 'powershell': return new PowerShellCompletionGenerator(COMMAND_REGISTRY, provider);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Single point of instantiation
|
||||
- Type safety ensures exhaustive switch (TypeScript error if shell type missing)
|
||||
- Easy to inject dependencies (registry, provider)
|
||||
|
||||
## Command Structure
|
||||
|
||||
**This Proposal (Zsh-only):**
|
||||
```
|
||||
openspec completion
|
||||
├── zsh # Generate Zsh completion script
|
||||
├── install [shell] # Install Zsh completion (auto-detects or explicit zsh)
|
||||
└── uninstall [shell] # Remove Zsh completion (auto-detects or explicit zsh)
|
||||
```
|
||||
|
||||
**Future (after follow-up proposals):**
|
||||
```
|
||||
openspec completion
|
||||
├── bash # Generate Bash completion script (future)
|
||||
├── zsh # Generate Zsh completion script (this proposal)
|
||||
├── fish # Generate Fish completion script (future)
|
||||
├── powershell # Generate PowerShell completion script (future)
|
||||
├── install [shell] # Install completion (auto-detects or explicit shell)
|
||||
└── uninstall [shell] # Remove completion (auto-detects or explicit shell)
|
||||
```
|
||||
|
||||
## File Organization
|
||||
|
||||
**This Proposal (Zsh-only):**
|
||||
```
|
||||
src/
|
||||
├── commands/
|
||||
│ └── completion.ts # CLI command registration (zsh, install, uninstall)
|
||||
├── core/
|
||||
│ └── completions/
|
||||
│ ├── types.ts # Interfaces: CompletionGenerator, CommandDefinition, etc.
|
||||
│ ├── command-registry.ts # Single source of truth for OpenSpec commands
|
||||
│ ├── completion-provider.ts # Dynamic change/spec ID discovery with caching
|
||||
│ ├── factory.ts # Factory for instantiating Zsh generator/installer
|
||||
│ ├── generators/
|
||||
│ │ └── zsh-generator.ts # Zsh completion script generator
|
||||
│ └── installers/
|
||||
│ └── zsh-installer.ts # Handles Oh My Zsh + standard Zsh installation
|
||||
└── utils/
|
||||
└── shell-detection.ts # Shell detection (returns 'zsh' or throws)
|
||||
```
|
||||
|
||||
**Future additions (bash, fish, powershell):**
|
||||
- `generators/bash-generator.ts`, `fish-generator.ts`, `powershell-generator.ts`
|
||||
- `installers/bash-installer.ts`, `fish-installer.ts`, `powershell-installer.ts`
|
||||
- Update `shell-detection.ts` to support additional shell types
|
||||
|
||||
## Oh My Zsh Priority
|
||||
|
||||
Zsh implementation prioritizes Oh My Zsh because:
|
||||
1. **Popularity** - Oh My Zsh is the most popular Zsh configuration framework
|
||||
2. **Convention** - Has standard completion directory (`~/.oh-my-zsh/custom/completions/`)
|
||||
3. **Detection** - Easy to detect via `$ZSH` environment variable
|
||||
4. **Fallback** - Standard Zsh support provides compatibility when Oh My Zsh isn't installed
|
||||
|
||||
**Installation Strategy:**
|
||||
```typescript
|
||||
if (isOhMyZshInstalled()) {
|
||||
// Install to ~/.oh-my-zsh/custom/completions/_openspec
|
||||
// Automatically loaded by Oh My Zsh
|
||||
} else {
|
||||
// Install to ~/.zsh/completions/_openspec
|
||||
// Update ~/.zshrc with fpath and compinit if needed
|
||||
}
|
||||
```
|
||||
|
||||
## Caching Strategy
|
||||
|
||||
Dynamic completions cache results for 2 seconds to balance freshness with performance:
|
||||
|
||||
**Why 2 seconds?**
|
||||
- Typical tab completion sessions last < 2 seconds
|
||||
- Prevents repeated file system scans during rapid tabbing
|
||||
- Short enough to feel "live" when changes/specs are added
|
||||
- Automatic per-process expiration (no stale data across sessions)
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
private changeCache: { ids: string[]; timestamp: number } | null = null;
|
||||
private readonly CACHE_TTL_MS = 2000;
|
||||
|
||||
if (this.changeCache && Date.now() - this.changeCache.timestamp < this.CACHE_TTL_MS) {
|
||||
return this.changeCache.ids; // Use cached
|
||||
}
|
||||
// Refresh cache
|
||||
```
|
||||
|
||||
## Error Handling Philosophy
|
||||
|
||||
Completions should degrade gracefully rather than break workflows:
|
||||
|
||||
1. **Unsupported shell** - Clear error with list of supported shells
|
||||
2. **Not in OpenSpec project** - Skip dynamic completions, only offer static commands
|
||||
3. **Permission errors** - Suggest alternative installation methods
|
||||
4. **Missing config directories** - Auto-create with user notification
|
||||
5. **Already installed** - Offer to reinstall/update
|
||||
6. **Not installed (during uninstall)** - Exit gracefully with informational message
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Each component is independently testable:
|
||||
|
||||
1. **Unit Tests**
|
||||
- Shell detection with mocked `$SHELL` environment variable
|
||||
- Generator output verification (regex pattern matching)
|
||||
- Completion provider caching behavior
|
||||
- Command registry structure validation
|
||||
|
||||
2. **Integration Tests**
|
||||
- Installation to temporary test directories
|
||||
- Configuration file modifications
|
||||
- End-to-end command flow (generate → install → verify)
|
||||
|
||||
3. **Manual Testing**
|
||||
- Real shell environments (Oh My Zsh, Bash, Fish, PowerShell)
|
||||
- Tab completion behavior in OpenSpec projects
|
||||
- Dynamic change/spec ID suggestions
|
||||
- Installation/uninstallation workflows
|
||||
|
||||
## TypeScript Sugar Patterns
|
||||
|
||||
### 1. Const Assertions for Immutable Data
|
||||
```typescript
|
||||
const COMMAND_REGISTRY = [
|
||||
{ name: 'init', ... },
|
||||
{ name: 'list', ... }
|
||||
] as const;
|
||||
```
|
||||
|
||||
### 2. Discriminated Unions for Command Types
|
||||
```typescript
|
||||
type Command =
|
||||
| { type: 'simple'; name: string }
|
||||
| { type: 'with-subcommands'; name: string; subcommands: Command[] };
|
||||
```
|
||||
|
||||
### 3. Template Literal Types for Strings
|
||||
```typescript
|
||||
type ShellConfigFile = `~/.${SupportedShell}rc` | `~/.${SupportedShell}_profile`;
|
||||
```
|
||||
|
||||
### 4. Satisfies Operator for Type Validation
|
||||
```typescript
|
||||
const config = {
|
||||
shell: 'zsh',
|
||||
path: '~/.zshrc'
|
||||
} satisfies ShellConfig;
|
||||
```
|
||||
|
||||
### 5. Optional Chaining and Nullish Coalescing
|
||||
```typescript
|
||||
const path = process.env.ZSH ?? `${os.homedir()}/.oh-my-zsh`;
|
||||
```
|
||||
|
||||
### 6. Async/Await with Promise.all for Parallel Operations
|
||||
```typescript
|
||||
const [changes, specs] = await Promise.all([
|
||||
provider.getChangeIds(),
|
||||
provider.getSpecIds()
|
||||
]);
|
||||
```
|
||||
|
||||
## Scalability Considerations
|
||||
|
||||
### Adding a New Shell
|
||||
|
||||
1. Define shell in `SupportedShell` union type
|
||||
2. Create generator class implementing `CompletionGenerator`
|
||||
3. Create installer class implementing `CompletionInstaller`
|
||||
4. Add cases to factory functions
|
||||
5. Add command registration in CLI
|
||||
6. Write tests
|
||||
|
||||
**TypeScript will enforce** that all switch statements are updated (exhaustiveness checking).
|
||||
|
||||
### Adding a New Command
|
||||
|
||||
1. Add to `COMMAND_REGISTRY` with appropriate metadata
|
||||
2. All generators automatically include it
|
||||
3. Update tests to verify new command appears
|
||||
|
||||
### Changing Completion Behavior
|
||||
|
||||
Dynamic completion logic is centralized in `CompletionProvider`, making behavior changes trivial without touching shell-specific code.
|
||||
|
||||
## Trade-offs and Decisions
|
||||
|
||||
### Decision: Separate Generators vs. Template Engine
|
||||
|
||||
**Chosen:** Separate generator classes per shell
|
||||
|
||||
**Alternative:** Template engine with shell-specific templates
|
||||
|
||||
**Rationale:**
|
||||
- Shell completion syntax is fundamentally different (not just text substitution)
|
||||
- Type safety is better with classes than templates
|
||||
- Logic complexity (caching, dynamic completions) doesn't fit template paradigm
|
||||
- Easier to debug and test dedicated classes
|
||||
|
||||
### Decision: 2-Second Cache TTL
|
||||
|
||||
**Chosen:** 2-second cache
|
||||
|
||||
**Alternatives:** No cache (slow), longer cache (stale), persistent cache (complex)
|
||||
|
||||
**Rationale:**
|
||||
- Balances performance with freshness
|
||||
- Matches typical user interaction patterns
|
||||
- Simple implementation (no invalidation complexity)
|
||||
- Automatic cleanup on process exit
|
||||
|
||||
### Decision: Oh My Zsh Detection
|
||||
|
||||
**Chosen:** Check `$ZSH` env var first, then `~/.oh-my-zsh/` directory
|
||||
|
||||
**Rationale:**
|
||||
- `$ZSH` is set by Oh My Zsh initialization (reliable)
|
||||
- Directory check is fallback for non-interactive scenarios
|
||||
- Standard Zsh serves as ultimate fallback
|
||||
|
||||
### Decision: Installation Automation vs. Manual Instructions
|
||||
|
||||
**Chosen:** Automated installation with install/uninstall commands
|
||||
|
||||
**Alternative:** Generate script and provide manual installation instructions
|
||||
|
||||
**Rationale:**
|
||||
- Better user experience (one command vs. multiple manual steps)
|
||||
- Reduces errors from manual configuration
|
||||
- Aligns with user expectations for modern CLI tools
|
||||
- Still supports manual workflow via script generation to stdout
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Contextual Flag Completion** - Suggest only valid flags for current command
|
||||
2. **Fuzzy Matching** - Allow partial matching for change/spec IDs
|
||||
3. **Rich Descriptions** - Include "why" section in completion suggestions (shell-dependent)
|
||||
4. **Completion Stats** - Track completion usage for analytics
|
||||
5. **Custom Completion Hooks** - Allow projects to extend completions
|
||||
6. **MCP Integration** - Provide completions via Model Context Protocol
|
||||
|
||||
## References
|
||||
|
||||
- [Bash Programmable Completion](https://www.gnu.org/software/bash/manual/html_node/Programmable-Completion.html)
|
||||
- [Zsh Completion System](https://zsh.sourceforge.io/Doc/Release/Completion-System.html)
|
||||
- [Fish Completions](https://fishshell.com/docs/current/completions.html)
|
||||
- [PowerShell Argument Completers](https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/register-argumentcompleter)
|
||||
- [Oh My Zsh Custom Completions](https://github.com/ohmyzsh/ohmyzsh/wiki/Customization#adding-custom-completions)
|
||||
@@ -0,0 +1,29 @@
|
||||
# Add Shell Completions
|
||||
|
||||
## Why
|
||||
|
||||
OpenSpec CLI commands lack shell completion, forcing users to remember all commands, subcommands, flags, and change/spec IDs manually. This creates friction during daily use and slows developer workflows. Shell completions are a standard expectation for modern CLI tools and significantly improve user experience through:
|
||||
- Faster command discovery via tab completion
|
||||
- Reduced cognitive load by removing memorization requirements
|
||||
- Fewer typos through validated suggestions
|
||||
- Professional polish expected of production-grade tools
|
||||
|
||||
## What Changes
|
||||
|
||||
This change adds shell completion support for the OpenSpec CLI, starting with **Zsh (including Oh My Zsh)** and establishing a scalable architecture for future shells (bash, fish, PowerShell). The implementation provides:
|
||||
|
||||
1. **New `openspec completion` command** with Zsh generation and installation/uninstallation capabilities
|
||||
2. **Native Zsh integration** that respects standard Zsh tab completion behavior (single-TAB menu navigation)
|
||||
3. **Dynamic completion providers** that discover active changes and specs from the current project
|
||||
4. **Plugin-based architecture** using TypeScript interfaces for easy extension to additional shells in future proposals
|
||||
5. **Installation automation** for Oh My Zsh (priority) and standard Zsh configurations
|
||||
6. **Context-aware suggestions** that only activate within OpenSpec-enabled projects
|
||||
|
||||
The architecture emphasizes clean TypeScript patterns, composable generators, separation of concerns between shell-specific logic and shared completion data providers, and integration with native shell completion systems. Other shells (bash, fish, PowerShell) are architecturally documented but not implemented in this proposal—they will be added in follow-up changes.
|
||||
|
||||
## Deltas
|
||||
|
||||
### Delta: New CLI completion specification
|
||||
- **Spec:** cli-completion
|
||||
- **Operation:** ADDED
|
||||
- **Description:** Defines requirements for the new `openspec completion` command including generation, installation, and shell-specific behaviors for Oh My Zsh, bash, fish, and PowerShell.
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
# CLI Completion Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
The `openspec completion` command SHALL provide shell completion functionality for all OpenSpec CLI commands, flags, and dynamic values (change IDs, spec IDs), with support for Zsh (including Oh My Zsh) and a scalable architecture ready for future shells (bash, fish, PowerShell). The completion system SHALL integrate with Zsh's native completion behavior rather than attempting to customize the user experience.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Native Shell Behavior Integration
|
||||
|
||||
The completion system SHALL respect and integrate with Zsh'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: No custom UX patterns
|
||||
|
||||
- **WHEN** implementing Zsh completion
|
||||
- **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
|
||||
|
||||
### Requirement: Command Structure
|
||||
|
||||
The completion command SHALL follow a subcommand pattern for generating and managing completion scripts.
|
||||
|
||||
#### Scenario: Available subcommands
|
||||
|
||||
- **WHEN** user executes `openspec completion --help`
|
||||
- **THEN** display available subcommands:
|
||||
- `zsh` - Generate Zsh completion script
|
||||
- `install [shell]` - Install completion for Zsh (auto-detects or requires explicit shell)
|
||||
- `uninstall [shell]` - Remove completion for Zsh (auto-detects or requires explicit 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 `zsh`
|
||||
- **AND** throw an error if the shell is not `zsh`, with message indicating only Zsh is currently supported
|
||||
|
||||
#### Scenario: Non-Zsh shell detection
|
||||
|
||||
- **WHEN** shell path indicates bash, fish, powershell, or other non-Zsh shell
|
||||
- **THEN** throw error: "Shell '<name>' is not supported yet. Currently supported: zsh"
|
||||
|
||||
### Requirement: Completion Generation
|
||||
|
||||
The completion command SHALL generate Zsh completion scripts on demand.
|
||||
|
||||
#### Scenario: Generating Zsh completion
|
||||
|
||||
- **WHEN** user executes `openspec completion 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
|
||||
|
||||
### Requirement: Dynamic Completions
|
||||
|
||||
The completion system SHALL provide context-aware dynamic completions for project-specific values.
|
||||
|
||||
#### Scenario: Completing change IDs
|
||||
|
||||
- **WHEN** completing arguments for commands that accept change names (show, validate, archive)
|
||||
- **THEN** discover active changes from `openspec/changes/` directory
|
||||
- **AND** exclude archived changes in `openspec/changes/archive/`
|
||||
- **AND** return change IDs as completion suggestions
|
||||
- **AND** only provide suggestions when inside an OpenSpec-enabled project
|
||||
|
||||
#### Scenario: Completing spec IDs
|
||||
|
||||
- **WHEN** completing arguments for commands that accept spec names (show, validate)
|
||||
- **THEN** discover specs from `openspec/specs/` directory
|
||||
- **AND** return spec IDs as completion suggestions
|
||||
- **AND** only provide suggestions when inside an OpenSpec-enabled project
|
||||
|
||||
#### Scenario: Completion caching
|
||||
|
||||
- **WHEN** dynamic completions are requested
|
||||
- **THEN** cache discovered change and spec IDs for 2 seconds
|
||||
- **AND** reuse cached values for subsequent requests within cache window
|
||||
- **AND** automatically refresh cache after expiration
|
||||
|
||||
#### Scenario: Project detection
|
||||
|
||||
- **WHEN** user requests completions outside an OpenSpec project
|
||||
- **THEN** skip dynamic change/spec ID completions
|
||||
- **AND** only suggest static commands and flags
|
||||
|
||||
### Requirement: Installation Automation
|
||||
|
||||
The completion command SHALL automatically install completion scripts into shell configuration files.
|
||||
|
||||
#### 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: Auto-detecting Zsh for installation
|
||||
|
||||
- **WHEN** user executes `openspec completion install` without specifying a shell
|
||||
- **THEN** detect current shell using shell detection logic
|
||||
- **AND** install completion if detected shell is Zsh
|
||||
- **AND** throw error if detected shell is not Zsh
|
||||
- **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.
|
||||
|
||||
#### Scenario: Uninstalling Oh My Zsh completion
|
||||
|
||||
- **WHEN** user executes `openspec completion uninstall zsh`
|
||||
- **THEN** 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** optionally remove fpath modifications from `~/.zshrc` (with confirmation)
|
||||
- **AND** display success message
|
||||
|
||||
#### Scenario: Auto-detecting Zsh 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
|
||||
|
||||
#### Scenario: Not installed
|
||||
|
||||
- **WHEN** attempting to uninstall completion that isn't installed
|
||||
- **THEN** display message indicating completion is not installed
|
||||
- **AND** exit with code 0
|
||||
|
||||
### Requirement: Architecture Patterns
|
||||
|
||||
The completion implementation SHALL follow clean architecture principles with TypeScript best practices.
|
||||
|
||||
#### 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)
|
||||
|
||||
#### 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
|
||||
- `acceptsChangeId: boolean` - Whether command takes change ID argument
|
||||
- `acceptsSpecId: boolean` - Whether command takes spec ID argument
|
||||
- `subcommands?: CommandDefinition[]` - Nested subcommands
|
||||
- **AND** export a `COMMAND_REGISTRY` constant with all command definitions
|
||||
- **AND** generators consume this registry to ensure consistency
|
||||
|
||||
#### Scenario: Type-safe shell detection
|
||||
|
||||
- **WHEN** implementing shell detection
|
||||
- **THEN** define a `SupportedShell` type as literal type: `'zsh'`
|
||||
- **AND** implement `detectShell()` function that returns 'zsh' or throws error
|
||||
- **AND** design type to be extensible (e.g., future: `'bash' | 'zsh' | 'fish' | 'powershell'`)
|
||||
|
||||
### Requirement: Error Handling
|
||||
|
||||
The completion command SHALL provide clear error messages for common failure scenarios.
|
||||
|
||||
#### 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"
|
||||
- **AND** exit with code 1
|
||||
|
||||
#### Scenario: Permission errors during installation
|
||||
|
||||
- **WHEN** installation fails due to file permission issues
|
||||
- **THEN** display clear error message indicating permission problem
|
||||
- **AND** suggest using appropriate permissions or alternative installation method
|
||||
- **AND** exit with code 1
|
||||
|
||||
#### Scenario: Missing shell configuration directory
|
||||
|
||||
- **WHEN** expected shell configuration directory doesn't exist
|
||||
- **THEN** create the directory automatically (with user notification)
|
||||
- **AND** proceed with installation
|
||||
|
||||
#### Scenario: Shell not detected
|
||||
|
||||
- **WHEN** `openspec completion install` cannot detect current shell or detects non-Zsh shell
|
||||
- **THEN** display error: "Could not detect Zsh. Please specify explicitly: openspec completion install zsh"
|
||||
- **AND** exit with code 1
|
||||
|
||||
### Requirement: Output Format
|
||||
|
||||
The completion command SHALL provide machine-parseable and human-readable output.
|
||||
|
||||
#### Scenario: Script generation output
|
||||
|
||||
- **WHEN** generating completion script to stdout
|
||||
- **THEN** output only the completion script content (no extra messages)
|
||||
- **AND** allow redirection to files: `openspec completion zsh > /path/to/_openspec`
|
||||
|
||||
#### Scenario: Installation success output
|
||||
|
||||
- **WHEN** installation completes successfully
|
||||
- **THEN** display formatted success message with:
|
||||
- Checkmark indicator
|
||||
- Installation location
|
||||
- Next steps (shell reload instructions)
|
||||
- **AND** use colors when terminal supports it (unless `--no-color` is set)
|
||||
|
||||
#### Scenario: Verbose installation output
|
||||
|
||||
- **WHEN** user provides `--verbose` flag during installation
|
||||
- **THEN** display detailed steps:
|
||||
- Shell detection result
|
||||
- Target file paths
|
||||
- Configuration modifications
|
||||
- File creation confirmations
|
||||
|
||||
### Requirement: Testing Support
|
||||
|
||||
The completion implementation SHALL be testable with unit and integration tests.
|
||||
|
||||
#### Scenario: Mock shell environment
|
||||
|
||||
- **WHEN** writing tests for shell detection
|
||||
- **THEN** allow overriding `$SHELL` environment variable
|
||||
- **AND** use dependency injection for file system operations
|
||||
|
||||
#### Scenario: Generator output verification
|
||||
|
||||
- **WHEN** testing completion generators
|
||||
- **THEN** verify generated scripts contain expected patterns
|
||||
- **AND** test that command registry is properly consumed
|
||||
- **AND** ensure dynamic completion placeholders are present
|
||||
|
||||
#### Scenario: Installation simulation
|
||||
|
||||
- **WHEN** testing installation logic
|
||||
- **THEN** use temporary test directories instead of actual home directories
|
||||
- **AND** verify file creation without modifying real shell configurations
|
||||
- **AND** test path resolution logic independently
|
||||
|
||||
## Not in Scope
|
||||
|
||||
The following shells are **architecturally documented but not implemented** in this proposal. They will be added in future proposals:
|
||||
|
||||
- **Bash completion** - Will use bash-completion framework with `_init_completion`, `compgen`, and `COMPREPLY`
|
||||
- **Fish completion** - Will use Fish's declarative `complete -c` syntax
|
||||
- **PowerShell completion** - Will use `Register-ArgumentCompleter` with completion result objects
|
||||
|
||||
The plugin-based architecture (CompletionGenerator interface, command registry, dynamic providers) is designed to make adding these shells straightforward in follow-up changes.
|
||||
|
||||
## Why
|
||||
|
||||
Shell completions are essential for professional CLI tools and significantly improve developer experience by reducing friction, errors, and cognitive load during daily workflows.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Implementation Tasks
|
||||
|
||||
## Phase 1: Foundation & Architecture
|
||||
|
||||
- [x] Create `src/utils/shell-detection.ts` with `SupportedShell` type and `detectShell()` function
|
||||
- [x] Create `src/core/completions/types.ts` with interfaces: `CompletionGenerator`, `CommandDefinition`, `FlagDefinition`
|
||||
- [x] Create `src/core/completions/command-registry.ts` with `COMMAND_REGISTRY` constant defining all OpenSpec commands, flags, and metadata
|
||||
- [x] Create `src/core/completions/completion-provider.ts` with `CompletionProvider` class for dynamic change/spec ID discovery with 2-second caching
|
||||
- [x] Write tests for shell detection (`test/utils/shell-detection.test.ts`)
|
||||
- [x] Write tests for completion provider (`test/core/completions/completion-provider.test.ts`)
|
||||
|
||||
## Phase 2: Zsh Completion (Oh My Zsh Priority)
|
||||
|
||||
- [x] Create `src/core/completions/generators/zsh-generator.ts` implementing `CompletionGenerator` interface
|
||||
- [x] Implement Zsh script generation using `_arguments` and `_describe` patterns
|
||||
- [x] Add dynamic completion logic for change/spec IDs using completion provider
|
||||
- [x] Test Zsh generator output (`test/core/completions/generators/zsh-generator.test.ts`)
|
||||
- [x] Create `src/core/completions/installers/zsh-installer.ts` with Oh My Zsh and standard Zsh support
|
||||
- [x] Implement Oh My Zsh detection (`$ZSH` env var or `~/.oh-my-zsh/` directory)
|
||||
- [x] Implement installation to `~/.oh-my-zsh/custom/completions/_openspec` for Oh My Zsh
|
||||
- [x] Implement fallback installation to `~/.zsh/completions/_openspec` with `fpath` updates
|
||||
- [x] Test Zsh installer logic with mocked file system (`test/core/completions/installers/zsh-installer.test.ts`)
|
||||
|
||||
## Phase 3: CLI Command Implementation
|
||||
|
||||
- [x] Create `src/commands/completion.ts` with `CompletionCommand` class
|
||||
- [x] Register `completion` command in `src/cli/index.ts` with subcommands: generate, install, uninstall
|
||||
- [x] Implement `generateSubcommand()` that outputs Zsh script to stdout
|
||||
- [x] Implement `installSubcommand(shell?: 'zsh')` with auto-detection for Zsh-only
|
||||
- [x] Implement `uninstallSubcommand(shell?: 'zsh')` for removing Zsh completions
|
||||
- [x] Add `--verbose` flag support for detailed installation output
|
||||
- [x] Add error handling with clear messages: "Shell '<name>' is not supported yet. Currently supported: zsh"
|
||||
- [x] Test completion command integration (`test/commands/completion.test.ts`)
|
||||
|
||||
## Phase 4: Integration & Polish
|
||||
|
||||
- [x] Create factory pattern in `src/core/completions/factory.ts` to instantiate Zsh generator/installer (extensible for future shells)
|
||||
- [x] Add `completion` command to command registry for self-referential completion
|
||||
- [x] Implement dynamic completion helper functions in Zsh generator (`_openspec_complete_changes`, `_openspec_complete_specs`, `_openspec_complete_items`)
|
||||
- [x] Add 'shell' positional type for completion command arguments
|
||||
- [x] Test completion generation with dynamic helpers
|
||||
- [x] Test completion install/uninstall flow
|
||||
- [x] Verify all tests pass (97 completion tests, 340 total tests)
|
||||
- [x] Implement auto-install via npm postinstall script
|
||||
- [x] Add safety checks (CI detection, opt-out flag)
|
||||
- [x] Handle Oh My Zsh vs standard Zsh installation paths
|
||||
- [x] Add test script for postinstall validation
|
||||
- [x] Document auto-install behavior and opt-out in README
|
||||
- [ ] Manually test Zsh completion in Oh My Zsh environment (install, test tab completion, uninstall)
|
||||
- [ ] Manually test Zsh completion in standard Zsh environment
|
||||
- [ ] Test dynamic change/spec ID completion in real OpenSpec projects
|
||||
- [ ] Verify completion cache behavior (2-second TTL)
|
||||
- [ ] Test behavior outside OpenSpec projects (should skip dynamic completions)
|
||||
- [x] Update `openspec --help` output to include completion command (automatically done via Commander)
|
||||
|
||||
## Phase 5: Edge Cases & Error Handling
|
||||
|
||||
- [ ] Test and handle permission errors during installation
|
||||
- [ ] Test and handle missing shell configuration directories (auto-create with notification)
|
||||
- [ ] Test "already installed" detection and reinstall flow
|
||||
- [ ] Test "not installed" detection during uninstall
|
||||
- [ ] Verify `--no-color` flag is respected in completion command output
|
||||
- [ ] Test shell detection failure scenarios with helpful error messages
|
||||
- [ ] Ensure graceful handling when `$SHELL` is unset or invalid
|
||||
- [ ] Test non-Zsh shells get clear "not supported yet" error messages
|
||||
- [ ] Test generator output can be redirected to files without corruption
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Phase 2 depends on Phase 1 (foundation must exist first)
|
||||
- Phase 3 depends on Phase 2 (CLI needs Zsh generator working)
|
||||
- Phase 4 depends on Phase 3 (integration requires CLI + Zsh implementation)
|
||||
- Phase 5 depends on Phase 4 (edge case testing after core functionality works)
|
||||
|
||||
## Future Work (Not in This Proposal)
|
||||
|
||||
- **Bash completions** - Create bash-generator.ts and bash-installer.ts in follow-up proposal
|
||||
- **Fish completions** - Create fish-generator.ts and fish-installer.ts in follow-up proposal
|
||||
- **PowerShell completions** - Create powershell-generator.ts and powershell-installer.ts in follow-up proposal
|
||||
|
||||
The architecture is designed to make adding these shells straightforward by implementing the `CompletionGenerator` interface.
|
||||
@@ -0,0 +1,105 @@
|
||||
## Context
|
||||
|
||||
OpenSpec needs a standard location for user-level configuration that works across platforms and follows established conventions. This will serve as the foundation for settings, feature flags, and future artifacts like workflows or templates.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Provide a single, well-defined location for global config
|
||||
- Follow XDG Base Directory Specification (widely adopted by CLI tools)
|
||||
- Support cross-platform usage (Unix, macOS, Windows)
|
||||
- Keep implementation minimal - just the foundation
|
||||
- Enable future expansion (cache, state, workflows)
|
||||
|
||||
**Non-Goals:**
|
||||
- Project-local config override (not in scope)
|
||||
- Config file migration tooling
|
||||
- Config validation CLI commands
|
||||
- Multiple config profiles
|
||||
|
||||
## Decisions
|
||||
|
||||
### Path Resolution Strategy
|
||||
|
||||
**Decision:** Use XDG Base Directory Specification with platform fallbacks.
|
||||
|
||||
```
|
||||
Unix/macOS: $XDG_CONFIG_HOME/openspec/ or ~/.config/openspec/
|
||||
Windows: %APPDATA%/openspec/
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- XDG is the de facto standard for CLI tools (used by gh, bat, ripgrep, etc.)
|
||||
- Environment variable override allows user customization
|
||||
- Windows uses its native convention (%APPDATA%) for better integration
|
||||
|
||||
**Alternatives considered:**
|
||||
- `~/.openspec/` - Simple but clutters home directory
|
||||
- `~/Library/Application Support/` on macOS - Overkill for a CLI tool
|
||||
|
||||
### Config File Format
|
||||
|
||||
**Decision:** JSON (`config.json`)
|
||||
|
||||
**Rationale:**
|
||||
- Native Node.js support (no dependencies)
|
||||
- Human-readable and editable
|
||||
- Type-safe with TypeScript
|
||||
- Matches project.md's "minimal dependencies" principle
|
||||
|
||||
**Alternatives considered:**
|
||||
- YAML - Requires dependency, more error-prone to edit
|
||||
- TOML - Less common in Node.js ecosystem
|
||||
- Environment variables only - Too limited for structured settings
|
||||
|
||||
### Config Schema
|
||||
|
||||
**Decision:** Flat structure with typed fields, start minimal.
|
||||
|
||||
```typescript
|
||||
interface GlobalConfig {
|
||||
featureFlags?: Record<string, boolean>;
|
||||
}
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- `featureFlags` enables controlled rollout of new features
|
||||
- Optional fields with defaults avoid breaking changes
|
||||
- Flat structure is easy to understand and extend
|
||||
|
||||
### Loading Strategy
|
||||
|
||||
**Decision:** Read from disk on each call, no caching.
|
||||
|
||||
```typescript
|
||||
export function getGlobalConfig(): GlobalConfig {
|
||||
return loadConfigFromDisk();
|
||||
}
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- CLI commands are short-lived; caching adds complexity without benefit
|
||||
- Reading a small JSON file is ~1ms; negligible overhead
|
||||
- Always returns fresh data; no cache invalidation concerns
|
||||
- Simpler implementation
|
||||
|
||||
### Directory Creation
|
||||
|
||||
**Decision:** Create directory only when saving, not when reading.
|
||||
|
||||
**Rationale:**
|
||||
- Don't create empty directories on read operations
|
||||
- Users who never save config won't have unnecessary directories
|
||||
- Aligns with principle of least surprise
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Config file corruption | Return defaults on parse error, log warning |
|
||||
| Permissions issues | Check write permissions before save, clear error message |
|
||||
| Future schema changes | Use optional fields, add version field if needed later |
|
||||
|
||||
## Open Questions
|
||||
|
||||
None - this proposal is intentionally minimal.
|
||||
@@ -0,0 +1,20 @@
|
||||
## Why
|
||||
|
||||
OpenSpec currently has no mechanism for user-level global settings or feature flags. As the CLI grows, we need a standard location to store user preferences, experimental features, and other configuration that persists across projects. Following XDG Base Directory Specification provides a well-understood, cross-platform approach.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add new `src/core/global-config.ts` module with:
|
||||
- Path resolution following XDG Base Directory spec (`$XDG_CONFIG_HOME/openspec/` or fallback)
|
||||
- Cross-platform support (Unix, macOS, Windows)
|
||||
- Lazy config loading with sensible defaults
|
||||
- TypeScript types for config shape
|
||||
- Export a global config directory path getter for future use (workflows, templates, cache)
|
||||
- Initial config schema supports 1-2 settings/feature flags only
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: New `global-config` capability (no existing specs modified)
|
||||
- Affected code:
|
||||
- New `src/core/global-config.ts`
|
||||
- Update `src/core/index.ts` to export new module
|
||||
@@ -0,0 +1,76 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Global Config Directory Path
|
||||
|
||||
The system SHALL resolve the global configuration directory path following XDG Base Directory Specification with platform-specific fallbacks.
|
||||
|
||||
#### Scenario: Unix/macOS with XDG_CONFIG_HOME set
|
||||
- **WHEN** `$XDG_CONFIG_HOME` environment variable is set to `/custom/config`
|
||||
- **THEN** `getGlobalConfigDir()` returns `/custom/config/openspec`
|
||||
|
||||
#### Scenario: Unix/macOS without XDG_CONFIG_HOME
|
||||
- **WHEN** `$XDG_CONFIG_HOME` environment variable is not set
|
||||
- **AND** the platform is Unix or macOS
|
||||
- **THEN** `getGlobalConfigDir()` returns `~/.config/openspec` (expanded to absolute path)
|
||||
|
||||
#### Scenario: Windows platform
|
||||
- **WHEN** the platform is Windows
|
||||
- **AND** `%APPDATA%` is set to `C:\Users\User\AppData\Roaming`
|
||||
- **THEN** `getGlobalConfigDir()` returns `C:\Users\User\AppData\Roaming\openspec`
|
||||
|
||||
### Requirement: Global Config Loading
|
||||
|
||||
The system SHALL load global configuration from the config directory with sensible defaults when the config file does not exist or cannot be parsed.
|
||||
|
||||
#### Scenario: Config file exists and is valid
|
||||
- **WHEN** `config.json` exists in the global config directory
|
||||
- **AND** the file contains valid JSON matching the config schema
|
||||
- **THEN** `getGlobalConfig()` returns the parsed configuration
|
||||
|
||||
#### Scenario: Config file does not exist
|
||||
- **WHEN** `config.json` does not exist in the global config directory
|
||||
- **THEN** `getGlobalConfig()` returns the default configuration
|
||||
- **AND** no directory or file is created
|
||||
|
||||
#### Scenario: Config file is invalid JSON
|
||||
- **WHEN** `config.json` exists but contains invalid JSON
|
||||
- **THEN** `getGlobalConfig()` returns the default configuration
|
||||
- **AND** a warning is logged to stderr
|
||||
|
||||
### Requirement: Global Config Saving
|
||||
|
||||
The system SHALL save global configuration to the config directory, creating the directory if it does not exist.
|
||||
|
||||
#### Scenario: Save config to new directory
|
||||
- **WHEN** `saveGlobalConfig(config)` is called
|
||||
- **AND** the global config directory does not exist
|
||||
- **THEN** the directory is created
|
||||
- **AND** `config.json` is written with the provided configuration
|
||||
|
||||
#### Scenario: Save config to existing directory
|
||||
- **WHEN** `saveGlobalConfig(config)` is called
|
||||
- **AND** the global config directory already exists
|
||||
- **THEN** `config.json` is written (overwriting if exists)
|
||||
|
||||
### Requirement: Default Configuration
|
||||
|
||||
The system SHALL provide a default configuration that is used when no config file exists.
|
||||
|
||||
#### Scenario: Default config structure
|
||||
- **WHEN** no config file exists
|
||||
- **THEN** the default configuration includes an empty `featureFlags` object
|
||||
|
||||
### Requirement: Config Schema Evolution
|
||||
|
||||
The system SHALL merge loaded configuration with default values to ensure new config fields are available even when loading older config files.
|
||||
|
||||
#### Scenario: Config file missing new fields
|
||||
- **WHEN** `config.json` exists with `{ "featureFlags": {} }`
|
||||
- **AND** the current schema includes a new field `defaultAiTool`
|
||||
- **THEN** `getGlobalConfig()` returns `{ featureFlags: {}, defaultAiTool: <default> }`
|
||||
- **AND** the loaded values take precedence over defaults for fields that exist in both
|
||||
|
||||
#### Scenario: Config file has extra unknown fields
|
||||
- **WHEN** `config.json` contains fields not in the current schema
|
||||
- **THEN** the unknown fields are preserved in the returned configuration
|
||||
- **AND** no error or warning is raised
|
||||
@@ -0,0 +1,26 @@
|
||||
## 1. Core Implementation
|
||||
|
||||
- [x] 1.1 Create `src/core/global-config.ts` with path resolution
|
||||
- Implement `getGlobalConfigDir()` following XDG spec
|
||||
- Support `$XDG_CONFIG_HOME` environment variable override
|
||||
- Platform-specific fallbacks (Unix: `~/.config/`, Windows: `%APPDATA%`)
|
||||
- [x] 1.2 Define TypeScript interfaces for config shape
|
||||
- `GlobalConfig` interface with optional fields
|
||||
- Start minimal: just `featureFlags?: Record<string, boolean>`
|
||||
- [x] 1.3 Implement config loading with defaults
|
||||
- `getGlobalConfig()` - reads config.json if exists, merges with defaults
|
||||
- No directory/file creation on read (lazy initialization)
|
||||
- [x] 1.4 Implement config saving
|
||||
- `saveGlobalConfig(config)` - writes config.json, creates directory if needed
|
||||
|
||||
## 2. Integration
|
||||
|
||||
- [x] 2.1 Export new module from `src/core/index.ts`
|
||||
- [x] 2.2 Add constants for config file name and directory name
|
||||
|
||||
## 3. Testing
|
||||
|
||||
- [x] 3.1 Manual testing of path resolution on current platform
|
||||
- [x] 3.2 Test with/without `$XDG_CONFIG_HOME` set
|
||||
- [x] 3.3 Test config load when file doesn't exist (should return defaults)
|
||||
- [x] 3.4 Unit tests in `test/core/global-config.test.ts` (18 tests)
|
||||
@@ -0,0 +1,13 @@
|
||||
## Why
|
||||
The Cline implementation was architecturally incorrect. According to Cline's official documentation, Cline uses workflows for on-demand automation and rules for behavioral guidelines. The OpenSpec slash commands are procedural workflows (scaffold → implement → archive), not behavioral rules, so they should be placed in `.clinerules/workflows/` instead of `.clinerules/`.
|
||||
|
||||
## What Changes
|
||||
- Update ClineSlashCommandConfigurator to use `.clinerules/workflows/` paths instead of `.clinerules/` paths
|
||||
- Update all tests to expect the correct workflow file locations
|
||||
- Update README.md documentation to reflect workflows instead of rules
|
||||
- **BREAKING**: Existing Cline users will need to re-run `openspec init` to get the corrected workflow files
|
||||
|
||||
## Impact
|
||||
- Affected specs: cli-init (corrected Cline workflow paths)
|
||||
- Affected code: `src/core/configurators/slash/cline.ts`, test files, README.md
|
||||
- Modified files: `.clinerules/workflows/openspec-*.md` (moved from `.clinerules/openspec-*.md`)
|
||||
@@ -0,0 +1,11 @@
|
||||
# Delta for CLI Init
|
||||
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Slash Command Configuration
|
||||
|
||||
#### Scenario: Generating slash commands for Cline
|
||||
- **WHEN** the user selects Cline during initialization
|
||||
- **THEN** create `.clinerules/workflows/openspec-proposal.md`, `.clinerules/workflows/openspec-apply.md`, and `.clinerules/workflows/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** include Cline-specific Markdown heading frontmatter
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
@@ -0,0 +1,13 @@
|
||||
## 1. Update ClineSlashCommandConfigurator
|
||||
- [x] Change FILE_PATHS in `src/core/configurators/slash/cline.ts` from `.clinerules/openspec-*.md` to `.clinerules/workflows/openspec-*.md`
|
||||
|
||||
## 2. Update Tests
|
||||
- [x] Update "should refresh existing Cline rule files" test in `test/core/update.test.ts` to use workflow paths
|
||||
- [x] Update "should create Cline rule files with templates" test in `test/core/init.test.ts` to use workflow paths
|
||||
|
||||
## 3. Update Documentation
|
||||
- [x] Update README.md table to show "Workflows in `.clinerules/workflows/` directory" for Cline
|
||||
|
||||
## 4. Validate Changes
|
||||
- [x] Ensure all tests pass with the new paths
|
||||
- [x] Verify the change follows OpenSpec conventions
|
||||
@@ -0,0 +1,287 @@
|
||||
# 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.
|
||||
## Requirements
|
||||
### Requirement: Native Shell Behavior Integration
|
||||
|
||||
The completion system SHALL respect and integrate with Zsh'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: No custom UX patterns
|
||||
|
||||
- **WHEN** implementing Zsh completion
|
||||
- **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
|
||||
|
||||
### Requirement: Command Structure
|
||||
|
||||
The completion command SHALL follow a subcommand pattern for generating and managing completion scripts.
|
||||
|
||||
#### Scenario: Available subcommands
|
||||
|
||||
- **WHEN** user executes `openspec completion --help`
|
||||
- **THEN** display available subcommands:
|
||||
- `generate [shell]` - Generate completion script for a shell (outputs to stdout)
|
||||
- `install [shell]` - Install completion for Zsh (auto-detects or requires explicit shell)
|
||||
- `uninstall [shell]` - Remove completion for Zsh (auto-detects or requires explicit 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 `zsh`
|
||||
- **AND** throw an error if the shell is not `zsh`, with message indicating only Zsh is currently supported
|
||||
|
||||
#### Scenario: Non-Zsh shell detection
|
||||
|
||||
- **WHEN** shell path indicates bash, fish, powershell, or other non-Zsh shell
|
||||
- **THEN** throw error: "Shell '<name>' is not supported yet. Currently supported: zsh"
|
||||
|
||||
### Requirement: Completion Generation
|
||||
|
||||
The completion command SHALL generate Zsh completion scripts 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
|
||||
|
||||
### Requirement: Dynamic Completions
|
||||
|
||||
The completion system SHALL provide context-aware dynamic completions for project-specific values.
|
||||
|
||||
#### Scenario: Completing change IDs
|
||||
|
||||
- **WHEN** completing arguments for commands that accept change names (show, validate, archive)
|
||||
- **THEN** discover active changes from `openspec/changes/` directory
|
||||
- **AND** exclude archived changes in `openspec/changes/archive/`
|
||||
- **AND** return change IDs as completion suggestions
|
||||
- **AND** only provide suggestions when inside an OpenSpec-enabled project
|
||||
|
||||
#### Scenario: Completing spec IDs
|
||||
|
||||
- **WHEN** completing arguments for commands that accept spec names (show, validate)
|
||||
- **THEN** discover specs from `openspec/specs/` directory
|
||||
- **AND** return spec IDs as completion suggestions
|
||||
- **AND** only provide suggestions when inside an OpenSpec-enabled project
|
||||
|
||||
#### Scenario: Completion caching
|
||||
|
||||
- **WHEN** dynamic completions are requested
|
||||
- **THEN** cache discovered change and spec IDs for 2 seconds
|
||||
- **AND** reuse cached values for subsequent requests within cache window
|
||||
- **AND** automatically refresh cache after expiration
|
||||
|
||||
#### Scenario: Project detection
|
||||
|
||||
- **WHEN** user requests completions outside an OpenSpec project
|
||||
- **THEN** skip dynamic change/spec ID completions
|
||||
- **AND** only suggest static commands and flags
|
||||
|
||||
### Requirement: Installation Automation
|
||||
|
||||
The completion command SHALL automatically install completion scripts into shell configuration files.
|
||||
|
||||
#### 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: Auto-detecting Zsh for installation
|
||||
|
||||
- **WHEN** user executes `openspec completion install` without specifying a shell
|
||||
- **THEN** detect current shell using shell detection logic
|
||||
- **AND** install completion if detected shell is Zsh
|
||||
- **AND** throw error if detected shell is not Zsh
|
||||
- **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.
|
||||
|
||||
#### Scenario: Uninstalling Oh My Zsh completion
|
||||
|
||||
- **WHEN** user executes `openspec completion uninstall zsh`
|
||||
- **THEN** prompt for confirmation before proceeding (unless `--yes` flag provided)
|
||||
- **AND** if user declines, cancel uninstall and display "Uninstall cancelled."
|
||||
- **AND** if user confirms, remove `~/.oh-my-zsh/custom/completions/_openspec` if Oh My Zsh is detected
|
||||
- **AND** remove `~/.zsh/completions/_openspec` if standard Zsh setup is detected
|
||||
- **AND** remove fpath modifications from `~/.zshrc`
|
||||
- **AND** display success message
|
||||
|
||||
#### Scenario: Auto-detecting Zsh 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
|
||||
|
||||
#### 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.
|
||||
|
||||
#### 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)
|
||||
|
||||
#### 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
|
||||
- `acceptsChangeId: boolean` - Whether command takes change ID argument
|
||||
- `acceptsSpecId: boolean` - Whether command takes spec ID argument
|
||||
- `subcommands?: CommandDefinition[]` - Nested subcommands
|
||||
- **AND** export a `COMMAND_REGISTRY` constant with all command definitions
|
||||
- **AND** generators consume this registry to ensure consistency
|
||||
|
||||
#### Scenario: Type-safe shell detection
|
||||
|
||||
- **WHEN** implementing shell detection
|
||||
- **THEN** define a `SupportedShell` type as literal type: `'zsh'`
|
||||
- **AND** implement `detectShell()` function that returns 'zsh' or throws error
|
||||
- **AND** design type to be extensible (e.g., future: `'bash' | 'zsh' | 'fish' | 'powershell'`)
|
||||
|
||||
### Requirement: Error Handling
|
||||
|
||||
The completion command SHALL provide clear error messages for common failure scenarios.
|
||||
|
||||
#### 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"
|
||||
- **AND** exit with code 1
|
||||
|
||||
#### Scenario: Permission errors during installation
|
||||
|
||||
- **WHEN** installation fails due to file permission issues
|
||||
- **THEN** display clear error message indicating permission problem
|
||||
- **AND** suggest using appropriate permissions or alternative installation method
|
||||
- **AND** exit with code 1
|
||||
|
||||
#### Scenario: Missing shell configuration directory
|
||||
|
||||
- **WHEN** expected shell configuration directory doesn't exist
|
||||
- **THEN** create the directory automatically (with user notification)
|
||||
- **AND** proceed with installation
|
||||
|
||||
#### Scenario: Shell not detected
|
||||
|
||||
- **WHEN** `openspec completion install` cannot detect current shell or detects non-Zsh shell
|
||||
- **THEN** display error: "Could not auto-detect shell. Please specify shell explicitly."
|
||||
- **AND** display usage hint: "Usage: openspec completion <operation> [shell]"
|
||||
- **AND** exit with code 1
|
||||
|
||||
### Requirement: Output Format
|
||||
|
||||
The completion command SHALL provide machine-parseable and human-readable output.
|
||||
|
||||
#### Scenario: Script generation output
|
||||
|
||||
- **WHEN** generating completion script to stdout
|
||||
- **THEN** output only the completion script content (no extra messages)
|
||||
- **AND** allow redirection to files: `openspec completion generate zsh > /path/to/_openspec`
|
||||
|
||||
#### Scenario: Installation success output
|
||||
|
||||
- **WHEN** installation completes successfully
|
||||
- **THEN** display formatted success message with:
|
||||
- Checkmark indicator
|
||||
- Installation location
|
||||
- Next steps (shell reload instructions)
|
||||
- **AND** use colors when terminal supports it (unless `--no-color` is set)
|
||||
|
||||
#### Scenario: Verbose installation output
|
||||
|
||||
- **WHEN** user provides `--verbose` flag during installation
|
||||
- **THEN** display detailed steps:
|
||||
- Shell detection result
|
||||
- Target file paths
|
||||
- Configuration modifications
|
||||
- File creation confirmations
|
||||
|
||||
### Requirement: Testing Support
|
||||
|
||||
The completion implementation SHALL be testable with unit and integration tests.
|
||||
|
||||
#### Scenario: Mock shell environment
|
||||
|
||||
- **WHEN** writing tests for shell detection
|
||||
- **THEN** allow overriding `$SHELL` environment variable
|
||||
- **AND** use dependency injection for file system operations
|
||||
|
||||
#### Scenario: Generator output verification
|
||||
|
||||
- **WHEN** testing completion generators
|
||||
- **THEN** verify generated scripts contain expected patterns
|
||||
- **AND** test that command registry is properly consumed
|
||||
- **AND** ensure dynamic completion placeholders are present
|
||||
|
||||
#### Scenario: Installation simulation
|
||||
|
||||
- **WHEN** testing installation logic
|
||||
- **THEN** use temporary test directories instead of actual home directories
|
||||
- **AND** verify file creation without modifying real shell configurations
|
||||
- **AND** test path resolution logic independently
|
||||
|
||||
@@ -64,6 +64,24 @@ The command SHALL properly configure selected AI tools with OpenSpec-specific in
|
||||
- **THEN** create or update `CLAUDE.md` in the project root directory (not inside openspec/)
|
||||
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
|
||||
|
||||
#### Scenario: Configuring CodeBuddy Code
|
||||
|
||||
- **WHEN** CodeBuddy Code is selected
|
||||
- **THEN** create or update `CODEBUDDY.md` in the project root directory (not inside openspec/)
|
||||
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
|
||||
|
||||
#### Scenario: Configuring Cline
|
||||
|
||||
- **WHEN** Cline is selected
|
||||
- **THEN** create or update `CLINE.md` in the project root directory (not inside openspec/)
|
||||
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
|
||||
|
||||
#### Scenario: Configuring iFlow CLI
|
||||
|
||||
- **WHEN** iFlow CLI is selected
|
||||
- **THEN** create or update `IFLOW.md` in the project root directory (not inside openspec/)
|
||||
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
|
||||
|
||||
#### Scenario: Creating new CLAUDE.md
|
||||
|
||||
- **WHEN** CLAUDE.md does not exist
|
||||
@@ -106,6 +124,12 @@ The command SHALL provide clear, actionable next steps upon successful initializ
|
||||
- **WHEN** initialization completes successfully
|
||||
- **THEN** include prompt: "Please explain the OpenSpec workflow from openspec/AGENTS.md and how I should work with you on this project"
|
||||
|
||||
#### Scenario: Displaying restart instruction
|
||||
- **WHEN** initialization completes successfully and tools were created or refreshed
|
||||
- **THEN** display a prominent restart instruction before the "Next steps" section
|
||||
- **AND** inform users that slash commands are loaded at startup
|
||||
- **AND** instruct users to restart their coding assistant to ensure /openspec commands appear
|
||||
|
||||
### Requirement: Exit Codes
|
||||
|
||||
The command SHALL use consistent exit codes to indicate different failure modes.
|
||||
@@ -154,12 +178,39 @@ The init command SHALL generate slash command files for supported editors using
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for CodeBuddy Code
|
||||
- **WHEN** the user selects CodeBuddy Code during initialization
|
||||
- **THEN** create `.codebuddy/commands/openspec/proposal.md`, `.codebuddy/commands/openspec/apply.md`, and `.codebuddy/commands/openspec/archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Cline
|
||||
- **WHEN** the user selects Cline during initialization
|
||||
- **THEN** create `.clinerules/openspec-proposal.md`, `.clinerules/openspec-apply.md`, and `.clinerules/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** include Cline-specific Markdown heading frontmatter
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Crush
|
||||
- **WHEN** the user selects Crush during initialization
|
||||
- **THEN** create `.crush/commands/openspec/proposal.md`, `.crush/commands/openspec/apply.md`, and `.crush/commands/openspec/archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** include Crush-specific frontmatter with OpenSpec category and tags
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Cursor
|
||||
- **WHEN** the user selects Cursor during initialization
|
||||
- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Factory Droid
|
||||
- **WHEN** the user selects Factory Droid during initialization
|
||||
- **THEN** create `.factory/commands/openspec-proposal.md`, `.factory/commands/openspec-apply.md`, and `.factory/commands/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates that include Factory-compatible YAML frontmatter for the `description` and `argument-hint` fields
|
||||
- **AND** include the `$ARGUMENTS` placeholder in the template body so droid receives any user-supplied input
|
||||
- **AND** wrap the generated content in OpenSpec managed markers so `openspec update` can safely refresh the commands
|
||||
|
||||
#### Scenario: Generating slash commands for OpenCode
|
||||
- **WHEN** the user selects OpenCode during initialization
|
||||
- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md`
|
||||
@@ -192,6 +243,29 @@ The init command SHALL generate slash command files for supported editors using
|
||||
- **AND** wrap the shared template body with OpenSpec markers so `openspec update` can refresh the content
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Gemini CLI
|
||||
- **WHEN** the user selects Gemini CLI during initialization
|
||||
- **THEN** create `.gemini/commands/openspec/proposal.toml`, `.gemini/commands/openspec/apply.toml`, and `.gemini/commands/openspec/archive.toml`
|
||||
- **AND** populate each file as TOML that sets a stage-specific `description = "<summary>"` and a multi-line `prompt = """` block with the shared OpenSpec template
|
||||
- **AND** wrap the OpenSpec managed markers (`<!-- OPENSPEC:START -->` / `<!-- OPENSPEC:END -->`) inside the `prompt` value so `openspec update` can safely refresh the body between markers without touching the TOML framing
|
||||
- **AND** ensure the slash-command copy matches the existing proposal/apply/archive templates used by other tools
|
||||
|
||||
#### Scenario: Generating slash commands for iFlow CLI
|
||||
- **WHEN** the user selects iFlow CLI during initialization
|
||||
- **THEN** create `.iflow/commands/openspec-proposal.md`, `.iflow/commands/openspec-apply.md`, and `.iflow/commands/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** include YAML frontmatter with `name`, `id`, `category`, and `description` fields for each command
|
||||
- **AND** wrap the generated content in OpenSpec managed markers so `openspec update` can safely refresh the commands
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for RooCode
|
||||
- **WHEN** the user selects RooCode during initialization
|
||||
- **THEN** create `.roo/commands/openspec-proposal.md`, `.roo/commands/openspec-apply.md`, and `.roo/commands/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** include simple Markdown headings (e.g., `# OpenSpec: Proposal`) without YAML frontmatter
|
||||
- **AND** wrap the generated content in OpenSpec managed markers where applicable so `openspec update` can safely refresh the commands
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
### Requirement: Non-Interactive Mode
|
||||
The command SHALL support non-interactive operation through command-line options for automation and CI/CD use cases.
|
||||
|
||||
|
||||
@@ -50,22 +50,47 @@ 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.
|
||||
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 Claude Code
|
||||
- **WHEN** `.claude/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Updating slash commands for CodeBuddy Code
|
||||
- **WHEN** `.codebuddy/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Updating slash commands for Cline
|
||||
- **WHEN** `.clinerules/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** include Cline-specific Markdown heading frontmatter
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Updating slash commands for Crush
|
||||
- **WHEN** `.crush/commands/` contains `openspec/proposal.md`, `openspec/apply.md`, and `openspec/archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** include Crush-specific frontmatter with OpenSpec category and tags
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Updating slash commands for Cursor
|
||||
- **WHEN** `.cursor/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Updating slash commands for Factory Droid
|
||||
- **WHEN** `.factory/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh each file using the shared Factory templates that include YAML frontmatter for the `description` and `argument-hint` fields
|
||||
- **AND** ensure the template body retains the `$ARGUMENTS` placeholder so user input keeps flowing into droid
|
||||
- **AND** update only the content inside the OpenSpec managed markers, leaving any unmanaged notes untouched
|
||||
- **AND** skip creating missing files during update
|
||||
|
||||
#### Scenario: Updating slash commands for OpenCode
|
||||
- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
- **AND** ensure the archive command includes `$ARGUMENTS` placeholder in frontmatter for accepting change ID arguments
|
||||
|
||||
#### Scenario: Updating slash commands for Windsurf
|
||||
- **WHEN** `.windsurf/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
@@ -92,10 +117,43 @@ The update command SHALL refresh existing slash command files for configured too
|
||||
- **AND** update only the OpenSpec-managed block between markers
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Updating slash commands for Gemini CLI
|
||||
- **WHEN** `.gemini/commands/openspec/` contains `proposal.toml`, `apply.toml`, and `archive.toml`
|
||||
- **THEN** refresh the body of each file using the shared proposal/apply/archive templates
|
||||
- **AND** replace only the content between `<!-- OPENSPEC:START -->` and `<!-- OPENSPEC:END -->` markers inside the `prompt = """` block so the TOML framing (`description`, `prompt`) stays intact
|
||||
- **AND** skip creating any missing `.toml` files during update; only pre-existing Gemini commands are refreshed
|
||||
|
||||
#### Scenario: Updating slash commands for iFlow CLI
|
||||
- **WHEN** `.iflow/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** preserve the YAML frontmatter with `name`, `id`, `category`, and `description` fields
|
||||
- **AND** update only the OpenSpec-managed block between markers
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Missing slash command file
|
||||
- **WHEN** a tool lacks a slash command file
|
||||
- **THEN** do not create a new file during update
|
||||
|
||||
### Requirement: Archive Command Argument Support
|
||||
The archive slash command template SHALL support optional change ID arguments for tools that support `$ARGUMENTS` placeholder.
|
||||
|
||||
#### Scenario: Archive command with change ID argument
|
||||
- **WHEN** a user invokes `/openspec:archive <change-id>` with a change ID
|
||||
- **THEN** the template SHALL instruct the AI to validate the provided change ID against `openspec list`
|
||||
- **AND** use the provided change ID for archiving if valid
|
||||
- **AND** fail fast if the provided change ID doesn't match an archivable change
|
||||
|
||||
#### Scenario: Archive command without argument (backward compatibility)
|
||||
- **WHEN** a user invokes `/openspec:archive` without providing a change ID
|
||||
- **THEN** the template SHALL instruct the AI to identify the change ID from context or by running `openspec list`
|
||||
- **AND** proceed with the existing behavior (maintaining backward compatibility)
|
||||
|
||||
#### Scenario: OpenCode archive template generation
|
||||
- **WHEN** generating the OpenCode archive slash command file
|
||||
- **THEN** include the `$ARGUMENTS` placeholder in the frontmatter
|
||||
- **AND** wrap it in a clear structure like `<ChangeId>\n $ARGUMENTS\n</ChangeId>` to indicate the expected argument
|
||||
- **AND** include validation steps in the template body to check if the change ID is valid
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### Requirement: Error Handling
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# global-config Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
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 Config Directory Path
|
||||
|
||||
The system SHALL resolve the global configuration directory path following XDG Base Directory Specification with platform-specific fallbacks.
|
||||
|
||||
#### Scenario: Unix/macOS with XDG_CONFIG_HOME set
|
||||
- **WHEN** `$XDG_CONFIG_HOME` environment variable is set to `/custom/config`
|
||||
- **THEN** `getGlobalConfigDir()` returns `/custom/config/openspec`
|
||||
|
||||
#### Scenario: Unix/macOS without XDG_CONFIG_HOME
|
||||
- **WHEN** `$XDG_CONFIG_HOME` environment variable is not set
|
||||
- **AND** the platform is Unix or macOS
|
||||
- **THEN** `getGlobalConfigDir()` returns `~/.config/openspec` (expanded to absolute path)
|
||||
|
||||
#### Scenario: Windows platform
|
||||
- **WHEN** the platform is Windows
|
||||
- **AND** `%APPDATA%` is set to `C:\Users\User\AppData\Roaming`
|
||||
- **THEN** `getGlobalConfigDir()` returns `C:\Users\User\AppData\Roaming\openspec`
|
||||
|
||||
### Requirement: Global Config Loading
|
||||
|
||||
The system SHALL load global configuration from the config directory with sensible defaults when the config file does not exist or cannot be parsed.
|
||||
|
||||
#### Scenario: Config file exists and is valid
|
||||
- **WHEN** `config.json` exists in the global config directory
|
||||
- **AND** the file contains valid JSON matching the config schema
|
||||
- **THEN** `getGlobalConfig()` returns the parsed configuration
|
||||
|
||||
#### Scenario: Config file does not exist
|
||||
- **WHEN** `config.json` does not exist in the global config directory
|
||||
- **THEN** `getGlobalConfig()` returns the default configuration
|
||||
- **AND** no directory or file is created
|
||||
|
||||
#### Scenario: Config file is invalid JSON
|
||||
- **WHEN** `config.json` exists but contains invalid JSON
|
||||
- **THEN** `getGlobalConfig()` returns the default configuration
|
||||
- **AND** a warning is logged to stderr
|
||||
|
||||
### Requirement: Global Config Saving
|
||||
|
||||
The system SHALL save global configuration to the config directory, creating the directory if it does not exist.
|
||||
|
||||
#### Scenario: Save config to new directory
|
||||
- **WHEN** `saveGlobalConfig(config)` is called
|
||||
- **AND** the global config directory does not exist
|
||||
- **THEN** the directory is created
|
||||
- **AND** `config.json` is written with the provided configuration
|
||||
|
||||
#### Scenario: Save config to existing directory
|
||||
- **WHEN** `saveGlobalConfig(config)` is called
|
||||
- **AND** the global config directory already exists
|
||||
- **THEN** `config.json` is written (overwriting if exists)
|
||||
|
||||
### Requirement: Default Configuration
|
||||
|
||||
The system SHALL provide a default configuration that is used when no config file exists.
|
||||
|
||||
#### Scenario: Default config structure
|
||||
- **WHEN** no config file exists
|
||||
- **THEN** the default configuration includes an empty `featureFlags` object
|
||||
|
||||
### Requirement: Config Schema Evolution
|
||||
|
||||
The system SHALL merge loaded configuration with default values to ensure new config fields are available even when loading older config files.
|
||||
|
||||
#### Scenario: Config file missing new fields
|
||||
- **WHEN** `config.json` exists with `{ "featureFlags": {} }`
|
||||
- **AND** the current schema includes a new field `defaultAiTool`
|
||||
- **THEN** `getGlobalConfig()` returns `{ featureFlags: {}, defaultAiTool: <default> }`
|
||||
- **AND** the loaded values take precedence over defaults for fields that exist in both
|
||||
|
||||
#### Scenario: Config file has extra unknown fields
|
||||
- **WHEN** `config.json` contains fields not in the current schema
|
||||
- **THEN** the unknown fields are preserved in the returned configuration
|
||||
- **AND** no error or warning is raised
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fission-ai/openspec",
|
||||
"version": "0.12.0",
|
||||
"version": "0.16.0",
|
||||
"description": "AI-native system for spec-driven development",
|
||||
"keywords": [
|
||||
"openspec",
|
||||
@@ -32,6 +32,7 @@
|
||||
"files": [
|
||||
"dist",
|
||||
"bin",
|
||||
"scripts/postinstall.js",
|
||||
"!dist/**/*.test.js",
|
||||
"!dist/**/__tests__",
|
||||
"!dist/**/*.map"
|
||||
@@ -44,8 +45,10 @@
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:postinstall": "node scripts/postinstall.js",
|
||||
"prepare": "pnpm run build",
|
||||
"prepublishOnly": "pnpm run build",
|
||||
"postinstall": "node scripts/postinstall.js",
|
||||
"check:pack-version": "node scripts/pack-version-check.mjs",
|
||||
"release": "pnpm run release:ci",
|
||||
"release:ci": "pnpm run check:pack-version && pnpm exec changeset publish",
|
||||
@@ -59,7 +62,7 @@
|
||||
"@changesets/cli": "^2.27.7",
|
||||
"@types/node": "^24.2.0",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
Generated
+5
-5
@@ -37,8 +37,8 @@ importers:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(vitest@3.2.4)
|
||||
typescript:
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.2
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)
|
||||
@@ -1115,8 +1115,8 @@ packages:
|
||||
resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
typescript@5.9.2:
|
||||
resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==}
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
@@ -2223,7 +2223,7 @@ snapshots:
|
||||
|
||||
type-fest@0.21.3: {}
|
||||
|
||||
typescript@5.9.2: {}
|
||||
typescript@5.9.3: {}
|
||||
|
||||
undici-types@7.10.0: {}
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Postinstall script for auto-installing shell completions
|
||||
*
|
||||
* This script runs automatically after npm install unless:
|
||||
* - CI=true environment variable is set
|
||||
* - OPENSPEC_NO_COMPLETIONS=1 environment variable is set
|
||||
* - dist/ directory doesn't exist (dev setup scenario)
|
||||
*
|
||||
* The script never fails npm install - all errors are caught and handled gracefully.
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
/**
|
||||
* Check if we should skip installation
|
||||
*/
|
||||
function shouldSkipInstallation() {
|
||||
// Skip in CI environments
|
||||
if (process.env.CI === 'true' || process.env.CI === '1') {
|
||||
return { skip: true, reason: 'CI environment detected' };
|
||||
}
|
||||
|
||||
// Skip if user opted out
|
||||
if (process.env.OPENSPEC_NO_COMPLETIONS === '1') {
|
||||
return { skip: true, reason: 'OPENSPEC_NO_COMPLETIONS=1 set' };
|
||||
}
|
||||
|
||||
return { skip: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if dist/ directory exists
|
||||
*/
|
||||
async function distExists() {
|
||||
const distPath = path.join(__dirname, '..', 'dist');
|
||||
try {
|
||||
const stat = await fs.stat(distPath);
|
||||
return stat.isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the user's shell
|
||||
*/
|
||||
async function detectShell() {
|
||||
try {
|
||||
const { detectShell } = await import('../dist/utils/shell-detection.js');
|
||||
const result = detectShell();
|
||||
return result.shell;
|
||||
} catch (error) {
|
||||
// Fail silently if detection module doesn't exist
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install completions for the detected shell
|
||||
*/
|
||||
async function installCompletions(shell) {
|
||||
try {
|
||||
const { CompletionFactory } = await import('../dist/core/completions/factory.js');
|
||||
const { COMMAND_REGISTRY } = await import('../dist/core/completions/command-registry.js');
|
||||
|
||||
// Check if shell is supported
|
||||
if (!CompletionFactory.isSupported(shell)) {
|
||||
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate completion script
|
||||
const generator = CompletionFactory.createGenerator(shell);
|
||||
const script = generator.generate(COMMAND_REGISTRY);
|
||||
|
||||
// Install completion script
|
||||
const installer = CompletionFactory.createInstaller(shell);
|
||||
const result = await installer.install(script);
|
||||
|
||||
if (result.success) {
|
||||
// Show success message based on installation type
|
||||
if (result.isOhMyZsh) {
|
||||
console.log(`✓ Shell completions installed`);
|
||||
console.log(` Restart shell: exec zsh`);
|
||||
} else if (result.zshrcConfigured) {
|
||||
console.log(`✓ Shell completions installed and configured`);
|
||||
console.log(` Restart shell: exec zsh`);
|
||||
} else {
|
||||
console.log(`✓ Shell completions installed to ~/.zsh/completions/`);
|
||||
console.log(` Add to ~/.zshrc: fpath=(~/.zsh/completions $fpath)`);
|
||||
console.log(` Then: exec zsh`);
|
||||
}
|
||||
} else {
|
||||
// Installation failed, show tip for manual install
|
||||
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Fail gracefully - show tip for manual install
|
||||
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function
|
||||
*/
|
||||
async function main() {
|
||||
try {
|
||||
// Check if we should skip
|
||||
const skipCheck = shouldSkipInstallation();
|
||||
if (skipCheck.skip) {
|
||||
// Silent skip - no output
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if dist/ exists (skip silently if not - expected during dev setup)
|
||||
if (!(await distExists())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect shell
|
||||
const shell = await detectShell();
|
||||
if (!shell) {
|
||||
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Install completions
|
||||
await installCompletions(shell);
|
||||
} catch (error) {
|
||||
// Fail gracefully - never break npm install
|
||||
// Show tip for manual install
|
||||
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
|
||||
}
|
||||
}
|
||||
|
||||
// Run main and handle any unhandled errors
|
||||
main().catch(() => {
|
||||
// Silent failure - never break npm install
|
||||
process.exit(0);
|
||||
});
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Test script for postinstall.js
|
||||
# Tests different scenarios: normal install, CI, opt-out
|
||||
|
||||
set -e
|
||||
|
||||
echo "======================================"
|
||||
echo "Testing OpenSpec Postinstall Script"
|
||||
echo "======================================"
|
||||
echo ""
|
||||
|
||||
# Save original environment
|
||||
ORIGINAL_CI="${CI:-}"
|
||||
ORIGINAL_OPENSPEC_NO_COMPLETIONS="${OPENSPEC_NO_COMPLETIONS:-}"
|
||||
|
||||
# Test 1: Normal install
|
||||
echo "Test 1: Normal install (should attempt to install completions)"
|
||||
echo "--------------------------------------"
|
||||
unset CI
|
||||
unset OPENSPEC_NO_COMPLETIONS
|
||||
node scripts/postinstall.js
|
||||
echo ""
|
||||
|
||||
# Test 2: CI environment (should skip silently)
|
||||
echo "Test 2: CI=true (should skip silently)"
|
||||
echo "--------------------------------------"
|
||||
export CI=true
|
||||
node scripts/postinstall.js
|
||||
echo "[No output expected - skipped due to CI]"
|
||||
echo ""
|
||||
|
||||
# Test 3: Opt-out flag (should skip silently)
|
||||
echo "Test 3: OPENSPEC_NO_COMPLETIONS=1 (should skip silently)"
|
||||
echo "--------------------------------------"
|
||||
unset CI
|
||||
export OPENSPEC_NO_COMPLETIONS=1
|
||||
node scripts/postinstall.js
|
||||
echo "[No output expected - skipped due to opt-out]"
|
||||
echo ""
|
||||
|
||||
# Restore original environment
|
||||
if [ -n "$ORIGINAL_CI" ]; then
|
||||
export CI="$ORIGINAL_CI"
|
||||
else
|
||||
unset CI
|
||||
fi
|
||||
|
||||
if [ -n "$ORIGINAL_OPENSPEC_NO_COMPLETIONS" ]; then
|
||||
export OPENSPEC_NO_COMPLETIONS="$ORIGINAL_OPENSPEC_NO_COMPLETIONS"
|
||||
else
|
||||
unset OPENSPEC_NO_COMPLETIONS
|
||||
fi
|
||||
|
||||
echo "======================================"
|
||||
echo "All tests completed successfully!"
|
||||
echo "======================================"
|
||||
+66
-2
@@ -3,7 +3,6 @@ import { createRequire } from 'module';
|
||||
import ora from 'ora';
|
||||
import path from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
import { InitCommand } from '../core/init.js';
|
||||
import { AI_TOOLS } from '../core/config.js';
|
||||
import { UpdateCommand } from '../core/update.js';
|
||||
import { ListCommand } from '../core/list.js';
|
||||
@@ -13,6 +12,7 @@ import { registerSpecCommand } from '../commands/spec.js';
|
||||
import { ChangeCommand } from '../commands/change.js';
|
||||
import { ValidateCommand } from '../commands/validate.js';
|
||||
import { ShowCommand } from '../commands/show.js';
|
||||
import { CompletionCommand } from '../commands/completion.js';
|
||||
|
||||
const program = new Command();
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -29,7 +29,7 @@ program.option('--no-color', 'Disable color output');
|
||||
// Apply global flags before any command runs
|
||||
program.hook('preAction', (thisCommand) => {
|
||||
const opts = thisCommand.opts();
|
||||
if (opts.noColor) {
|
||||
if (opts.color === false) {
|
||||
process.env.NO_COLOR = '1';
|
||||
}
|
||||
});
|
||||
@@ -62,6 +62,7 @@ program
|
||||
}
|
||||
}
|
||||
|
||||
const { InitCommand } = await import('../core/init.js');
|
||||
const initCommand = new InitCommand({
|
||||
tools: options?.tools,
|
||||
});
|
||||
@@ -250,4 +251,67 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
// Completion command with subcommands
|
||||
const completionCmd = program
|
||||
.command('completion')
|
||||
.description('Manage shell completions for OpenSpec CLI');
|
||||
|
||||
completionCmd
|
||||
.command('generate [shell]')
|
||||
.description('Generate completion script for a shell (outputs to stdout)')
|
||||
.action(async (shell?: string) => {
|
||||
try {
|
||||
const completionCommand = new CompletionCommand();
|
||||
await completionCommand.generate({ shell });
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
completionCmd
|
||||
.command('install [shell]')
|
||||
.description('Install completion script for a shell')
|
||||
.option('--verbose', 'Show detailed installation output')
|
||||
.action(async (shell?: string, options?: { verbose?: boolean }) => {
|
||||
try {
|
||||
const completionCommand = new CompletionCommand();
|
||||
await completionCommand.install({ shell, verbose: options?.verbose });
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
completionCmd
|
||||
.command('uninstall [shell]')
|
||||
.description('Uninstall completion script for a shell')
|
||||
.option('-y, --yes', 'Skip confirmation prompts')
|
||||
.action(async (shell?: string, options?: { yes?: boolean }) => {
|
||||
try {
|
||||
const completionCommand = new CompletionCommand();
|
||||
await completionCommand.uninstall({ shell, yes: options?.yes });
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Hidden command for machine-readable completion data
|
||||
program
|
||||
.command('__complete <type>', { hidden: true })
|
||||
.description('Output completion data in machine-readable format (internal use)')
|
||||
.action(async (type: string) => {
|
||||
try {
|
||||
const completionCommand = new CompletionCommand();
|
||||
await completionCommand.complete({ type });
|
||||
} catch (error) {
|
||||
// Silently fail for graceful shell completion experience
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
program.parse();
|
||||
|
||||
+15
-14
@@ -1,6 +1,5 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { select } from '@inquirer/prompts';
|
||||
import { JsonConverter } from '../core/converters/json-converter.js';
|
||||
import { Validator } from '../core/validation/validator.js';
|
||||
import { ChangeParser } from '../core/parsers/change-parser.js';
|
||||
@@ -28,11 +27,12 @@ export class ChangeCommand {
|
||||
*/
|
||||
async show(changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean; noInteractive?: boolean }): Promise<void> {
|
||||
const changesPath = path.join(process.cwd(), 'openspec', 'changes');
|
||||
|
||||
|
||||
if (!changeName) {
|
||||
const canPrompt = isInteractive(options?.noInteractive);
|
||||
const canPrompt = isInteractive(options);
|
||||
const changes = await this.getActiveChanges(changesPath);
|
||||
if (canPrompt && changes.length > 0) {
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
const selected = await select({
|
||||
message: 'Select a change to show',
|
||||
choices: changes.map(id => ({ name: id, value: id })),
|
||||
@@ -49,25 +49,25 @@ export class ChangeCommand {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const proposalPath = path.join(changesPath, changeName, 'proposal.md');
|
||||
|
||||
|
||||
try {
|
||||
await fs.access(proposalPath);
|
||||
} catch {
|
||||
throw new Error(`Change "${changeName}" not found at ${proposalPath}`);
|
||||
}
|
||||
|
||||
|
||||
if (options?.json) {
|
||||
const jsonOutput = await this.converter.convertChangeToJson(proposalPath);
|
||||
|
||||
|
||||
if (options.requirementsOnly) {
|
||||
console.error('Flag --requirements-only is deprecated; use --deltas-only instead.');
|
||||
}
|
||||
|
||||
const parsed: Change = JSON.parse(jsonOutput);
|
||||
const contentForTitle = await fs.readFile(proposalPath, 'utf-8');
|
||||
const title = this.extractTitle(contentForTitle);
|
||||
const title = this.extractTitle(contentForTitle, changeName);
|
||||
const id = parsed.name;
|
||||
const deltas = parsed.deltas || [];
|
||||
|
||||
@@ -124,7 +124,7 @@ export class ChangeCommand {
|
||||
|
||||
return {
|
||||
id: changeName,
|
||||
title: this.extractTitle(content),
|
||||
title: this.extractTitle(content, changeName),
|
||||
deltaCount: change.deltas.length,
|
||||
taskStatus,
|
||||
};
|
||||
@@ -159,7 +159,7 @@ export class ChangeCommand {
|
||||
const tasksPath = path.join(changesPath, changeName, 'tasks.md');
|
||||
try {
|
||||
const content = await fs.readFile(proposalPath, 'utf-8');
|
||||
const title = this.extractTitle(content);
|
||||
const title = this.extractTitle(content, changeName);
|
||||
let taskStatusText = '';
|
||||
try {
|
||||
const tasksContent = await fs.readFile(tasksPath, 'utf-8');
|
||||
@@ -186,9 +186,10 @@ export class ChangeCommand {
|
||||
const changesPath = path.join(process.cwd(), 'openspec', 'changes');
|
||||
|
||||
if (!changeName) {
|
||||
const canPrompt = isInteractive(options?.noInteractive);
|
||||
const canPrompt = isInteractive(options);
|
||||
const changes = await getActiveChangeIds();
|
||||
if (canPrompt && changes.length > 0) {
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
const selected = await select({
|
||||
message: 'Select a change to validate',
|
||||
choices: changes.map(id => ({ name: id, value: id })),
|
||||
@@ -258,9 +259,9 @@ export class ChangeCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private extractTitle(content: string): string {
|
||||
const match = content.match(/^#\s+(?:Change:\s+)?(.+)$/m);
|
||||
return match ? match[1].trim() : 'Untitled Change';
|
||||
private extractTitle(content: string, changeName: string): string {
|
||||
const match = content.match(/^#\s+(?:Change:\s+)?(.+)$/im);
|
||||
return match ? match[1].trim() : changeName;
|
||||
}
|
||||
|
||||
private countTasks(content: string): { total: number; completed: number } {
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import ora from 'ora';
|
||||
import { CompletionFactory } from '../core/completions/factory.js';
|
||||
import { COMMAND_REGISTRY } from '../core/completions/command-registry.js';
|
||||
import { detectShell, SupportedShell } from '../utils/shell-detection.js';
|
||||
import { CompletionProvider } from '../core/completions/completion-provider.js';
|
||||
import { getArchivedChangeIds } from '../utils/item-discovery.js';
|
||||
|
||||
interface GenerateOptions {
|
||||
shell?: string;
|
||||
}
|
||||
|
||||
interface InstallOptions {
|
||||
shell?: string;
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
interface UninstallOptions {
|
||||
shell?: string;
|
||||
yes?: boolean;
|
||||
}
|
||||
|
||||
interface CompleteOptions {
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command for managing shell completions for OpenSpec CLI
|
||||
*/
|
||||
export class CompletionCommand {
|
||||
private completionProvider: CompletionProvider;
|
||||
|
||||
constructor() {
|
||||
this.completionProvider = new CompletionProvider();
|
||||
}
|
||||
/**
|
||||
* Resolve shell parameter or exit with error
|
||||
*
|
||||
* @param shell - The shell parameter (may be undefined)
|
||||
* @param operationName - Name of the operation (for error messages)
|
||||
* @returns Resolved shell or null if should exit
|
||||
*/
|
||||
private resolveShellOrExit(shell: string | undefined, operationName: string): SupportedShell | null {
|
||||
const normalizedShell = this.normalizeShell(shell);
|
||||
|
||||
if (!normalizedShell) {
|
||||
const detectionResult = detectShell();
|
||||
|
||||
if (detectionResult.shell && CompletionFactory.isSupported(detectionResult.shell)) {
|
||||
return detectionResult.shell;
|
||||
}
|
||||
|
||||
// Shell was detected but not supported
|
||||
if (detectionResult.detected && !detectionResult.shell) {
|
||||
console.error(`Error: Shell '${detectionResult.detected}' is not supported yet. Currently supported: ${CompletionFactory.getSupportedShells().join(', ')}`);
|
||||
process.exitCode = 1;
|
||||
return null;
|
||||
}
|
||||
|
||||
// No shell specified and cannot auto-detect
|
||||
console.error('Error: Could not auto-detect shell. Please specify shell explicitly.');
|
||||
console.error(`Usage: openspec completion ${operationName} [shell]`);
|
||||
console.error(`Currently supported: ${CompletionFactory.getSupportedShells().join(', ')}`);
|
||||
process.exitCode = 1;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!CompletionFactory.isSupported(normalizedShell)) {
|
||||
console.error(`Error: Shell '${normalizedShell}' is not supported yet. Currently supported: ${CompletionFactory.getSupportedShells().join(', ')}`);
|
||||
process.exitCode = 1;
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizedShell;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate completion script and output to stdout
|
||||
*
|
||||
* @param options - Options for generation (shell type)
|
||||
*/
|
||||
async generate(options: GenerateOptions = {}): Promise<void> {
|
||||
const shell = this.resolveShellOrExit(options.shell, 'generate');
|
||||
if (!shell) return;
|
||||
|
||||
await this.generateForShell(shell);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install completion script to the appropriate location
|
||||
*
|
||||
* @param options - Options for installation (shell type, verbose output)
|
||||
*/
|
||||
async install(options: InstallOptions = {}): Promise<void> {
|
||||
const shell = this.resolveShellOrExit(options.shell, 'install');
|
||||
if (!shell) return;
|
||||
|
||||
await this.installForShell(shell, options.verbose || false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall completion script from the installation location
|
||||
*
|
||||
* @param options - Options for uninstallation (shell type, yes flag)
|
||||
*/
|
||||
async uninstall(options: UninstallOptions = {}): Promise<void> {
|
||||
const shell = this.resolveShellOrExit(options.shell, 'uninstall');
|
||||
if (!shell) return;
|
||||
|
||||
await this.uninstallForShell(shell, options.yes || false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate completion script for a specific shell
|
||||
*/
|
||||
private async generateForShell(shell: SupportedShell): Promise<void> {
|
||||
const generator = CompletionFactory.createGenerator(shell);
|
||||
const script = generator.generate(COMMAND_REGISTRY);
|
||||
console.log(script);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install completion script for a specific shell
|
||||
*/
|
||||
private async installForShell(shell: SupportedShell, verbose: boolean): Promise<void> {
|
||||
const generator = CompletionFactory.createGenerator(shell);
|
||||
const installer = CompletionFactory.createInstaller(shell);
|
||||
|
||||
const spinner = ora(`Installing ${shell} completion script...`).start();
|
||||
|
||||
try {
|
||||
// Generate the completion script
|
||||
const script = generator.generate(COMMAND_REGISTRY);
|
||||
|
||||
// Install it
|
||||
const result = await installer.install(script);
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (result.success) {
|
||||
console.log(`✓ ${result.message}`);
|
||||
|
||||
if (verbose && result.installedPath) {
|
||||
console.log(` Installed to: ${result.installedPath}`);
|
||||
if (result.backupPath) {
|
||||
console.log(` Backup created: ${result.backupPath}`);
|
||||
}
|
||||
if (result.zshrcConfigured) {
|
||||
console.log(` ~/.zshrc configured automatically`);
|
||||
}
|
||||
}
|
||||
|
||||
// Print instructions (only shown if .zshrc wasn't auto-configured)
|
||||
if (result.instructions && result.instructions.length > 0) {
|
||||
console.log('');
|
||||
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 {
|
||||
console.error(`✗ ${result.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.stop();
|
||||
console.error(`✗ Failed to install completion script: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall completion script for a specific shell
|
||||
*/
|
||||
private async uninstallForShell(shell: SupportedShell, skipConfirmation: boolean): Promise<void> {
|
||||
const installer = CompletionFactory.createInstaller(shell);
|
||||
|
||||
// Prompt for confirmation unless --yes flag is provided
|
||||
if (!skipConfirmation) {
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
const confirmed = await confirm({
|
||||
message: 'Remove OpenSpec configuration from ~/.zshrc?',
|
||||
default: false,
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
console.log('Uninstall cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const spinner = ora(`Uninstalling ${shell} completion script...`).start();
|
||||
|
||||
try {
|
||||
const result = await installer.uninstall();
|
||||
|
||||
spinner.stop();
|
||||
|
||||
if (result.success) {
|
||||
console.log(`✓ ${result.message}`);
|
||||
} else {
|
||||
console.error(`✗ ${result.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.stop();
|
||||
console.error(`✗ Failed to uninstall completion script: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Output machine-readable completion data for shell consumption
|
||||
* Format: tab-separated "id\tdescription" per line
|
||||
*
|
||||
* @param options - Options specifying completion type
|
||||
*/
|
||||
async complete(options: CompleteOptions): Promise<void> {
|
||||
const type = options.type.toLowerCase();
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
case 'changes': {
|
||||
const changeIds = await this.completionProvider.getChangeIds();
|
||||
for (const id of changeIds) {
|
||||
console.log(`${id}\tactive change`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'specs': {
|
||||
const specIds = await this.completionProvider.getSpecIds();
|
||||
for (const id of specIds) {
|
||||
console.log(`${id}\tspecification`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'archived-changes': {
|
||||
const archivedIds = await getArchivedChangeIds();
|
||||
for (const id of archivedIds) {
|
||||
console.log(`${id}\tarchived change`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Invalid type - silently exit with no output for graceful shell completion failure
|
||||
process.exitCode = 1;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail for graceful shell completion experience
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize shell parameter to lowercase
|
||||
*/
|
||||
private normalizeShell(shell?: string): string | undefined {
|
||||
return shell?.toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { select } from '@inquirer/prompts';
|
||||
import path from 'path';
|
||||
import { isInteractive } from '../utils/interactive.js';
|
||||
import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js';
|
||||
@@ -13,11 +12,12 @@ const SPEC_FLAG_KEYS = new Set(['requirements', 'scenarios', 'requirement']);
|
||||
|
||||
export class ShowCommand {
|
||||
async execute(itemName?: string, options: { json?: boolean; type?: string; noInteractive?: boolean; [k: string]: any } = {}): Promise<void> {
|
||||
const interactive = isInteractive(options.noInteractive);
|
||||
const interactive = isInteractive(options);
|
||||
const typeOverride = this.normalizeType(options.type);
|
||||
|
||||
if (!itemName) {
|
||||
if (interactive) {
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
const type = await select<ItemType>({
|
||||
message: 'What would you like to show?',
|
||||
choices: [
|
||||
@@ -44,6 +44,7 @@ export class ShowCommand {
|
||||
}
|
||||
|
||||
private async runInteractiveByType(type: ItemType, options: { json?: boolean; noInteractive?: boolean; [k: string]: any }): Promise<void> {
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
if (type === 'change') {
|
||||
const changes = await getActiveChangeIds();
|
||||
if (changes.length === 0) {
|
||||
@@ -135,5 +136,3 @@ export class ShowCommand {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { join } from 'path';
|
||||
import { MarkdownParser } from '../core/parsers/markdown-parser.js';
|
||||
import { Validator } from '../core/validation/validator.js';
|
||||
import type { Spec } from '../core/schemas/index.js';
|
||||
import { select } from '@inquirer/prompts';
|
||||
import { isInteractive } from '../utils/interactive.js';
|
||||
import { getSpecIds } from '../utils/item-discovery.js';
|
||||
|
||||
@@ -70,9 +69,10 @@ export class SpecCommand {
|
||||
|
||||
async show(specId?: string, options: ShowOptions = {}): Promise<void> {
|
||||
if (!specId) {
|
||||
const canPrompt = isInteractive(options?.noInteractive);
|
||||
const canPrompt = isInteractive(options);
|
||||
const specIds = await getSpecIds();
|
||||
if (canPrompt && specIds.length > 0) {
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
specId = await select({
|
||||
message: 'Select a spec to show',
|
||||
choices: specIds.map(id => ({ name: id, value: id })),
|
||||
@@ -204,9 +204,10 @@ export function registerSpecCommand(rootProgram: typeof program) {
|
||||
.action(async (specId: string | undefined, options: { strict?: boolean; json?: boolean; noInteractive?: boolean }) => {
|
||||
try {
|
||||
if (!specId) {
|
||||
const canPrompt = isInteractive(options?.noInteractive);
|
||||
const canPrompt = isInteractive(options);
|
||||
const specIds = await getSpecIds();
|
||||
if (canPrompt && specIds.length > 0) {
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
specId = await select({
|
||||
message: 'Select a spec to validate',
|
||||
choices: specIds.map(id => ({ name: id, value: id })),
|
||||
@@ -247,4 +248,4 @@ export function registerSpecCommand(rootProgram: typeof program) {
|
||||
});
|
||||
|
||||
return specCommand;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { select } from '@inquirer/prompts';
|
||||
import ora from 'ora';
|
||||
import path from 'path';
|
||||
import { Validator } from '../core/validation/validator.js';
|
||||
@@ -29,7 +28,7 @@ interface BulkItemResult {
|
||||
|
||||
export class ValidateCommand {
|
||||
async execute(itemName: string | undefined, options: ExecuteOptions = {}): Promise<void> {
|
||||
const interactive = isInteractive(options.noInteractive);
|
||||
const interactive = isInteractive(options);
|
||||
|
||||
// Handle bulk flags first
|
||||
if (options.all || options.changes || options.specs) {
|
||||
@@ -64,6 +63,7 @@ export class ValidateCommand {
|
||||
}
|
||||
|
||||
private async runInteractiveSelector(opts: { strict: boolean; json: boolean; concurrency?: string }): Promise<void> {
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
const choice = await select({
|
||||
message: 'What would you like to validate?',
|
||||
choices: [
|
||||
@@ -212,6 +212,28 @@ export class ValidateCommand {
|
||||
});
|
||||
}
|
||||
|
||||
if (queue.length === 0) {
|
||||
spinner?.stop();
|
||||
|
||||
const summary = {
|
||||
totals: { items: 0, passed: 0, failed: 0 },
|
||||
byType: {
|
||||
...(scope.changes ? { change: { items: 0, passed: 0, failed: 0 } } : {}),
|
||||
...(scope.specs ? { spec: { items: 0, passed: 0, failed: 0 } } : {}),
|
||||
},
|
||||
} as const;
|
||||
|
||||
if (opts.json) {
|
||||
const out = { items: [] as BulkItemResult[], summary, version: '1.0' };
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
} else {
|
||||
console.log('No items found to validate.');
|
||||
}
|
||||
|
||||
process.exitCode = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const results: BulkItemResult[] = [];
|
||||
let index = 0;
|
||||
let running = 0;
|
||||
@@ -301,5 +323,3 @@ function getPlannedType(index: number, changeIds: string[], specIds: string[]):
|
||||
if (specIndex >= 0 && specIndex < specIds.length) return 'spec';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
|
||||
+4
-1
@@ -1,6 +1,5 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { select, confirm } from '@inquirer/prompts';
|
||||
import { FileSystemUtils } from '../utils/file-system.js';
|
||||
import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
|
||||
import { Validator } from './validation/validator.js';
|
||||
@@ -125,6 +124,7 @@ export class ArchiveCommand {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
if (!options.yes) {
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
const proceed = await confirm({
|
||||
message: chalk.yellow('⚠️ WARNING: Skipping validation may archive invalid specs. Continue? (y/N)'),
|
||||
default: false
|
||||
@@ -149,6 +149,7 @@ export class ArchiveCommand {
|
||||
const incompleteTasks = Math.max(progress.total - progress.completed, 0);
|
||||
if (incompleteTasks > 0) {
|
||||
if (!options.yes) {
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
const proceed = await confirm({
|
||||
message: `Warning: ${incompleteTasks} incomplete task(s) found. Continue?`,
|
||||
default: false
|
||||
@@ -179,6 +180,7 @@ export class ArchiveCommand {
|
||||
|
||||
let shouldUpdateSpecs = true;
|
||||
if (!options.yes) {
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
shouldUpdateSpecs = await confirm({
|
||||
message: 'Proceed with spec updates?',
|
||||
default: true
|
||||
@@ -256,6 +258,7 @@ export class ArchiveCommand {
|
||||
}
|
||||
|
||||
private async selectChange(changesDir: string): Promise<string | null> {
|
||||
const { select } = await import('@inquirer/prompts');
|
||||
// Get all directories in changes (excluding archive)
|
||||
const entries = await fs.readdir(changesDir, { withFileTypes: true });
|
||||
const changeDirs = entries
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import { CommandDefinition, FlagDefinition } from './types.js';
|
||||
|
||||
/**
|
||||
* Common flags used across multiple commands
|
||||
*/
|
||||
const COMMON_FLAGS = {
|
||||
json: {
|
||||
name: 'json',
|
||||
description: 'Output as JSON',
|
||||
} as FlagDefinition,
|
||||
jsonValidation: {
|
||||
name: 'json',
|
||||
description: 'Output validation results as JSON',
|
||||
} as FlagDefinition,
|
||||
strict: {
|
||||
name: 'strict',
|
||||
description: 'Enable strict validation mode',
|
||||
} as FlagDefinition,
|
||||
noInteractive: {
|
||||
name: 'no-interactive',
|
||||
description: 'Disable interactive prompts',
|
||||
} as FlagDefinition,
|
||||
type: {
|
||||
name: 'type',
|
||||
description: 'Specify item type when ambiguous',
|
||||
takesValue: true,
|
||||
values: ['change', 'spec'],
|
||||
} as FlagDefinition,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Registry of all OpenSpec CLI commands with their flags and metadata.
|
||||
* This registry is used to generate shell completion scripts.
|
||||
*/
|
||||
export const COMMAND_REGISTRY: CommandDefinition[] = [
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize OpenSpec in your project',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'path',
|
||||
flags: [
|
||||
{
|
||||
name: 'tools',
|
||||
description: 'Configure AI tools non-interactively (e.g., "all", "none", or comma-separated tool IDs)',
|
||||
takesValue: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'update',
|
||||
description: 'Update OpenSpec instruction files',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'path',
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
name: 'list',
|
||||
description: 'List items (changes by default, or specs with --specs)',
|
||||
flags: [
|
||||
{
|
||||
name: 'specs',
|
||||
description: 'List specs instead of changes',
|
||||
},
|
||||
{
|
||||
name: 'changes',
|
||||
description: 'List changes explicitly (default)',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'view',
|
||||
description: 'Display an interactive dashboard of specs and changes',
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
name: 'validate',
|
||||
description: 'Validate changes and specs',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'change-or-spec-id',
|
||||
flags: [
|
||||
{
|
||||
name: 'all',
|
||||
description: 'Validate all changes and specs',
|
||||
},
|
||||
{
|
||||
name: 'changes',
|
||||
description: 'Validate all changes',
|
||||
},
|
||||
{
|
||||
name: 'specs',
|
||||
description: 'Validate all specs',
|
||||
},
|
||||
COMMON_FLAGS.type,
|
||||
COMMON_FLAGS.strict,
|
||||
COMMON_FLAGS.jsonValidation,
|
||||
{
|
||||
name: 'concurrency',
|
||||
description: 'Max concurrent validations (defaults to env OPENSPEC_CONCURRENCY or 6)',
|
||||
takesValue: true,
|
||||
},
|
||||
COMMON_FLAGS.noInteractive,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'show',
|
||||
description: 'Show a change or spec',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'change-or-spec-id',
|
||||
flags: [
|
||||
COMMON_FLAGS.json,
|
||||
COMMON_FLAGS.type,
|
||||
COMMON_FLAGS.noInteractive,
|
||||
{
|
||||
name: 'deltas-only',
|
||||
description: 'Show only deltas (JSON only, change-specific)',
|
||||
},
|
||||
{
|
||||
name: 'requirements-only',
|
||||
description: 'Alias for --deltas-only (deprecated, change-specific)',
|
||||
},
|
||||
{
|
||||
name: 'requirements',
|
||||
description: 'Show only requirements, exclude scenarios (JSON only, spec-specific)',
|
||||
},
|
||||
{
|
||||
name: 'no-scenarios',
|
||||
description: 'Exclude scenario content (JSON only, spec-specific)',
|
||||
},
|
||||
{
|
||||
name: 'requirement',
|
||||
short: 'r',
|
||||
description: 'Show specific requirement by ID (JSON only, spec-specific)',
|
||||
takesValue: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'archive',
|
||||
description: 'Archive a completed change and update main specs',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'change-id',
|
||||
flags: [
|
||||
{
|
||||
name: 'yes',
|
||||
short: 'y',
|
||||
description: 'Skip confirmation prompts',
|
||||
},
|
||||
{
|
||||
name: 'skip-specs',
|
||||
description: 'Skip spec update operations',
|
||||
},
|
||||
{
|
||||
name: 'no-validate',
|
||||
description: 'Skip validation (not recommended)',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'change',
|
||||
description: 'Manage OpenSpec change proposals (deprecated)',
|
||||
flags: [],
|
||||
subcommands: [
|
||||
{
|
||||
name: 'show',
|
||||
description: 'Show a change proposal',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'change-id',
|
||||
flags: [
|
||||
COMMON_FLAGS.json,
|
||||
{
|
||||
name: 'deltas-only',
|
||||
description: 'Show only deltas (JSON only)',
|
||||
},
|
||||
{
|
||||
name: 'requirements-only',
|
||||
description: 'Alias for --deltas-only (deprecated)',
|
||||
},
|
||||
COMMON_FLAGS.noInteractive,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'list',
|
||||
description: 'List all active changes (deprecated)',
|
||||
flags: [
|
||||
COMMON_FLAGS.json,
|
||||
{
|
||||
name: 'long',
|
||||
description: 'Show id and title with counts',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'validate',
|
||||
description: 'Validate a change proposal',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'change-id',
|
||||
flags: [
|
||||
COMMON_FLAGS.strict,
|
||||
COMMON_FLAGS.jsonValidation,
|
||||
COMMON_FLAGS.noInteractive,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'spec',
|
||||
description: 'Manage OpenSpec specifications',
|
||||
flags: [],
|
||||
subcommands: [
|
||||
{
|
||||
name: 'show',
|
||||
description: 'Show a specification',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'spec-id',
|
||||
flags: [
|
||||
COMMON_FLAGS.json,
|
||||
{
|
||||
name: 'requirements',
|
||||
description: 'Show only requirements, exclude scenarios (JSON only)',
|
||||
},
|
||||
{
|
||||
name: 'no-scenarios',
|
||||
description: 'Exclude scenario content (JSON only)',
|
||||
},
|
||||
{
|
||||
name: 'requirement',
|
||||
short: 'r',
|
||||
description: 'Show specific requirement by ID (JSON only)',
|
||||
takesValue: true,
|
||||
},
|
||||
COMMON_FLAGS.noInteractive,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'list',
|
||||
description: 'List all specifications',
|
||||
flags: [
|
||||
COMMON_FLAGS.json,
|
||||
{
|
||||
name: 'long',
|
||||
description: 'Show id and title with counts',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'validate',
|
||||
description: 'Validate a specification',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'spec-id',
|
||||
flags: [
|
||||
COMMON_FLAGS.strict,
|
||||
COMMON_FLAGS.jsonValidation,
|
||||
COMMON_FLAGS.noInteractive,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'completion',
|
||||
description: 'Manage shell completions for OpenSpec CLI',
|
||||
flags: [],
|
||||
subcommands: [
|
||||
{
|
||||
name: 'generate',
|
||||
description: 'Generate completion script for a shell (outputs to stdout)',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'shell',
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
name: 'install',
|
||||
description: 'Install completion script for a shell',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'shell',
|
||||
flags: [
|
||||
{
|
||||
name: 'verbose',
|
||||
description: 'Show detailed installation output',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'uninstall',
|
||||
description: 'Uninstall completion script for a shell',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'shell',
|
||||
flags: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,128 @@
|
||||
import { getActiveChangeIds, getSpecIds } from '../../utils/item-discovery.js';
|
||||
|
||||
/**
|
||||
* Cache entry for completion data
|
||||
*/
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides dynamic completion suggestions for OpenSpec items (changes and specs).
|
||||
* Implements a 2-second cache to avoid excessive file system operations during
|
||||
* tab completion.
|
||||
*/
|
||||
export class CompletionProvider {
|
||||
private readonly cacheTTL: number;
|
||||
private changeCache: CacheEntry<string[]> | null = null;
|
||||
private specCache: CacheEntry<string[]> | null = null;
|
||||
|
||||
/**
|
||||
* Creates a new completion provider
|
||||
*
|
||||
* @param cacheTTLMs - Cache time-to-live in milliseconds (default: 2000ms)
|
||||
* @param projectRoot - Project root directory (default: process.cwd())
|
||||
*/
|
||||
constructor(
|
||||
private readonly cacheTTLMs: number = 2000,
|
||||
private readonly projectRoot: string = process.cwd()
|
||||
) {
|
||||
this.cacheTTL = cacheTTLMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active change IDs for completion
|
||||
*
|
||||
* @returns Array of change IDs
|
||||
*/
|
||||
async getChangeIds(): Promise<string[]> {
|
||||
const now = Date.now();
|
||||
|
||||
// Check if cache is valid
|
||||
if (this.changeCache && now - this.changeCache.timestamp < this.cacheTTL) {
|
||||
return this.changeCache.data;
|
||||
}
|
||||
|
||||
// Fetch fresh data
|
||||
const changeIds = await getActiveChangeIds(this.projectRoot);
|
||||
|
||||
// Update cache
|
||||
this.changeCache = {
|
||||
data: changeIds,
|
||||
timestamp: now,
|
||||
};
|
||||
|
||||
return changeIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all spec IDs for completion
|
||||
*
|
||||
* @returns Array of spec IDs
|
||||
*/
|
||||
async getSpecIds(): Promise<string[]> {
|
||||
const now = Date.now();
|
||||
|
||||
// Check if cache is valid
|
||||
if (this.specCache && now - this.specCache.timestamp < this.cacheTTL) {
|
||||
return this.specCache.data;
|
||||
}
|
||||
|
||||
// Fetch fresh data
|
||||
const specIds = await getSpecIds(this.projectRoot);
|
||||
|
||||
// Update cache
|
||||
this.specCache = {
|
||||
data: specIds,
|
||||
timestamp: now,
|
||||
};
|
||||
|
||||
return specIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get both change and spec IDs for completion
|
||||
*
|
||||
* @returns Object with changeIds and specIds arrays
|
||||
*/
|
||||
async getAllIds(): Promise<{ changeIds: string[]; specIds: string[] }> {
|
||||
const [changeIds, specIds] = await Promise.all([
|
||||
this.getChangeIds(),
|
||||
this.getSpecIds(),
|
||||
]);
|
||||
|
||||
return { changeIds, specIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached data
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.changeCache = null;
|
||||
this.specCache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics for debugging
|
||||
*
|
||||
* @returns Cache status information
|
||||
*/
|
||||
getCacheStats(): {
|
||||
changeCache: { valid: boolean; age?: number };
|
||||
specCache: { valid: boolean; age?: number };
|
||||
} {
|
||||
const now = Date.now();
|
||||
|
||||
return {
|
||||
changeCache: {
|
||||
valid: this.changeCache !== null && now - this.changeCache.timestamp < this.cacheTTL,
|
||||
age: this.changeCache ? now - this.changeCache.timestamp : undefined,
|
||||
},
|
||||
specCache: {
|
||||
valid: this.specCache !== null && now - this.specCache.timestamp < this.cacheTTL,
|
||||
age: this.specCache ? now - this.specCache.timestamp : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { CompletionGenerator } from './types.js';
|
||||
import { ZshGenerator } from './generators/zsh-generator.js';
|
||||
import { ZshInstaller, InstallationResult } from './installers/zsh-installer.js';
|
||||
import { SupportedShell } from '../../utils/shell-detection.js';
|
||||
|
||||
/**
|
||||
* Interface for completion installers
|
||||
*/
|
||||
export interface CompletionInstaller {
|
||||
install(script: string): Promise<InstallationResult>;
|
||||
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'];
|
||||
|
||||
/**
|
||||
* Create a completion generator for the specified shell
|
||||
*
|
||||
* @param shell - The target shell
|
||||
* @returns CompletionGenerator instance
|
||||
* @throws Error if shell is not supported
|
||||
*/
|
||||
static createGenerator(shell: SupportedShell): CompletionGenerator {
|
||||
switch (shell) {
|
||||
case 'zsh':
|
||||
return new ZshGenerator();
|
||||
default:
|
||||
throw new Error(`Unsupported shell: ${shell}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a completion installer for the specified shell
|
||||
*
|
||||
* @param shell - The target shell
|
||||
* @returns CompletionInstaller instance
|
||||
* @throws Error if shell is not supported
|
||||
*/
|
||||
static createInstaller(shell: SupportedShell): CompletionInstaller {
|
||||
switch (shell) {
|
||||
case 'zsh':
|
||||
return new ZshInstaller();
|
||||
default:
|
||||
throw new Error(`Unsupported shell: ${shell}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a shell is supported
|
||||
*
|
||||
* @param shell - The shell to check
|
||||
* @returns true if the shell is supported
|
||||
*/
|
||||
static isSupported(shell: string): shell is SupportedShell {
|
||||
return this.SUPPORTED_SHELLS.includes(shell as SupportedShell);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all supported shells
|
||||
*
|
||||
* @returns Array of supported shell names
|
||||
*/
|
||||
static getSupportedShells(): SupportedShell[] {
|
||||
return [...this.SUPPORTED_SHELLS];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import { CompletionGenerator, CommandDefinition, FlagDefinition } from '../types.js';
|
||||
|
||||
/**
|
||||
* Generates Zsh completion scripts for the OpenSpec CLI.
|
||||
* Follows Zsh completion system conventions using the _openspec function.
|
||||
*/
|
||||
export class ZshGenerator implements CompletionGenerator {
|
||||
readonly shell = 'zsh' as const;
|
||||
|
||||
/**
|
||||
* Generate a Zsh completion script
|
||||
*
|
||||
* @param commands - Command definitions to generate completions for
|
||||
* @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=(');
|
||||
for (const cmd of commands) {
|
||||
const escapedDesc = this.escapeDescription(cmd.description);
|
||||
script.push(` '${cmd.name}:${escapedDesc}'`);
|
||||
}
|
||||
script.push(' )');
|
||||
script.push('');
|
||||
|
||||
// Main _arguments call
|
||||
script.push(' _arguments -C \\');
|
||||
script.push(' "1: :->command" \\');
|
||||
script.push(' "*::arg:->args"');
|
||||
script.push('');
|
||||
|
||||
// Command dispatch logic
|
||||
script.push(' case $state in');
|
||||
script.push(' command)');
|
||||
script.push(' _describe "openspec command" commands');
|
||||
script.push(' ;;');
|
||||
script.push(' args)');
|
||||
script.push(' case $words[1] in');
|
||||
|
||||
// Generate completion for each command
|
||||
for (const cmd of commands) {
|
||||
script.push(` ${cmd.name})`);
|
||||
script.push(` _openspec_${this.sanitizeFunctionName(cmd.name)}`);
|
||||
script.push(' ;;');
|
||||
}
|
||||
|
||||
script.push(' esac');
|
||||
script.push(' ;;');
|
||||
script.push(' esac');
|
||||
script.push('}');
|
||||
script.push('');
|
||||
|
||||
// Generate individual command completion functions
|
||||
for (const cmd of commands) {
|
||||
script.push(...this.generateCommandFunction(cmd));
|
||||
script.push('');
|
||||
}
|
||||
|
||||
// Add dynamic completion helper functions
|
||||
script.push(...this.generateDynamicCompletionHelpers());
|
||||
|
||||
// Register the completion function
|
||||
script.push('compdef _openspec openspec');
|
||||
script.push('');
|
||||
|
||||
return script.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[] = [];
|
||||
|
||||
if (comment) {
|
||||
lines.push(comment);
|
||||
}
|
||||
|
||||
lines.push(`${functionName}() {`);
|
||||
lines.push(` local -a ${varName}`);
|
||||
|
||||
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(' )');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate completion function for a specific command
|
||||
*/
|
||||
private generateCommandFunction(cmd: CommandDefinition): string[] {
|
||||
const funcName = `_openspec_${this.sanitizeFunctionName(cmd.name)}`;
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`${funcName}() {`);
|
||||
|
||||
// If command has subcommands, handle them
|
||||
if (cmd.subcommands && cmd.subcommands.length > 0) {
|
||||
lines.push(' local context state line');
|
||||
lines.push(' typeset -A opt_args');
|
||||
lines.push('');
|
||||
lines.push(' local -a subcommands');
|
||||
lines.push(' subcommands=(');
|
||||
|
||||
for (const subcmd of cmd.subcommands) {
|
||||
const escapedDesc = this.escapeDescription(subcmd.description);
|
||||
lines.push(` '${subcmd.name}:${escapedDesc}'`);
|
||||
}
|
||||
|
||||
lines.push(' )');
|
||||
lines.push('');
|
||||
lines.push(' _arguments -C \\');
|
||||
|
||||
// Add command flags
|
||||
for (const flag of cmd.flags) {
|
||||
lines.push(' ' + this.generateFlagSpec(flag) + ' \\');
|
||||
}
|
||||
|
||||
lines.push(' "1: :->subcommand" \\');
|
||||
lines.push(' "*::arg:->args"');
|
||||
lines.push('');
|
||||
lines.push(' case $state in');
|
||||
lines.push(' subcommand)');
|
||||
lines.push(' _describe "subcommand" subcommands');
|
||||
lines.push(' ;;');
|
||||
lines.push(' args)');
|
||||
lines.push(' case $words[1] in');
|
||||
|
||||
for (const subcmd of cmd.subcommands) {
|
||||
lines.push(` ${subcmd.name})`);
|
||||
lines.push(` _openspec_${this.sanitizeFunctionName(cmd.name)}_${this.sanitizeFunctionName(subcmd.name)}`);
|
||||
lines.push(' ;;');
|
||||
}
|
||||
|
||||
lines.push(' esac');
|
||||
lines.push(' ;;');
|
||||
lines.push(' esac');
|
||||
} else {
|
||||
// Command without subcommands
|
||||
lines.push(' _arguments \\');
|
||||
|
||||
// Add flags
|
||||
for (const flag of cmd.flags) {
|
||||
lines.push(' ' + this.generateFlagSpec(flag) + ' \\');
|
||||
}
|
||||
|
||||
// Add positional argument completion
|
||||
if (cmd.acceptsPositional) {
|
||||
const positionalSpec = this.generatePositionalSpec(cmd.positionalType);
|
||||
lines.push(' ' + positionalSpec);
|
||||
} else {
|
||||
// Remove trailing backslash from last flag
|
||||
if (lines[lines.length - 1].endsWith(' \\')) {
|
||||
lines[lines.length - 1] = lines[lines.length - 1].slice(0, -2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('}');
|
||||
|
||||
// Generate subcommand functions if they exist
|
||||
if (cmd.subcommands) {
|
||||
for (const subcmd of cmd.subcommands) {
|
||||
lines.push('');
|
||||
lines.push(...this.generateSubcommandFunction(cmd.name, subcmd));
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate completion function for a subcommand
|
||||
*/
|
||||
private generateSubcommandFunction(parentName: string, subcmd: CommandDefinition): string[] {
|
||||
const funcName = `_openspec_${this.sanitizeFunctionName(parentName)}_${this.sanitizeFunctionName(subcmd.name)}`;
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`${funcName}() {`);
|
||||
lines.push(' _arguments \\');
|
||||
|
||||
// Add flags
|
||||
for (const flag of subcmd.flags) {
|
||||
lines.push(' ' + this.generateFlagSpec(flag) + ' \\');
|
||||
}
|
||||
|
||||
// Add positional argument completion
|
||||
if (subcmd.acceptsPositional) {
|
||||
const positionalSpec = this.generatePositionalSpec(subcmd.positionalType);
|
||||
lines.push(' ' + positionalSpec);
|
||||
} else {
|
||||
// Remove trailing backslash from last flag
|
||||
if (lines[lines.length - 1].endsWith(' \\')) {
|
||||
lines[lines.length - 1] = lines[lines.length - 1].slice(0, -2);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('}');
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate flag specification for _arguments
|
||||
*/
|
||||
private generateFlagSpec(flag: FlagDefinition): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
// Handle mutually exclusive short and long forms
|
||||
if (flag.short) {
|
||||
parts.push(`'(-${flag.short} --${flag.name})'{-${flag.short},--${flag.name}}'`);
|
||||
} else {
|
||||
parts.push(`'--${flag.name}`);
|
||||
}
|
||||
|
||||
// Add description
|
||||
const escapedDesc = this.escapeDescription(flag.description);
|
||||
parts.push(`[${escapedDesc}]`);
|
||||
|
||||
// Add value completion if flag takes a value
|
||||
if (flag.takesValue) {
|
||||
if (flag.values && flag.values.length > 0) {
|
||||
// Provide specific value completions
|
||||
const valueList = flag.values.map(v => this.escapeValue(v)).join(' ');
|
||||
parts.push(`:value:(${valueList})`);
|
||||
} else {
|
||||
// Generic value placeholder
|
||||
parts.push(':value:');
|
||||
}
|
||||
}
|
||||
|
||||
// Close the quote (needed for both short and long forms)
|
||||
parts.push("'");
|
||||
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate positional argument specification
|
||||
*/
|
||||
private generatePositionalSpec(positionalType?: string): string {
|
||||
switch (positionalType) {
|
||||
case 'change-id':
|
||||
return "'*: :_openspec_complete_changes'";
|
||||
case 'spec-id':
|
||||
return "'*: :_openspec_complete_specs'";
|
||||
case 'change-or-spec-id':
|
||||
return "'*: :_openspec_complete_items'";
|
||||
case 'path':
|
||||
return "'*:path:_files'";
|
||||
case 'shell':
|
||||
return "'*:shell:(zsh)'";
|
||||
default:
|
||||
return "'*: :_default'";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special characters in descriptions
|
||||
*/
|
||||
private escapeDescription(desc: string): string {
|
||||
return desc
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/'/g, "\\'")
|
||||
.replace(/\[/g, '\\[')
|
||||
.replace(/]/g, '\\]')
|
||||
.replace(/:/g, '\\:');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special characters in values
|
||||
*/
|
||||
private escapeValue(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/'/g, "\\'")
|
||||
.replace(/ /g, '\\ ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize command names for use in function names
|
||||
*/
|
||||
private sanitizeFunctionName(name: string): string {
|
||||
return name.replace(/-/g, '_');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
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[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Installer for Zsh completion scripts.
|
||||
* Supports both Oh My Zsh and standard Zsh configurations.
|
||||
*/
|
||||
export class ZshInstaller {
|
||||
private readonly homeDir: string;
|
||||
|
||||
/**
|
||||
* Markers for .zshrc configuration management
|
||||
*/
|
||||
private readonly ZSHRC_MARKERS = {
|
||||
start: '# OPENSPEC:START',
|
||||
end: '# OPENSPEC:END',
|
||||
};
|
||||
|
||||
constructor(homeDir: string = os.homedir()) {
|
||||
this.homeDir = homeDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Oh My Zsh is installed
|
||||
*
|
||||
* @returns true if Oh My Zsh is detected via $ZSH env var or directory exists
|
||||
*/
|
||||
async isOhMyZshInstalled(): Promise<boolean> {
|
||||
// First check for $ZSH environment variable (standard OMZ setup)
|
||||
if (process.env.ZSH) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fall back to checking for ~/.oh-my-zsh directory
|
||||
const ohMyZshPath = path.join(this.homeDir, '.oh-my-zsh');
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(ohMyZshPath);
|
||||
return stat.isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate installation path for the completion script
|
||||
*
|
||||
* @returns Object with installation path and whether it's Oh My Zsh
|
||||
*/
|
||||
async getInstallationPath(): Promise<{ path: string; isOhMyZsh: boolean }> {
|
||||
const isOhMyZsh = await this.isOhMyZshInstalled();
|
||||
|
||||
if (isOhMyZsh) {
|
||||
// Oh My Zsh custom completions directory
|
||||
return {
|
||||
path: path.join(this.homeDir, '.oh-my-zsh', 'custom', 'completions', '_openspec'),
|
||||
isOhMyZsh: true,
|
||||
};
|
||||
} else {
|
||||
// Standard Zsh completions directory
|
||||
return {
|
||||
path: path.join(this.homeDir, '.zsh', 'completions', '_openspec'),
|
||||
isOhMyZsh: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 .zshrc file
|
||||
*
|
||||
* @returns Path to .zshrc
|
||||
*/
|
||||
private getZshrcPath(): string {
|
||||
return path.join(this.homeDir, '.zshrc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate .zshrc configuration content
|
||||
*
|
||||
* @param completionsDir - Directory containing completion scripts
|
||||
* @returns Configuration content
|
||||
*/
|
||||
private generateZshrcConfig(completionsDir: string): string {
|
||||
return [
|
||||
'# OpenSpec shell completions configuration',
|
||||
`fpath=("${completionsDir}" $fpath)`,
|
||||
'autoload -Uz compinit',
|
||||
'compinit',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure .zshrc to enable completions
|
||||
* Only applies to standard Zsh (not Oh My Zsh)
|
||||
*
|
||||
* @param completionsDir - Directory containing completion scripts
|
||||
* @returns true if configured successfully, false otherwise
|
||||
*/
|
||||
async configureZshrc(completionsDir: string): Promise<boolean> {
|
||||
// Check if auto-configuration is disabled
|
||||
if (process.env.OPENSPEC_NO_AUTO_CONFIG === '1') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const zshrcPath = this.getZshrcPath();
|
||||
const config = this.generateZshrcConfig(completionsDir);
|
||||
|
||||
// Check write permissions
|
||||
const canWrite = await FileSystemUtils.canWriteFile(zshrcPath);
|
||||
if (!canWrite) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use marker-based update
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
zshrcPath,
|
||||
config,
|
||||
this.ZSHRC_MARKERS.start,
|
||||
this.ZSHRC_MARKERS.end
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
// Fail gracefully - don't break installation
|
||||
console.debug(`Unable to configure .zshrc for completions: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if .zshrc has OpenSpec configuration markers
|
||||
*
|
||||
* @returns true if .zshrc exists and has markers
|
||||
*/
|
||||
private async hasZshrcConfig(): Promise<boolean> {
|
||||
try {
|
||||
const zshrcPath = this.getZshrcPath();
|
||||
const content = await fs.readFile(zshrcPath, 'utf-8');
|
||||
return content.includes(this.ZSHRC_MARKERS.start) && content.includes(this.ZSHRC_MARKERS.end);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if fpath configuration is needed for a given directory
|
||||
* Used to verify if Oh My Zsh (or other) completions directory is already in fpath
|
||||
*
|
||||
* @param completionsDir - Directory to check for in fpath
|
||||
* @returns true if configuration is needed, false if directory is already referenced
|
||||
*/
|
||||
private async needsFpathConfig(completionsDir: string): Promise<boolean> {
|
||||
try {
|
||||
const zshrcPath = this.getZshrcPath();
|
||||
const content = await fs.readFile(zshrcPath, 'utf-8');
|
||||
|
||||
// Check if fpath already includes this directory
|
||||
return !content.includes(completionsDir);
|
||||
} catch (error) {
|
||||
// If we can't read .zshrc, assume config is needed
|
||||
console.debug(`Unable to read .zshrc to check fpath config: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove .zshrc configuration
|
||||
* Used during uninstallation
|
||||
*
|
||||
* @returns true if removed successfully, false otherwise
|
||||
*/
|
||||
async removeZshrcConfig(): Promise<boolean> {
|
||||
try {
|
||||
const zshrcPath = this.getZshrcPath();
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await fs.access(zshrcPath);
|
||||
} catch {
|
||||
// File doesn't exist, nothing to remove
|
||||
return true;
|
||||
}
|
||||
|
||||
// Read file content
|
||||
const content = await fs.readFile(zshrcPath, 'utf-8');
|
||||
|
||||
// Check if markers exist
|
||||
if (!content.includes(this.ZSHRC_MARKERS.start) || !content.includes(this.ZSHRC_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.ZSHRC_MARKERS.start);
|
||||
const endIndex = lines.findIndex((line) => line.trim() === this.ZSHRC_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 at the start if the markers were at the top
|
||||
while (lines.length > 0 && lines[0].trim() === '') {
|
||||
lines.shift();
|
||||
}
|
||||
|
||||
// Write back
|
||||
await fs.writeFile(zshrcPath, lines.join('\n'), 'utf-8');
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
// Fail gracefully
|
||||
console.debug(`Unable to remove .zshrc 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 { path: targetPath, isOhMyZsh } = await 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,
|
||||
isOhMyZsh,
|
||||
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 zsh',
|
||||
],
|
||||
};
|
||||
}
|
||||
// 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 .zshrc
|
||||
let zshrcConfigured = false;
|
||||
if (isOhMyZsh) {
|
||||
// For Oh My Zsh, verify that custom/completions is in fpath
|
||||
// If not, add it to .zshrc
|
||||
const needsConfig = await this.needsFpathConfig(targetDir);
|
||||
if (needsConfig) {
|
||||
zshrcConfigured = await this.configureZshrc(targetDir);
|
||||
}
|
||||
} else {
|
||||
// Standard Zsh always needs .zshrc configuration
|
||||
zshrcConfigured = await this.configureZshrc(targetDir);
|
||||
}
|
||||
|
||||
// Generate instructions (only if .zshrc wasn't auto-configured)
|
||||
let instructions = zshrcConfigured ? undefined : this.generateInstructions(isOhMyZsh, targetPath);
|
||||
|
||||
// Add fpath guidance for Oh My Zsh installations
|
||||
if (isOhMyZsh) {
|
||||
const fpathGuidance = this.generateOhMyZshFpathGuidance(targetDir);
|
||||
if (fpathGuidance) {
|
||||
instructions = instructions ? [...instructions, '', ...fpathGuidance] : fpathGuidance;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine appropriate message based on update status
|
||||
let message: string;
|
||||
if (isUpdate) {
|
||||
message = backupPath
|
||||
? 'Completion script updated successfully (previous version backed up)'
|
||||
: 'Completion script updated successfully';
|
||||
} else {
|
||||
message = isOhMyZsh
|
||||
? 'Completion script installed successfully for Oh My Zsh'
|
||||
: zshrcConfigured
|
||||
? 'Completion script installed and .zshrc configured successfully'
|
||||
: 'Completion script installed successfully for Zsh';
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
installedPath: targetPath,
|
||||
backupPath,
|
||||
isOhMyZsh,
|
||||
zshrcConfigured,
|
||||
message,
|
||||
instructions,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
isOhMyZsh: false,
|
||||
message: `Failed to install completion script: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Oh My Zsh fpath verification guidance
|
||||
*
|
||||
* @param completionsDir - Custom completions directory path
|
||||
* @returns Array of guidance strings, or undefined if not needed
|
||||
*/
|
||||
private generateOhMyZshFpathGuidance(completionsDir: string): string[] | undefined {
|
||||
return [
|
||||
'Note: Oh My Zsh typically auto-loads completions from custom/completions.',
|
||||
`Verify that ${completionsDir} is in your fpath by running:`,
|
||||
' echo $fpath | grep "custom/completions"',
|
||||
'',
|
||||
'If not found, completions may not work. Restart your shell to ensure changes take effect.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate user instructions for enabling completions
|
||||
*
|
||||
* @param isOhMyZsh - Whether Oh My Zsh is being used
|
||||
* @param installedPath - Path where the script was installed
|
||||
* @returns Array of instruction strings
|
||||
*/
|
||||
private generateInstructions(isOhMyZsh: boolean, installedPath: string): string[] {
|
||||
if (isOhMyZsh) {
|
||||
return [
|
||||
'Completion script installed to Oh My Zsh completions directory.',
|
||||
'Restart your shell or run: exec zsh',
|
||||
'Completions should activate automatically.',
|
||||
];
|
||||
} else {
|
||||
const completionsDir = path.dirname(installedPath);
|
||||
const zshrcPath = path.join(this.homeDir, '.zshrc');
|
||||
|
||||
return [
|
||||
'Completion script installed to ~/.zsh/completions/',
|
||||
'',
|
||||
'To enable completions, add the following to your ~/.zshrc file:',
|
||||
'',
|
||||
` # Add completions directory to fpath`,
|
||||
` fpath=(${completionsDir} $fpath)`,
|
||||
'',
|
||||
' # Initialize completion system',
|
||||
' autoload -Uz compinit',
|
||||
' compinit',
|
||||
'',
|
||||
'Then restart your shell or run: exec zsh',
|
||||
'',
|
||||
`Check if these lines already exist in ${zshrcPath} before adding.`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall the completion script
|
||||
*
|
||||
* @returns true if uninstalled successfully, false otherwise
|
||||
*/
|
||||
async uninstall(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const { path: targetPath, isOhMyZsh } = await this.getInstallationPath();
|
||||
|
||||
// Try to remove completion script
|
||||
let scriptRemoved = false;
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
await fs.unlink(targetPath);
|
||||
scriptRemoved = true;
|
||||
} catch {
|
||||
// Script not installed
|
||||
}
|
||||
|
||||
// Try to remove .zshrc configuration (only for standard Zsh)
|
||||
let zshrcWasPresent = false;
|
||||
let zshrcCleaned = false;
|
||||
if (!isOhMyZsh) {
|
||||
zshrcWasPresent = await this.hasZshrcConfig();
|
||||
if (zshrcWasPresent) {
|
||||
zshrcCleaned = await this.removeZshrcConfig();
|
||||
}
|
||||
}
|
||||
|
||||
if (!scriptRemoved && !zshrcWasPresent) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Completion script is not installed',
|
||||
};
|
||||
}
|
||||
|
||||
const messages: string[] = [];
|
||||
if (scriptRemoved) {
|
||||
messages.push(`Completion script removed from ${targetPath}`);
|
||||
}
|
||||
if (zshrcCleaned && !isOhMyZsh) {
|
||||
messages.push('Removed OpenSpec configuration from ~/.zshrc');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: messages.join('. '),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to uninstall completion script: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if completion script is currently installed
|
||||
*
|
||||
* @returns true if the completion script exists
|
||||
*/
|
||||
async isInstalled(): Promise<boolean> {
|
||||
try {
|
||||
const { path: targetPath } = await this.getInstallationPath();
|
||||
await fs.access(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get information about the current installation
|
||||
*
|
||||
* @returns Installation status information
|
||||
*/
|
||||
async getInstallationInfo(): Promise<{
|
||||
installed: boolean;
|
||||
path?: string;
|
||||
isOhMyZsh?: boolean;
|
||||
}> {
|
||||
const installed = await this.isInstalled();
|
||||
|
||||
if (!installed) {
|
||||
return { installed: false };
|
||||
}
|
||||
|
||||
const { path: targetPath, isOhMyZsh } = await this.getInstallationPath();
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
path: targetPath,
|
||||
isOhMyZsh,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { SupportedShell } from '../../utils/shell-detection.js';
|
||||
|
||||
/**
|
||||
* Definition of a command-line flag/option
|
||||
*/
|
||||
export interface FlagDefinition {
|
||||
/**
|
||||
* Flag name without dashes (e.g., "json", "strict", "no-interactive")
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Short flag name without dash (e.g., "y" for "-y")
|
||||
*/
|
||||
short?: string;
|
||||
|
||||
/**
|
||||
* Human-readable description of what the flag does
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* Whether the flag takes an argument value
|
||||
*/
|
||||
takesValue?: boolean;
|
||||
|
||||
/**
|
||||
* Possible values for the flag (for completion suggestions)
|
||||
*/
|
||||
values?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Definition of a CLI command
|
||||
*/
|
||||
export interface CommandDefinition {
|
||||
/**
|
||||
* Command name (e.g., "init", "validate", "show")
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Human-readable description of the command
|
||||
*/
|
||||
description: string;
|
||||
|
||||
/**
|
||||
* Flags/options supported by this command
|
||||
*/
|
||||
flags: FlagDefinition[];
|
||||
|
||||
/**
|
||||
* Subcommands (e.g., "change show", "spec validate")
|
||||
*/
|
||||
subcommands?: CommandDefinition[];
|
||||
|
||||
/**
|
||||
* Whether this command accepts a positional argument (e.g., item name, path)
|
||||
*/
|
||||
acceptsPositional?: boolean;
|
||||
|
||||
/**
|
||||
* Type of positional argument for dynamic completion
|
||||
* - 'change-id': Complete with active change IDs
|
||||
* - 'spec-id': Complete with spec IDs
|
||||
* - 'change-or-spec-id': Complete with both changes and specs
|
||||
* - 'path': Complete with file paths
|
||||
* - 'shell': Complete with supported shell names
|
||||
* - undefined: No specific completion
|
||||
*/
|
||||
positionalType?: 'change-id' | 'spec-id' | 'change-or-spec-id' | 'path' | 'shell';
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for shell-specific completion script generators
|
||||
*/
|
||||
export interface CompletionGenerator {
|
||||
/**
|
||||
* The shell type this generator targets
|
||||
*/
|
||||
readonly shell: SupportedShell;
|
||||
|
||||
/**
|
||||
* Generate the completion script content
|
||||
*
|
||||
* @param commands - Command definitions to generate completions for
|
||||
* @returns The shell-specific completion script as a string
|
||||
*/
|
||||
generate(commands: CommandDefinition[]): string;
|
||||
}
|
||||
+14
-5
@@ -17,16 +17,25 @@ export interface AIToolOption {
|
||||
}
|
||||
|
||||
export const AI_TOOLS: AIToolOption[] = [
|
||||
{ name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer' },
|
||||
{ name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity' },
|
||||
{ name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie' },
|
||||
{ name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code' },
|
||||
{ name: 'Cline', value: 'cline', available: true, successLabel: 'Cline' },
|
||||
{ name: 'Codex', value: 'codex', available: true, successLabel: 'Codex' },
|
||||
{ name: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code' },
|
||||
{ name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict' },
|
||||
{ name: 'Crush', value: 'crush', available: true, successLabel: 'Crush' },
|
||||
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor' },
|
||||
{ name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid' },
|
||||
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode' },
|
||||
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code' },
|
||||
{ name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf' },
|
||||
{ name: 'Codex', value: 'codex', available: true, successLabel: 'Codex' },
|
||||
{ name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI' },
|
||||
{ name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot' },
|
||||
{ name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer' },
|
||||
{ name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow' },
|
||||
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code' },
|
||||
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode' },
|
||||
{ name: 'Qoder (CLI)', value: 'qoder', available: true, successLabel: 'Qoder' },
|
||||
{ name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code' },
|
||||
{ name: 'RooCode', value: 'roocode', available: true, successLabel: 'RooCode' },
|
||||
{ name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf' },
|
||||
{ name: 'AGENTS.md (works with Amp, VS Code, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' }
|
||||
];
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import path from 'path';
|
||||
import { ToolConfigurator } from './base.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { TemplateManager } from '../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../config.js';
|
||||
|
||||
export class ClineConfigurator implements ToolConfigurator {
|
||||
name = 'Cline';
|
||||
configFileName = 'CLINE.md';
|
||||
isAvailable = true;
|
||||
|
||||
async configure(projectPath: string, openspecDir: string): Promise<void> {
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
const content = TemplateManager.getClineTemplate();
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import path from 'path';
|
||||
import { ToolConfigurator } from './base.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { TemplateManager } from '../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../config.js';
|
||||
|
||||
export class CodeBuddyConfigurator implements ToolConfigurator {
|
||||
name = 'CodeBuddy';
|
||||
configFileName = 'CODEBUDDY.md';
|
||||
isAvailable = true;
|
||||
|
||||
async configure(projectPath: string, openspecDir: string): Promise<void> {
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
const content = TemplateManager.getClaudeTemplate();
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import path from 'path';
|
||||
import { ToolConfigurator } from './base.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { TemplateManager } from '../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../config.js';
|
||||
|
||||
export class CostrictConfigurator implements ToolConfigurator {
|
||||
name = 'CoStrict';
|
||||
configFileName = 'COSTRICT.md';
|
||||
isAvailable = true;
|
||||
|
||||
async configure(projectPath: string, openspecDir: string): Promise<void> {
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
const content = TemplateManager.getCostrictTemplate();
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import path from "path";
|
||||
import { ToolConfigurator } from "./base.js";
|
||||
import { FileSystemUtils } from "../../utils/file-system.js";
|
||||
import { TemplateManager } from "../templates/index.js";
|
||||
import { OPENSPEC_MARKERS } from "../config.js";
|
||||
|
||||
export class IflowConfigurator implements ToolConfigurator {
|
||||
name = "iFlow";
|
||||
configFileName = "IFLOW.md";
|
||||
isAvailable = true;
|
||||
|
||||
async configure(projectPath: string, openspecDir: string): Promise<void> {
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
const content = TemplateManager.getClaudeTemplate();
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import path from 'path';
|
||||
import { ToolConfigurator } from './base.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { TemplateManager } from '../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../config.js';
|
||||
|
||||
/**
|
||||
* Qoder AI Tool Configurator
|
||||
*
|
||||
* Configures OpenSpec integration for Qoder AI coding assistant.
|
||||
* Creates and manages QODER.md configuration file with OpenSpec instructions.
|
||||
*
|
||||
* @implements {ToolConfigurator}
|
||||
*/
|
||||
export class QoderConfigurator implements ToolConfigurator {
|
||||
/** Display name for the Qoder tool */
|
||||
name = 'Qoder';
|
||||
|
||||
/** Configuration file name at project root */
|
||||
configFileName = 'QODER.md';
|
||||
|
||||
/** Indicates tool is available for configuration */
|
||||
isAvailable = true;
|
||||
|
||||
/**
|
||||
* Configure Qoder integration for a project
|
||||
*
|
||||
* Creates or updates QODER.md file with OpenSpec instructions.
|
||||
* Uses Claude-compatible template for instruction content.
|
||||
* Wrapped with OpenSpec markers for future updates.
|
||||
*
|
||||
* @param {string} projectPath - Absolute path to project root directory
|
||||
* @param {string} openspecDir - Path to openspec directory (unused but required by interface)
|
||||
* @returns {Promise<void>} Resolves when configuration is complete
|
||||
*/
|
||||
async configure(projectPath: string, openspecDir: string): Promise<void> {
|
||||
// Construct full path to QODER.md at project root
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
|
||||
// Get Claude-compatible instruction template
|
||||
// This ensures Qoder receives the same high-quality OpenSpec instructions
|
||||
const content = TemplateManager.getClaudeTemplate();
|
||||
|
||||
// Write or update file with managed content between markers
|
||||
// This allows future updates to refresh instructions automatically
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Qwen Code configurator for OpenSpec integration.
|
||||
* This class handles the configuration of Qwen Code as an AI tool within OpenSpec.
|
||||
*
|
||||
* @implements {ToolConfigurator}
|
||||
*/
|
||||
import path from 'path';
|
||||
import { ToolConfigurator } from './base.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { TemplateManager } from '../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../config.js';
|
||||
|
||||
/**
|
||||
* QwenConfigurator class provides integration with Qwen Code
|
||||
* by creating and managing the necessary configuration files.
|
||||
* Currently configures the QWEN.md file with OpenSpec instructions.
|
||||
*/
|
||||
export class QwenConfigurator implements ToolConfigurator {
|
||||
/** Display name for the Qwen Code tool */
|
||||
name = 'Qwen Code';
|
||||
|
||||
/** Configuration file name for Qwen Code */
|
||||
configFileName = 'QWEN.md';
|
||||
|
||||
/** Availability status for the Qwen Code tool */
|
||||
isAvailable = true;
|
||||
|
||||
/**
|
||||
* Configures the Qwen Code integration by creating or updating the QWEN.md file
|
||||
* with OpenSpec instructions and markers.
|
||||
*
|
||||
* @param {string} projectPath - The path to the project root
|
||||
* @param {string} _openspecDir - The path to the openspec directory (unused)
|
||||
* @returns {Promise<void>} A promise that resolves when configuration is complete
|
||||
*/
|
||||
async configure(projectPath: string, _openspecDir: string): Promise<void> {
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
const content = TemplateManager.getAgentsStandardTemplate();
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,34 @@
|
||||
import { ToolConfigurator } from './base.js';
|
||||
import { ClaudeConfigurator } from './claude.js';
|
||||
import { ClineConfigurator } from './cline.js';
|
||||
import { CodeBuddyConfigurator } from './codebuddy.js';
|
||||
import { CostrictConfigurator } from './costrict.js';
|
||||
import { QoderConfigurator } from './qoder.js';
|
||||
import { IflowConfigurator } from './iflow.js';
|
||||
import { AgentsStandardConfigurator } from './agents.js';
|
||||
import { QwenConfigurator } from './qwen.js';
|
||||
|
||||
export class ToolRegistry {
|
||||
private static tools: Map<string, ToolConfigurator> = new Map();
|
||||
|
||||
static {
|
||||
const claudeConfigurator = new ClaudeConfigurator();
|
||||
const clineConfigurator = new ClineConfigurator();
|
||||
const codeBuddyConfigurator = new CodeBuddyConfigurator();
|
||||
const costrictConfigurator = new CostrictConfigurator();
|
||||
const qoderConfigurator = new QoderConfigurator();
|
||||
const iflowConfigurator = new IflowConfigurator();
|
||||
const agentsConfigurator = new AgentsStandardConfigurator();
|
||||
const qwenConfigurator = new QwenConfigurator();
|
||||
// Register with the ID that matches the checkbox value
|
||||
this.tools.set('claude', claudeConfigurator);
|
||||
this.tools.set('cline', clineConfigurator);
|
||||
this.tools.set('codebuddy', codeBuddyConfigurator);
|
||||
this.tools.set('costrict', costrictConfigurator);
|
||||
this.tools.set('qoder', qoderConfigurator);
|
||||
this.tools.set('iflow', iflowConfigurator);
|
||||
this.tools.set('agents', agentsConfigurator);
|
||||
this.tools.set('qwen', qwenConfigurator);
|
||||
}
|
||||
|
||||
static register(tool: ToolConfigurator): void {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.agent/workflows/openspec-proposal.md',
|
||||
apply: '.agent/workflows/openspec-apply.md',
|
||||
archive: '.agent/workflows/openspec-archive.md'
|
||||
};
|
||||
|
||||
const DESCRIPTIONS: Record<SlashCommandId, string> = {
|
||||
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
|
||||
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
|
||||
archive: 'Archive a deployed OpenSpec change and update specs.'
|
||||
};
|
||||
|
||||
export class AntigravitySlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'antigravity';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string | undefined {
|
||||
const description = DESCRIPTIONS[id];
|
||||
return `---\ndescription: ${description}\n---`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.clinerules/workflows/openspec-proposal.md',
|
||||
apply: '.clinerules/workflows/openspec-apply.md',
|
||||
archive: '.clinerules/workflows/openspec-archive.md'
|
||||
};
|
||||
|
||||
export class ClineSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'cline';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string | undefined {
|
||||
const descriptions: Record<SlashCommandId, string> = {
|
||||
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
|
||||
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
|
||||
archive: 'Archive a deployed OpenSpec change and update specs.'
|
||||
};
|
||||
const description = descriptions[id];
|
||||
return `# OpenSpec: ${id.charAt(0).toUpperCase() + id.slice(1)}\n\n${description}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.codebuddy/commands/openspec/proposal.md',
|
||||
apply: '.codebuddy/commands/openspec/apply.md',
|
||||
archive: '.codebuddy/commands/openspec/archive.md'
|
||||
};
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
name: OpenSpec: Proposal
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
category: OpenSpec
|
||||
tags: [openspec, change]
|
||||
---`,
|
||||
apply: `---
|
||||
name: OpenSpec: Apply
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
category: OpenSpec
|
||||
tags: [openspec, apply]
|
||||
---`,
|
||||
archive: `---
|
||||
name: OpenSpec: Archive
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
category: OpenSpec
|
||||
tags: [openspec, archive]
|
||||
---`
|
||||
};
|
||||
|
||||
export class CodeBuddySlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'codebuddy';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS = {
|
||||
proposal: '.cospec/openspec/commands/openspec-proposal.md',
|
||||
apply: '.cospec/openspec/commands/openspec-apply.md',
|
||||
archive: '.cospec/openspec/commands/openspec-archive.md',
|
||||
} as const satisfies Record<SlashCommandId, string>;
|
||||
|
||||
const FRONTMATTER = {
|
||||
proposal: `---
|
||||
description: "Scaffold a new OpenSpec change and validate strictly."
|
||||
argument-hint: feature description or request
|
||||
---`,
|
||||
apply: `---
|
||||
description: "Implement an approved OpenSpec change and keep tasks in sync."
|
||||
argument-hint: change-id
|
||||
---`,
|
||||
archive: `---
|
||||
description: "Archive a deployed OpenSpec change and update specs."
|
||||
argument-hint: change-id
|
||||
---`
|
||||
} as const satisfies Record<SlashCommandId, string>;
|
||||
|
||||
export class CostrictSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'costrict';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string | undefined {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { TomlSlashCommandConfigurator } from './toml-base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.gemini/commands/openspec/proposal.toml',
|
||||
apply: '.gemini/commands/openspec/apply.toml',
|
||||
archive: '.gemini/commands/openspec/archive.toml'
|
||||
};
|
||||
|
||||
const DESCRIPTIONS: Record<SlashCommandId, string> = {
|
||||
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
|
||||
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
|
||||
archive: 'Archive a deployed OpenSpec change and update specs.'
|
||||
};
|
||||
|
||||
export class GeminiSlashCommandConfigurator extends TomlSlashCommandConfigurator {
|
||||
readonly toolId = 'gemini';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getDescription(id: SlashCommandId): string {
|
||||
return DESCRIPTIONS[id];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.iflow/commands/openspec-proposal.md',
|
||||
apply: '.iflow/commands/openspec-apply.md',
|
||||
archive: '.iflow/commands/openspec-archive.md'
|
||||
};
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
name: /openspec-proposal
|
||||
id: openspec-proposal
|
||||
category: OpenSpec
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
---`,
|
||||
apply: `---
|
||||
name: /openspec-apply
|
||||
id: openspec-apply
|
||||
category: OpenSpec
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
---`,
|
||||
archive: `---
|
||||
name: /openspec-archive
|
||||
id: openspec-archive
|
||||
category: OpenSpec
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
---`
|
||||
};
|
||||
|
||||
export class IflowSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'iflow';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
agent: build
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
---
|
||||
The user has requested the following change proposal. Use the openspec instructions to create their change proposal.
|
||||
@@ -20,11 +19,14 @@ The user has requested the following change proposal. Use the openspec instructi
|
||||
</UserRequest>
|
||||
`,
|
||||
apply: `---
|
||||
agent: build
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
---`,
|
||||
---
|
||||
The user has requested to implement the following change proposal. Find the change proposal and follow the instructions below. If you're not sure or if ambiguous, ask for clarification from the user.
|
||||
<UserRequest>
|
||||
$ARGUMENTS
|
||||
</UserRequest>
|
||||
`,
|
||||
archive: `---
|
||||
agent: build
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
---
|
||||
<ChangeId>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
/**
|
||||
* File paths for Qoder slash commands
|
||||
* Maps each OpenSpec workflow stage to its command file location
|
||||
* Commands are stored in .qoder/commands/openspec/ directory
|
||||
*/
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
// Create and validate new change proposals
|
||||
proposal: '.qoder/commands/openspec/proposal.md',
|
||||
|
||||
// Implement approved changes with task tracking
|
||||
apply: '.qoder/commands/openspec/apply.md',
|
||||
|
||||
// Archive completed changes and update specs
|
||||
archive: '.qoder/commands/openspec/archive.md'
|
||||
};
|
||||
|
||||
/**
|
||||
* YAML frontmatter for Qoder slash commands
|
||||
* Defines metadata displayed in Qoder's command palette
|
||||
* Each command is categorized and tagged for easy discovery
|
||||
*/
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
name: OpenSpec: Proposal
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
category: OpenSpec
|
||||
tags: [openspec, change]
|
||||
---`,
|
||||
apply: `---
|
||||
name: OpenSpec: Apply
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
category: OpenSpec
|
||||
tags: [openspec, apply]
|
||||
---`,
|
||||
archive: `---
|
||||
name: OpenSpec: Archive
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
category: OpenSpec
|
||||
tags: [openspec, archive]
|
||||
---`
|
||||
};
|
||||
|
||||
/**
|
||||
* Qoder Slash Command Configurator
|
||||
*
|
||||
* Manages OpenSpec slash commands for Qoder AI assistant.
|
||||
* Creates three workflow commands: proposal, apply, and archive.
|
||||
* Uses colon-separated command format (/openspec:proposal).
|
||||
*
|
||||
* @extends {SlashCommandConfigurator}
|
||||
*/
|
||||
export class QoderSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
/** Unique identifier for Qoder tool */
|
||||
readonly toolId = 'qoder';
|
||||
|
||||
/** Indicates slash commands are available for this tool */
|
||||
readonly isAvailable = true;
|
||||
|
||||
/**
|
||||
* Get relative file path for a slash command
|
||||
*
|
||||
* @param {SlashCommandId} id - Command identifier (proposal, apply, or archive)
|
||||
* @returns {string} Relative path from project root to command file
|
||||
*/
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get YAML frontmatter for a slash command
|
||||
*
|
||||
* Frontmatter defines how the command appears in Qoder's UI,
|
||||
* including display name, description, and categorization.
|
||||
*
|
||||
* @param {SlashCommandId} id - Command identifier (proposal, apply, or archive)
|
||||
* @returns {string} YAML frontmatter block with command metadata
|
||||
*/
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Qwen slash command configurator for OpenSpec integration.
|
||||
* This class handles the generation of Qwen-specific slash command files
|
||||
* in the .qwen/commands directory structure.
|
||||
*
|
||||
* @implements {SlashCommandConfigurator}
|
||||
*/
|
||||
import { TomlSlashCommandConfigurator } from './toml-base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
/**
|
||||
* Mapping of slash command IDs to their corresponding file paths in .qwen/commands directory.
|
||||
* @type {Record<SlashCommandId, string>}
|
||||
*/
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.qwen/commands/openspec-proposal.toml',
|
||||
apply: '.qwen/commands/openspec-apply.toml',
|
||||
archive: '.qwen/commands/openspec-archive.toml'
|
||||
};
|
||||
|
||||
const DESCRIPTIONS: Record<SlashCommandId, string> = {
|
||||
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
|
||||
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
|
||||
archive: 'Archive a deployed OpenSpec change and update specs.'
|
||||
};
|
||||
|
||||
/**
|
||||
* QwenSlashCommandConfigurator class provides integration with Qwen Code
|
||||
* by creating the necessary slash command files in the .qwen/commands directory.
|
||||
*
|
||||
* The slash commands include:
|
||||
* - /openspec-proposal: Create an OpenSpec change proposal
|
||||
* - /openspec-apply: Apply an approved OpenSpec change
|
||||
* - /openspec-archive: Archive a deployed OpenSpec change
|
||||
*/
|
||||
export class QwenSlashCommandConfigurator extends TomlSlashCommandConfigurator {
|
||||
/** Unique identifier for the Qwen tool */
|
||||
readonly toolId = 'qwen';
|
||||
|
||||
/** Availability status for the Qwen tool */
|
||||
readonly isAvailable = true;
|
||||
|
||||
/**
|
||||
* Returns the relative file path for a given slash command ID.
|
||||
* @param {SlashCommandId} id - The slash command identifier
|
||||
* @returns {string} The relative path to the command file
|
||||
*/
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getDescription(id: SlashCommandId): string {
|
||||
return DESCRIPTIONS[id];
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { ClaudeSlashCommandConfigurator } from './claude.js';
|
||||
import { CodeBuddySlashCommandConfigurator } from './codebuddy.js';
|
||||
import { QoderSlashCommandConfigurator } from './qoder.js';
|
||||
import { CursorSlashCommandConfigurator } from './cursor.js';
|
||||
import { WindsurfSlashCommandConfigurator } from './windsurf.js';
|
||||
import { KiloCodeSlashCommandConfigurator } from './kilocode.js';
|
||||
@@ -8,14 +10,23 @@ import { CodexSlashCommandConfigurator } from './codex.js';
|
||||
import { GitHubCopilotSlashCommandConfigurator } from './github-copilot.js';
|
||||
import { AmazonQSlashCommandConfigurator } from './amazon-q.js';
|
||||
import { FactorySlashCommandConfigurator } from './factory.js';
|
||||
import { GeminiSlashCommandConfigurator } from './gemini.js';
|
||||
import { AuggieSlashCommandConfigurator } from './auggie.js';
|
||||
import { ClineSlashCommandConfigurator } from './cline.js';
|
||||
import { CrushSlashCommandConfigurator } from './crush.js';
|
||||
import { CostrictSlashCommandConfigurator } from './costrict.js';
|
||||
import { QwenSlashCommandConfigurator } from './qwen.js';
|
||||
import { RooCodeSlashCommandConfigurator } from './roocode.js';
|
||||
import { AntigravitySlashCommandConfigurator } from './antigravity.js';
|
||||
import { IflowSlashCommandConfigurator } from './iflow.js';
|
||||
|
||||
export class SlashCommandRegistry {
|
||||
private static configurators: Map<string, SlashCommandConfigurator> = new Map();
|
||||
|
||||
static {
|
||||
const claude = new ClaudeSlashCommandConfigurator();
|
||||
const codeBuddy = new CodeBuddySlashCommandConfigurator();
|
||||
const qoder = new QoderSlashCommandConfigurator();
|
||||
const cursor = new CursorSlashCommandConfigurator();
|
||||
const windsurf = new WindsurfSlashCommandConfigurator();
|
||||
const kilocode = new KiloCodeSlashCommandConfigurator();
|
||||
@@ -24,10 +35,19 @@ export class SlashCommandRegistry {
|
||||
const githubCopilot = new GitHubCopilotSlashCommandConfigurator();
|
||||
const amazonQ = new AmazonQSlashCommandConfigurator();
|
||||
const factory = new FactorySlashCommandConfigurator();
|
||||
const gemini = new GeminiSlashCommandConfigurator();
|
||||
const auggie = new AuggieSlashCommandConfigurator();
|
||||
const cline = new ClineSlashCommandConfigurator();
|
||||
const crush = new CrushSlashCommandConfigurator();
|
||||
const costrict = new CostrictSlashCommandConfigurator();
|
||||
const qwen = new QwenSlashCommandConfigurator();
|
||||
const roocode = new RooCodeSlashCommandConfigurator();
|
||||
const antigravity = new AntigravitySlashCommandConfigurator();
|
||||
const iflow = new IflowSlashCommandConfigurator();
|
||||
|
||||
this.configurators.set(claude.toolId, claude);
|
||||
this.configurators.set(codeBuddy.toolId, codeBuddy);
|
||||
this.configurators.set(qoder.toolId, qoder);
|
||||
this.configurators.set(cursor.toolId, cursor);
|
||||
this.configurators.set(windsurf.toolId, windsurf);
|
||||
this.configurators.set(kilocode.toolId, kilocode);
|
||||
@@ -36,8 +56,15 @@ export class SlashCommandRegistry {
|
||||
this.configurators.set(githubCopilot.toolId, githubCopilot);
|
||||
this.configurators.set(amazonQ.toolId, amazonQ);
|
||||
this.configurators.set(factory.toolId, factory);
|
||||
this.configurators.set(gemini.toolId, gemini);
|
||||
this.configurators.set(auggie.toolId, auggie);
|
||||
this.configurators.set(cline.toolId, cline);
|
||||
this.configurators.set(crush.toolId, crush);
|
||||
this.configurators.set(costrict.toolId, costrict);
|
||||
this.configurators.set(qwen.toolId, qwen);
|
||||
this.configurators.set(roocode.toolId, roocode);
|
||||
this.configurators.set(antigravity.toolId, antigravity);
|
||||
this.configurators.set(iflow.toolId, iflow);
|
||||
}
|
||||
|
||||
static register(configurator: SlashCommandConfigurator): void {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const NEW_FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.roo/commands/openspec-proposal.md',
|
||||
apply: '.roo/commands/openspec-apply.md',
|
||||
archive: '.roo/commands/openspec-archive.md'
|
||||
};
|
||||
|
||||
export class RooCodeSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'roocode';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return NEW_FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string | undefined {
|
||||
const descriptions: Record<SlashCommandId, string> = {
|
||||
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
|
||||
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
|
||||
archive: 'Archive a deployed OpenSpec change and update specs.'
|
||||
};
|
||||
const description = descriptions[id];
|
||||
return `# OpenSpec: ${id.charAt(0).toUpperCase() + id.slice(1)}\n\n${description}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { FileSystemUtils } from '../../../utils/file-system.js';
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../../config.js';
|
||||
|
||||
export abstract class TomlSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
protected getFrontmatter(_id: SlashCommandId): string | undefined {
|
||||
// TOML doesn't use separate frontmatter - it's all in one structure
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected abstract getDescription(id: SlashCommandId): string;
|
||||
|
||||
// Override to generate TOML format with markers inside the prompt field
|
||||
async generateAll(projectPath: string, _openspecDir: string): Promise<string[]> {
|
||||
const createdOrUpdated: string[] = [];
|
||||
|
||||
for (const target of this.getTargets()) {
|
||||
const body = this.getBody(target.id);
|
||||
const filePath = FileSystemUtils.joinPath(projectPath, target.path);
|
||||
|
||||
if (await FileSystemUtils.fileExists(filePath)) {
|
||||
await this.updateBody(filePath, body);
|
||||
} else {
|
||||
const tomlContent = this.generateTOML(target.id, body);
|
||||
await FileSystemUtils.writeFile(filePath, tomlContent);
|
||||
}
|
||||
|
||||
createdOrUpdated.push(target.path);
|
||||
}
|
||||
|
||||
return createdOrUpdated;
|
||||
}
|
||||
|
||||
private generateTOML(id: SlashCommandId, body: string): string {
|
||||
const description = this.getDescription(id);
|
||||
|
||||
// TOML format with triple-quoted string for multi-line prompt
|
||||
// Markers are inside the prompt value
|
||||
return `description = "${description}"
|
||||
|
||||
prompt = """
|
||||
${OPENSPEC_MARKERS.start}
|
||||
${body}
|
||||
${OPENSPEC_MARKERS.end}
|
||||
"""
|
||||
`;
|
||||
}
|
||||
|
||||
// Override updateBody to handle TOML format
|
||||
protected async updateBody(filePath: string, body: string): Promise<void> {
|
||||
const content = await FileSystemUtils.readFile(filePath);
|
||||
const startIndex = content.indexOf(OPENSPEC_MARKERS.start);
|
||||
const endIndex = content.indexOf(OPENSPEC_MARKERS.end);
|
||||
|
||||
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
|
||||
throw new Error(`Missing OpenSpec markers in ${filePath}`);
|
||||
}
|
||||
|
||||
const before = content.slice(0, startIndex + OPENSPEC_MARKERS.start.length);
|
||||
const after = content.slice(endIndex);
|
||||
const updatedContent = `${before}\n${body}\n${after}`;
|
||||
|
||||
await FileSystemUtils.writeFile(filePath, updatedContent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
|
||||
// Constants
|
||||
export const GLOBAL_CONFIG_DIR_NAME = 'openspec';
|
||||
export const GLOBAL_CONFIG_FILE_NAME = 'config.json';
|
||||
|
||||
// TypeScript interfaces
|
||||
export interface GlobalConfig {
|
||||
featureFlags?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: GlobalConfig = {
|
||||
featureFlags: {}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the global configuration directory path following XDG Base Directory Specification.
|
||||
*
|
||||
* - All platforms: $XDG_CONFIG_HOME/openspec/ if XDG_CONFIG_HOME is set
|
||||
* - Unix/macOS fallback: ~/.config/openspec/
|
||||
* - Windows fallback: %APPDATA%/openspec/
|
||||
*/
|
||||
export function getGlobalConfigDir(): string {
|
||||
// XDG_CONFIG_HOME takes precedence on all platforms when explicitly set
|
||||
const xdgConfigHome = process.env.XDG_CONFIG_HOME;
|
||||
if (xdgConfigHome) {
|
||||
return path.join(xdgConfigHome, GLOBAL_CONFIG_DIR_NAME);
|
||||
}
|
||||
|
||||
const platform = os.platform();
|
||||
|
||||
if (platform === 'win32') {
|
||||
// Windows: use %APPDATA%
|
||||
const appData = process.env.APPDATA;
|
||||
if (appData) {
|
||||
return path.join(appData, GLOBAL_CONFIG_DIR_NAME);
|
||||
}
|
||||
// Fallback for Windows if APPDATA is not set
|
||||
return path.join(os.homedir(), 'AppData', 'Roaming', GLOBAL_CONFIG_DIR_NAME);
|
||||
}
|
||||
|
||||
// Unix/macOS fallback: ~/.config
|
||||
return path.join(os.homedir(), '.config', GLOBAL_CONFIG_DIR_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path to the global config file.
|
||||
*/
|
||||
export function getGlobalConfigPath(): string {
|
||||
return path.join(getGlobalConfigDir(), GLOBAL_CONFIG_FILE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the global configuration from disk.
|
||||
* Returns default configuration if file doesn't exist or is invalid.
|
||||
* Merges loaded config with defaults to ensure new fields are available.
|
||||
*/
|
||||
export function getGlobalConfig(): GlobalConfig {
|
||||
const configPath = getGlobalConfigPath();
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return { ...DEFAULT_CONFIG };
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
// Merge with defaults (loaded values take precedence)
|
||||
return {
|
||||
...DEFAULT_CONFIG,
|
||||
...parsed,
|
||||
// Deep merge featureFlags
|
||||
featureFlags: {
|
||||
...DEFAULT_CONFIG.featureFlags,
|
||||
...(parsed.featureFlags || {})
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
// Log warning for parse errors, but not for missing files
|
||||
if (error instanceof SyntaxError) {
|
||||
console.error(`Warning: Invalid JSON in ${configPath}, using defaults`);
|
||||
}
|
||||
return { ...DEFAULT_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the global configuration to disk.
|
||||
* Creates the config directory if it doesn't exist.
|
||||
*/
|
||||
export function saveGlobalConfig(config: GlobalConfig): void {
|
||||
const configDir = getGlobalConfigDir();
|
||||
const configPath = getGlobalConfigPath();
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
+9
-1
@@ -1,2 +1,10 @@
|
||||
// Core OpenSpec logic will be implemented here
|
||||
export {};
|
||||
export {
|
||||
GLOBAL_CONFIG_DIR_NAME,
|
||||
GLOBAL_CONFIG_FILE_NAME,
|
||||
type GlobalConfig,
|
||||
getGlobalConfigDir,
|
||||
getGlobalConfigPath,
|
||||
getGlobalConfig,
|
||||
saveGlobalConfig
|
||||
} from './global-config.js';
|
||||
+104
-21
@@ -21,6 +21,7 @@ import {
|
||||
AI_TOOLS,
|
||||
OPENSPEC_DIR_NAME,
|
||||
AIToolOption,
|
||||
OPENSPEC_MARKERS,
|
||||
} from './config.js';
|
||||
import { PALETTE } from './styles/palette.js';
|
||||
|
||||
@@ -388,7 +389,7 @@ export class InitCommand {
|
||||
|
||||
// Validation happens silently in the background
|
||||
const extendMode = await this.validate(projectPath, openspecPath);
|
||||
const existingToolStates = await this.getExistingToolStates(projectPath);
|
||||
const existingToolStates = await this.getExistingToolStates(projectPath, extendMode);
|
||||
|
||||
this.renderBanner(extendMode);
|
||||
|
||||
@@ -427,9 +428,11 @@ export class InitCommand {
|
||||
} else {
|
||||
ora({ stream: process.stdout }).info(
|
||||
PALETTE.midGray(
|
||||
'ℹ OpenSpec already initialized. Skipping base scaffolding.'
|
||||
'ℹ OpenSpec already initialized. Checking for missing files...'
|
||||
)
|
||||
);
|
||||
await this.createDirectoryStructure(openspecPath);
|
||||
await this.ensureTemplateFiles(openspecPath, config);
|
||||
}
|
||||
|
||||
// Step 2: Configure AI tools
|
||||
@@ -627,35 +630,78 @@ export class InitCommand {
|
||||
}
|
||||
|
||||
private async getExistingToolStates(
|
||||
projectPath: string
|
||||
projectPath: string,
|
||||
extendMode: boolean
|
||||
): Promise<Record<string, boolean>> {
|
||||
const states: Record<string, boolean> = {};
|
||||
for (const tool of AI_TOOLS) {
|
||||
states[tool.value] = await this.isToolConfigured(projectPath, tool.value);
|
||||
// Fresh initialization - no tools configured yet
|
||||
if (!extendMode) {
|
||||
return Object.fromEntries(AI_TOOLS.map(t => [t.value, false]));
|
||||
}
|
||||
return states;
|
||||
|
||||
// Extend mode - check all tools in parallel for better performance
|
||||
const entries = await Promise.all(
|
||||
AI_TOOLS.map(async (t) => [t.value, await this.isToolConfigured(projectPath, t.value)] as const)
|
||||
);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
private async isToolConfigured(
|
||||
projectPath: string,
|
||||
toolId: string
|
||||
): Promise<boolean> {
|
||||
const configFile = ToolRegistry.get(toolId)?.configFileName;
|
||||
if (
|
||||
configFile &&
|
||||
(await FileSystemUtils.fileExists(path.join(projectPath, configFile)))
|
||||
)
|
||||
return true;
|
||||
// A tool is only considered "configured by OpenSpec" if its files contain OpenSpec markers.
|
||||
// For tools with both config files and slash commands, BOTH must have markers.
|
||||
// For slash commands, at least one file with markers is sufficient (not all required).
|
||||
|
||||
const slashConfigurator = SlashCommandRegistry.get(toolId);
|
||||
if (!slashConfigurator) return false;
|
||||
for (const target of slashConfigurator.getTargets()) {
|
||||
const absolute = slashConfigurator.resolveAbsolutePath(
|
||||
projectPath,
|
||||
target.id
|
||||
);
|
||||
if (await FileSystemUtils.fileExists(absolute)) return true;
|
||||
// Helper to check if a file exists and contains OpenSpec markers
|
||||
const fileHasMarkers = async (absolutePath: string): Promise<boolean> => {
|
||||
try {
|
||||
const content = await FileSystemUtils.readFile(absolutePath);
|
||||
return content.includes(OPENSPEC_MARKERS.start) && content.includes(OPENSPEC_MARKERS.end);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let hasConfigFile = false;
|
||||
let hasSlashCommands = false;
|
||||
|
||||
// Check if the tool has a config file with OpenSpec markers
|
||||
const configFile = ToolRegistry.get(toolId)?.configFileName;
|
||||
if (configFile) {
|
||||
const configPath = path.join(projectPath, configFile);
|
||||
hasConfigFile = (await FileSystemUtils.fileExists(configPath)) && (await fileHasMarkers(configPath));
|
||||
}
|
||||
|
||||
// Check if any slash command file exists with OpenSpec markers
|
||||
const slashConfigurator = SlashCommandRegistry.get(toolId);
|
||||
if (slashConfigurator) {
|
||||
for (const target of slashConfigurator.getTargets()) {
|
||||
const absolute = slashConfigurator.resolveAbsolutePath(projectPath, target.id);
|
||||
if ((await FileSystemUtils.fileExists(absolute)) && (await fileHasMarkers(absolute))) {
|
||||
hasSlashCommands = true;
|
||||
break; // At least one file with markers is sufficient
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tool is only configured if BOTH exist with markers
|
||||
// OR if the tool has no config file requirement (slash commands only)
|
||||
// OR if the tool has no slash commands requirement (config file only)
|
||||
const hasConfigFileRequirement = configFile !== undefined;
|
||||
const hasSlashCommandRequirement = slashConfigurator !== undefined;
|
||||
|
||||
if (hasConfigFileRequirement && hasSlashCommandRequirement) {
|
||||
// Both are required - both must be present with markers
|
||||
return hasConfigFile && hasSlashCommands;
|
||||
} else if (hasConfigFileRequirement) {
|
||||
// Only config file required
|
||||
return hasConfigFile;
|
||||
} else if (hasSlashCommandRequirement) {
|
||||
// Only slash commands required
|
||||
return hasSlashCommands;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -675,6 +721,21 @@ export class InitCommand {
|
||||
private async generateFiles(
|
||||
openspecPath: string,
|
||||
config: OpenSpecConfig
|
||||
): Promise<void> {
|
||||
await this.writeTemplateFiles(openspecPath, config, false);
|
||||
}
|
||||
|
||||
private async ensureTemplateFiles(
|
||||
openspecPath: string,
|
||||
config: OpenSpecConfig
|
||||
): Promise<void> {
|
||||
await this.writeTemplateFiles(openspecPath, config, true);
|
||||
}
|
||||
|
||||
private async writeTemplateFiles(
|
||||
openspecPath: string,
|
||||
config: OpenSpecConfig,
|
||||
skipExisting: boolean
|
||||
): Promise<void> {
|
||||
const context: ProjectContext = {
|
||||
// Could be enhanced with prompts for project details
|
||||
@@ -684,6 +745,12 @@ export class InitCommand {
|
||||
|
||||
for (const template of templates) {
|
||||
const filePath = path.join(openspecPath, template.path);
|
||||
|
||||
// Skip if file exists and we're in skipExisting mode
|
||||
if (skipExisting && (await FileSystemUtils.fileExists(filePath))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content =
|
||||
typeof template.content === 'function'
|
||||
? template.content(context)
|
||||
@@ -795,6 +862,22 @@ export class InitCommand {
|
||||
)
|
||||
);
|
||||
|
||||
// Show restart instruction if any tools were configured
|
||||
if (created.length > 0 || refreshed.length > 0) {
|
||||
console.log();
|
||||
console.log(PALETTE.white('Important: Restart your IDE'));
|
||||
console.log(
|
||||
PALETTE.midGray(
|
||||
'Slash commands are loaded at startup. Please restart your coding assistant'
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
PALETTE.midGray(
|
||||
'to ensure the new /openspec commands appear in your command palette.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Get the selected tool name(s) for display
|
||||
const toolName = this.formatToolNames(selectedTools);
|
||||
|
||||
|
||||
@@ -101,6 +101,12 @@ export interface DeltaPlan {
|
||||
modified: RequirementBlock[];
|
||||
removed: string[]; // requirement names
|
||||
renamed: Array<{ from: string; to: string }>;
|
||||
sectionPresence: {
|
||||
added: boolean;
|
||||
modified: boolean;
|
||||
removed: boolean;
|
||||
renamed: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLineEndings(content: string): string {
|
||||
@@ -113,11 +119,26 @@ function normalizeLineEndings(content: string): string {
|
||||
export function parseDeltaSpec(content: string): DeltaPlan {
|
||||
const normalized = normalizeLineEndings(content);
|
||||
const sections = splitTopLevelSections(normalized);
|
||||
const added = parseRequirementBlocksFromSection(sections['ADDED Requirements'] || '');
|
||||
const modified = parseRequirementBlocksFromSection(sections['MODIFIED Requirements'] || '');
|
||||
const removedNames = parseRemovedNames(sections['REMOVED Requirements'] || '');
|
||||
const renamedPairs = parseRenamedPairs(sections['RENAMED Requirements'] || '');
|
||||
return { added, modified, removed: removedNames, renamed: renamedPairs };
|
||||
const addedLookup = getSectionCaseInsensitive(sections, 'ADDED Requirements');
|
||||
const modifiedLookup = getSectionCaseInsensitive(sections, 'MODIFIED Requirements');
|
||||
const removedLookup = getSectionCaseInsensitive(sections, 'REMOVED Requirements');
|
||||
const renamedLookup = getSectionCaseInsensitive(sections, 'RENAMED Requirements');
|
||||
const added = parseRequirementBlocksFromSection(addedLookup.body);
|
||||
const modified = parseRequirementBlocksFromSection(modifiedLookup.body);
|
||||
const removedNames = parseRemovedNames(removedLookup.body);
|
||||
const renamedPairs = parseRenamedPairs(renamedLookup.body);
|
||||
return {
|
||||
added,
|
||||
modified,
|
||||
removed: removedNames,
|
||||
renamed: renamedPairs,
|
||||
sectionPresence: {
|
||||
added: addedLookup.found,
|
||||
modified: modifiedLookup.found,
|
||||
removed: removedLookup.found,
|
||||
renamed: renamedLookup.found,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function splitTopLevelSections(content: string): Record<string, string> {
|
||||
@@ -140,6 +161,14 @@ function splitTopLevelSections(content: string): Record<string, string> {
|
||||
return result;
|
||||
}
|
||||
|
||||
function getSectionCaseInsensitive(sections: Record<string, string>, desired: string): { body: string; found: boolean } {
|
||||
const target = desired.toLowerCase();
|
||||
for (const [title, body] of Object.entries(sections)) {
|
||||
if (title.toLowerCase() === target) return { body, found: true };
|
||||
}
|
||||
return { body: '', found: false };
|
||||
}
|
||||
|
||||
function parseRequirementBlocksFromSection(sectionBody: string): RequirementBlock[] {
|
||||
if (!sectionBody) return [];
|
||||
const lines = normalizeLineEndings(sectionBody).split('\n');
|
||||
@@ -203,5 +232,3 @@ function parseRenamedPairs(sectionBody: string): Array<{ from: string; to: strin
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -95,7 +95,6 @@ After deployment, create separate PR to:
|
||||
openspec list # List active changes
|
||||
openspec list --specs # List specifications
|
||||
openspec show [item] # Display change or spec
|
||||
openspec diff [change] # Show spec differences
|
||||
openspec validate [item] # Validate changes or specs
|
||||
openspec archive <change-id> [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
|
||||
|
||||
@@ -161,6 +160,8 @@ New request?
|
||||
|
||||
2. **Write proposal.md:**
|
||||
\`\`\`markdown
|
||||
# Change: [Brief description of change]
|
||||
|
||||
## Why
|
||||
[1-2 sentences on problem/opportunity]
|
||||
|
||||
@@ -448,7 +449,6 @@ Only add complexity with:
|
||||
\`\`\`bash
|
||||
openspec list # What's in progress?
|
||||
openspec show [item] # View details
|
||||
openspec diff [change] # What's changing?
|
||||
openspec validate --strict # Is it correct?
|
||||
openspec archive <change-id> [--yes|-y] # Mark complete (add --yes for automation)
|
||||
\`\`\`
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { agentsRootStubTemplate as clineTemplate } from './agents-root-stub.js';
|
||||
@@ -0,0 +1 @@
|
||||
export { agentsRootStubTemplate as costrictTemplate } from './agents-root-stub.js';
|
||||
@@ -1,6 +1,8 @@
|
||||
import { agentsTemplate } from './agents-template.js';
|
||||
import { projectTemplate, ProjectContext } from './project-template.js';
|
||||
import { claudeTemplate } from './claude-template.js';
|
||||
import { clineTemplate } from './cline-template.js';
|
||||
import { costrictTemplate } from './costrict-template.js';
|
||||
import { agentsRootStubTemplate } from './agents-root-stub.js';
|
||||
import { getSlashCommandBody, SlashCommandId } from './slash-command-templates.js';
|
||||
|
||||
@@ -27,6 +29,14 @@ export class TemplateManager {
|
||||
return claudeTemplate;
|
||||
}
|
||||
|
||||
static getClineTemplate(): string {
|
||||
return clineTemplate;
|
||||
}
|
||||
|
||||
static getCostrictTemplate(): string {
|
||||
return costrictTemplate;
|
||||
}
|
||||
|
||||
static getAgentsStandardTemplate(): string {
|
||||
return agentsRootStubTemplate;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ const baseGuardrails = `**Guardrails**
|
||||
- Keep changes tightly scoped to the requested outcome.
|
||||
- Refer to \`openspec/AGENTS.md\` (located inside the \`openspec/\` directory—run \`ls openspec\` or \`openspec update\` if you don't see it) if you need additional OpenSpec conventions or clarifications.`;
|
||||
|
||||
const proposalGuardrails = `${baseGuardrails}\n- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files.`;
|
||||
const proposalGuardrails = `${baseGuardrails}\n- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files.
|
||||
- Do not write any code during the proposal stage. Only create design documents (proposal.md, tasks.md, design.md, and spec deltas). Implementation happens in the apply stage after approval.`;
|
||||
|
||||
const proposalSteps = `**Steps**
|
||||
1. Review \`openspec/project.md\`, run \`openspec list\` and \`openspec list --specs\`, and inspect related code or docs (e.g., via \`rg\`/\`ls\`) to ground the proposal in current behaviour; note any gaps that require clarification.
|
||||
@@ -16,6 +17,7 @@ const proposalSteps = `**Steps**
|
||||
6. Draft \`tasks.md\` as an ordered list of small, verifiable work items that deliver user-visible progress, include validation (tests, tooling), and highlight dependencies or parallelizable work.
|
||||
7. Validate with \`openspec validate <id> --strict\` and resolve every issue before sharing the proposal.`;
|
||||
|
||||
|
||||
const proposalReferences = `**Reference**
|
||||
- Use \`openspec show <id> --json --deltas-only\` or \`openspec show <spec> --type spec\` to inspect details when validation fails.
|
||||
- Search existing requirements with \`rg -n "Requirement:|Scenario:" openspec/specs\` before writing new ones.
|
||||
|
||||
@@ -114,6 +114,8 @@ export class Validator {
|
||||
const issues: ValidationIssue[] = [];
|
||||
const specsDir = path.join(changeDir, 'specs');
|
||||
let totalDeltas = 0;
|
||||
const missingHeaderSpecs: string[] = [];
|
||||
const emptySectionSpecs: Array<{ path: string; sections: string[] }> = [];
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(specsDir, { withFileTypes: true });
|
||||
@@ -130,6 +132,17 @@ export class Validator {
|
||||
|
||||
const plan = parseDeltaSpec(content);
|
||||
const entryPath = `${specName}/spec.md`;
|
||||
const sectionNames: string[] = [];
|
||||
if (plan.sectionPresence.added) sectionNames.push('## ADDED Requirements');
|
||||
if (plan.sectionPresence.modified) sectionNames.push('## MODIFIED Requirements');
|
||||
if (plan.sectionPresence.removed) sectionNames.push('## REMOVED Requirements');
|
||||
if (plan.sectionPresence.renamed) sectionNames.push('## RENAMED Requirements');
|
||||
const hasSections = sectionNames.length > 0;
|
||||
const hasEntries = plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length > 0;
|
||||
if (!hasEntries) {
|
||||
if (hasSections) emptySectionSpecs.push({ path: entryPath, sections: sectionNames });
|
||||
else missingHeaderSpecs.push(entryPath);
|
||||
}
|
||||
|
||||
const addedNames = new Set<string>();
|
||||
const modifiedNames = new Set<string>();
|
||||
@@ -236,6 +249,21 @@ export class Validator {
|
||||
// If no specs dir, treat as no deltas
|
||||
}
|
||||
|
||||
for (const { path: specPath, sections } of emptySectionSpecs) {
|
||||
issues.push({
|
||||
level: 'ERROR',
|
||||
path: specPath,
|
||||
message: `Delta sections ${this.formatSectionList(sections)} were found, but no requirement entries parsed. Ensure each section includes at least one "### Requirement:" block (REMOVED may use bullet list syntax).`,
|
||||
});
|
||||
}
|
||||
for (const path of missingHeaderSpecs) {
|
||||
issues.push({
|
||||
level: 'ERROR',
|
||||
path,
|
||||
message: 'No delta sections found. Add headers such as "## ADDED Requirements" or move non-delta notes outside specs/.',
|
||||
});
|
||||
}
|
||||
|
||||
if (totalDeltas === 0) {
|
||||
issues.push({ level: 'ERROR', path: 'file', message: this.enrichTopLevelError('change', VALIDATION_MESSAGES.CHANGE_NO_DELTAS) });
|
||||
}
|
||||
@@ -409,4 +437,12 @@ export class Validator {
|
||||
const matches = blockRaw.match(/^####\s+/gm);
|
||||
return matches ? matches.length : 0;
|
||||
}
|
||||
|
||||
private formatSectionList(sections: string[]): string {
|
||||
if (sections.length === 0) return '';
|
||||
if (sections.length === 1) return sections[0];
|
||||
const head = sections.slice(0, -1);
|
||||
const last = sections[sections.length - 1];
|
||||
return `${head.join(', ')} and ${last}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import { promises as fs, constants as fsConstants } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
function isMarkerOnOwnLine(content: string, markerIndex: number, markerLength: number): boolean {
|
||||
@@ -93,10 +93,24 @@ export class FileSystemUtils {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (stats.mode & 0o222) !== 0;
|
||||
// On Windows, stats.mode doesn't reliably indicate write permissions.
|
||||
// Use fs.access with W_OK to check actual write permissions cross-platform.
|
||||
try {
|
||||
await fs.access(filePath, fsConstants.W_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return true;
|
||||
// File doesn't exist; check if we can write to the parent directory
|
||||
const parentDir = path.dirname(filePath);
|
||||
try {
|
||||
await fs.access(parentDir, fsConstants.W_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
console.debug(`Unable to determine write permissions for ${filePath}: ${error.message}`);
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
export function isInteractive(noInteractiveFlag?: boolean): boolean {
|
||||
if (noInteractiveFlag) return false;
|
||||
type InteractiveOptions = {
|
||||
/**
|
||||
* Explicit "disable prompts" flag passed by internal callers.
|
||||
*/
|
||||
noInteractive?: boolean;
|
||||
/**
|
||||
* Commander-style negated option: `--no-interactive` sets this to false.
|
||||
*/
|
||||
interactive?: boolean;
|
||||
};
|
||||
|
||||
function resolveNoInteractive(value?: boolean | InteractiveOptions): boolean {
|
||||
if (typeof value === 'boolean') return value;
|
||||
return value?.noInteractive === true || value?.interactive === false;
|
||||
}
|
||||
|
||||
export function isInteractive(value?: boolean | InteractiveOptions): boolean {
|
||||
if (resolveNoInteractive(value)) return false;
|
||||
if (process.env.OPEN_SPEC_INTERACTIVE === '0') return false;
|
||||
return !!process.stdin.isTTY;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -43,3 +43,24 @@ export async function getSpecIds(root: string = process.cwd()): Promise<string[]
|
||||
return result.sort();
|
||||
}
|
||||
|
||||
export async function getArchivedChangeIds(root: string = process.cwd()): Promise<string[]> {
|
||||
const archivePath = path.join(root, 'openspec', 'changes', 'archive');
|
||||
try {
|
||||
const entries = await fs.readdir(archivePath, { withFileTypes: true });
|
||||
const result: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
||||
const proposalPath = path.join(archivePath, entry.name, 'proposal.md');
|
||||
try {
|
||||
await fs.access(proposalPath);
|
||||
result.push(entry.name);
|
||||
} catch {
|
||||
// skip directories without proposal.md
|
||||
}
|
||||
}
|
||||
return result.sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Supported shell types for completion generation
|
||||
*/
|
||||
export type SupportedShell = 'zsh' | 'bash' | 'fish' | 'powershell';
|
||||
|
||||
/**
|
||||
* Result of shell detection
|
||||
*/
|
||||
export interface ShellDetectionResult {
|
||||
/** The detected shell if supported, otherwise undefined */
|
||||
shell: SupportedShell | undefined;
|
||||
/** The raw shell name detected (even if unsupported), or undefined if nothing detected */
|
||||
detected: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the current user's shell based on environment variables
|
||||
*
|
||||
* @returns Detection result with supported shell and raw detected name
|
||||
*/
|
||||
export function detectShell(): ShellDetectionResult {
|
||||
// Try SHELL environment variable first (Unix-like systems)
|
||||
const shellPath = process.env.SHELL;
|
||||
|
||||
if (shellPath) {
|
||||
const shellName = shellPath.toLowerCase();
|
||||
|
||||
if (shellName.includes('zsh')) {
|
||||
return { shell: 'zsh', detected: 'zsh' };
|
||||
}
|
||||
if (shellName.includes('bash')) {
|
||||
return { shell: 'bash', detected: 'bash' };
|
||||
}
|
||||
if (shellName.includes('fish')) {
|
||||
return { shell: 'fish', detected: 'fish' };
|
||||
}
|
||||
|
||||
// Shell detected but not supported
|
||||
// Extract shell name from path (e.g., /bin/tcsh -> tcsh)
|
||||
const match = shellPath.match(/\/([^/]+)$/);
|
||||
const detectedName = match ? match[1] : shellPath;
|
||||
return { shell: undefined, detected: detectedName };
|
||||
}
|
||||
|
||||
// Check for PowerShell on Windows
|
||||
// PSModulePath is a reliable PowerShell-specific environment variable
|
||||
if (process.env.PSModulePath || process.platform === 'win32') {
|
||||
const comspec = process.env.COMSPEC?.toLowerCase();
|
||||
|
||||
// If PSModulePath exists, we're definitely in PowerShell
|
||||
if (process.env.PSModulePath) {
|
||||
return { shell: 'powershell', detected: 'powershell' };
|
||||
}
|
||||
|
||||
// On Windows without PSModulePath, we might be in cmd.exe
|
||||
if (comspec?.includes('cmd.exe')) {
|
||||
return { shell: undefined, detected: 'cmd.exe' };
|
||||
}
|
||||
}
|
||||
|
||||
return { shell: undefined, detected: undefined };
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { CompletionCommand } from '../../src/commands/completion.js';
|
||||
import * as shellDetection from '../../src/utils/shell-detection.js';
|
||||
|
||||
// Mock the shell detection module
|
||||
vi.mock('../../src/utils/shell-detection.js', () => ({
|
||||
detectShell: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the ZshInstaller
|
||||
vi.mock('../../src/core/completions/installers/zsh-installer.js', () => ({
|
||||
ZshInstaller: vi.fn().mockImplementation(() => ({
|
||||
install: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
installedPath: '/home/user/.oh-my-zsh/completions/_openspec',
|
||||
isOhMyZsh: true,
|
||||
message: 'Completion script installed successfully for Oh My Zsh',
|
||||
instructions: [
|
||||
'Completion script installed to Oh My Zsh completions directory.',
|
||||
'Restart your shell or run: exec zsh',
|
||||
'Completions should activate automatically.',
|
||||
],
|
||||
}),
|
||||
uninstall: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Completion script removed from /home/user/.oh-my-zsh/completions/_openspec',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('CompletionCommand', () => {
|
||||
let command: CompletionCommand;
|
||||
let consoleLogSpy: any;
|
||||
let consoleErrorSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
command = new CompletionCommand();
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
process.exitCode = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('generate subcommand', () => {
|
||||
it('should generate Zsh completion script to stdout', async () => {
|
||||
await command.generate({ shell: 'zsh' });
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalled();
|
||||
const output = consoleLogSpy.mock.calls[0][0];
|
||||
expect(output).toContain('#compdef openspec');
|
||||
expect(output).toContain('_openspec() {');
|
||||
});
|
||||
|
||||
it('should auto-detect Zsh shell when no shell specified', async () => {
|
||||
vi.mocked(shellDetection.detectShell).mockReturnValue({ shell: 'zsh', detected: 'zsh' });
|
||||
|
||||
await command.generate({});
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalled();
|
||||
const output = consoleLogSpy.mock.calls[0][0];
|
||||
expect(output).toContain('#compdef openspec');
|
||||
});
|
||||
|
||||
it('should show error when shell cannot be auto-detected', async () => {
|
||||
vi.mocked(shellDetection.detectShell).mockReturnValue({ shell: undefined, detected: undefined });
|
||||
|
||||
await command.generate({});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error: Could not auto-detect shell. Please specify shell explicitly.'
|
||||
);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('should show error for unsupported shell', async () => {
|
||||
await command.generate({ shell: 'bash' });
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Error: Shell 'bash' is not supported yet. Currently supported: zsh"
|
||||
);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle shell parameter case-insensitively', async () => {
|
||||
await command.generate({ shell: 'ZSH' });
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalled();
|
||||
const output = consoleLogSpy.mock.calls[0][0];
|
||||
expect(output).toContain('#compdef openspec');
|
||||
});
|
||||
});
|
||||
|
||||
describe('install subcommand', () => {
|
||||
it('should install Zsh completion script', async () => {
|
||||
await command.install({ shell: 'zsh' });
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Completion script installed successfully')
|
||||
);
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('should show verbose output when --verbose flag is provided', async () => {
|
||||
await command.install({ shell: 'zsh', verbose: true });
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Installed to:')
|
||||
);
|
||||
});
|
||||
|
||||
it('should auto-detect Zsh shell when no shell specified', async () => {
|
||||
vi.mocked(shellDetection.detectShell).mockReturnValue({ shell: 'zsh', detected: 'zsh' });
|
||||
|
||||
await command.install({});
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Completion script installed successfully')
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error when shell cannot be auto-detected', async () => {
|
||||
vi.mocked(shellDetection.detectShell).mockReturnValue({ shell: undefined, detected: undefined });
|
||||
|
||||
await command.install({});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error: Could not auto-detect shell. Please specify shell explicitly.'
|
||||
);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('should show error for unsupported shell', async () => {
|
||||
await command.install({ shell: 'fish' });
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Error: Shell 'fish' is not supported yet. Currently supported: zsh"
|
||||
);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('should display installation instructions', async () => {
|
||||
await command.install({ shell: 'zsh' });
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Restart your shell or run: exec zsh')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstall subcommand', () => {
|
||||
it('should uninstall Zsh completion script', async () => {
|
||||
await command.uninstall({ shell: 'zsh', yes: true });
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Completion script removed')
|
||||
);
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('should auto-detect Zsh shell when no shell specified', async () => {
|
||||
vi.mocked(shellDetection.detectShell).mockReturnValue({ shell: 'zsh', detected: 'zsh' });
|
||||
|
||||
await command.uninstall({ yes: true });
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Completion script removed')
|
||||
);
|
||||
});
|
||||
|
||||
it('should show error when shell cannot be auto-detected', async () => {
|
||||
vi.mocked(shellDetection.detectShell).mockReturnValue({ shell: undefined, detected: undefined });
|
||||
|
||||
await command.uninstall({ yes: true });
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error: Could not auto-detect shell. Please specify shell explicitly.'
|
||||
);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('should show error for unsupported shell', async () => {
|
||||
await command.uninstall({ shell: 'powershell', yes: true });
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Error: Shell 'powershell' is not supported yet. Currently supported: zsh"
|
||||
);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle installation failures gracefully', async () => {
|
||||
const { ZshInstaller } = await import('../../src/core/completions/installers/zsh-installer.js');
|
||||
vi.mocked(ZshInstaller).mockImplementationOnce(() => ({
|
||||
install: vi.fn().mockResolvedValue({
|
||||
success: false,
|
||||
isOhMyZsh: false,
|
||||
message: 'Permission denied',
|
||||
}),
|
||||
uninstall: vi.fn(),
|
||||
isInstalled: vi.fn(),
|
||||
getInstallationInfo: vi.fn(),
|
||||
isOhMyZshInstalled: vi.fn(),
|
||||
getInstallationPath: vi.fn(),
|
||||
backupExistingFile: vi.fn(),
|
||||
} as any));
|
||||
|
||||
const cmd = new CompletionCommand();
|
||||
await cmd.install({ shell: 'zsh' });
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Permission denied')
|
||||
);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle uninstallation failures gracefully', async () => {
|
||||
const { ZshInstaller } = await import('../../src/core/completions/installers/zsh-installer.js');
|
||||
vi.mocked(ZshInstaller).mockImplementationOnce(() => ({
|
||||
install: vi.fn(),
|
||||
uninstall: vi.fn().mockResolvedValue({
|
||||
success: false,
|
||||
message: 'Completion script is not installed',
|
||||
}),
|
||||
isInstalled: vi.fn(),
|
||||
getInstallationInfo: vi.fn(),
|
||||
isOhMyZshInstalled: vi.fn(),
|
||||
getInstallationPath: vi.fn(),
|
||||
backupExistingFile: vi.fn(),
|
||||
} as any));
|
||||
|
||||
const cmd = new CompletionCommand();
|
||||
await cmd.uninstall({ shell: 'zsh', yes: true });
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Completion script is not installed')
|
||||
);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shell detection integration', () => {
|
||||
it('should show appropriate error when detected shell is unsupported', async () => {
|
||||
vi.mocked(shellDetection.detectShell).mockReturnValue({ shell: undefined, detected: 'bash' });
|
||||
|
||||
await command.generate({});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Error: Shell 'bash' is not supported yet. Currently supported: zsh"
|
||||
);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('should respect explicit shell parameter over auto-detection', async () => {
|
||||
vi.mocked(shellDetection.detectShell).mockReturnValue({ shell: undefined, detected: 'bash' });
|
||||
|
||||
await command.generate({ shell: 'zsh' });
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalled();
|
||||
const output = consoleLogSpy.mock.calls[0][0];
|
||||
expect(output).toContain('#compdef openspec');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
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 { CompletionProvider } from '../../../src/core/completions/completion-provider.js';
|
||||
|
||||
describe('CompletionProvider', () => {
|
||||
let testDir: string;
|
||||
let provider: CompletionProvider;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
provider = new CompletionProvider(2000, testDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('getChangeIds', () => {
|
||||
it('should return empty array when no changes exist', async () => {
|
||||
const changeIds = await provider.getChangeIds();
|
||||
expect(changeIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return active change IDs', async () => {
|
||||
// Create openspec/changes directory structure
|
||||
const changesDir = path.join(testDir, 'openspec', 'changes');
|
||||
await fs.mkdir(changesDir, { recursive: true });
|
||||
|
||||
// Create some changes
|
||||
await fs.mkdir(path.join(changesDir, 'change-1'), { recursive: true });
|
||||
await fs.writeFile(path.join(changesDir, 'change-1', 'proposal.md'), '# Change 1');
|
||||
|
||||
await fs.mkdir(path.join(changesDir, 'change-2'), { recursive: true });
|
||||
await fs.writeFile(path.join(changesDir, 'change-2', 'proposal.md'), '# Change 2');
|
||||
|
||||
const changeIds = await provider.getChangeIds();
|
||||
expect(changeIds).toEqual(['change-1', 'change-2']);
|
||||
});
|
||||
|
||||
it('should exclude archive directory', async () => {
|
||||
const changesDir = path.join(testDir, 'openspec', 'changes');
|
||||
await fs.mkdir(changesDir, { recursive: true });
|
||||
|
||||
// Create active change
|
||||
await fs.mkdir(path.join(changesDir, 'active-change'), { recursive: true });
|
||||
await fs.writeFile(path.join(changesDir, 'active-change', 'proposal.md'), '# Active');
|
||||
|
||||
// Create archived change
|
||||
await fs.mkdir(path.join(changesDir, 'archive', 'old-change'), { recursive: true });
|
||||
await fs.writeFile(path.join(changesDir, 'archive', 'old-change', 'proposal.md'), '# Old');
|
||||
|
||||
const changeIds = await provider.getChangeIds();
|
||||
expect(changeIds).toEqual(['active-change']);
|
||||
});
|
||||
|
||||
it('should cache results for the TTL duration', async () => {
|
||||
const changesDir = path.join(testDir, 'openspec', 'changes');
|
||||
await fs.mkdir(changesDir, { recursive: true });
|
||||
|
||||
await fs.mkdir(path.join(changesDir, 'change-1'), { recursive: true });
|
||||
await fs.writeFile(path.join(changesDir, 'change-1', 'proposal.md'), '# Change 1');
|
||||
|
||||
// First call
|
||||
const firstResult = await provider.getChangeIds();
|
||||
expect(firstResult).toEqual(['change-1']);
|
||||
|
||||
// Add another change
|
||||
await fs.mkdir(path.join(changesDir, 'change-2'), { recursive: true });
|
||||
await fs.writeFile(path.join(changesDir, 'change-2', 'proposal.md'), '# Change 2');
|
||||
|
||||
// Second call should return cached result (still only change-1)
|
||||
const secondResult = await provider.getChangeIds();
|
||||
expect(secondResult).toEqual(['change-1']);
|
||||
});
|
||||
|
||||
it('should refresh cache after TTL expires', async () => {
|
||||
// Use a very short TTL for testing
|
||||
const shortTTLProvider = new CompletionProvider(50, testDir);
|
||||
|
||||
const changesDir = path.join(testDir, 'openspec', 'changes');
|
||||
await fs.mkdir(changesDir, { recursive: true });
|
||||
|
||||
await fs.mkdir(path.join(changesDir, 'change-1'), { recursive: true });
|
||||
await fs.writeFile(path.join(changesDir, 'change-1', 'proposal.md'), '# Change 1');
|
||||
|
||||
// First call
|
||||
const firstResult = await shortTTLProvider.getChangeIds();
|
||||
expect(firstResult).toEqual(['change-1']);
|
||||
|
||||
// Add another change
|
||||
await fs.mkdir(path.join(changesDir, 'change-2'), { recursive: true });
|
||||
await fs.writeFile(path.join(changesDir, 'change-2', 'proposal.md'), '# Change 2');
|
||||
|
||||
// Wait for cache to expire
|
||||
await new Promise(resolve => setTimeout(resolve, 60));
|
||||
|
||||
// Should now see both changes
|
||||
const secondResult = await shortTTLProvider.getChangeIds();
|
||||
expect(secondResult).toEqual(['change-1', 'change-2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSpecIds', () => {
|
||||
it('should return empty array when no specs exist', async () => {
|
||||
const specIds = await provider.getSpecIds();
|
||||
expect(specIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return spec IDs', async () => {
|
||||
const specsDir = path.join(testDir, 'openspec', 'specs');
|
||||
await fs.mkdir(specsDir, { recursive: true });
|
||||
|
||||
// Create some specs
|
||||
await fs.mkdir(path.join(specsDir, 'spec-1'), { recursive: true });
|
||||
await fs.writeFile(path.join(specsDir, 'spec-1', 'spec.md'), '# Spec 1');
|
||||
|
||||
await fs.mkdir(path.join(specsDir, 'spec-2'), { recursive: true });
|
||||
await fs.writeFile(path.join(specsDir, 'spec-2', 'spec.md'), '# Spec 2');
|
||||
|
||||
const specIds = await provider.getSpecIds();
|
||||
expect(specIds).toEqual(['spec-1', 'spec-2']);
|
||||
});
|
||||
|
||||
it('should cache results for the TTL duration', async () => {
|
||||
const specsDir = path.join(testDir, 'openspec', 'specs');
|
||||
await fs.mkdir(specsDir, { recursive: true });
|
||||
|
||||
await fs.mkdir(path.join(specsDir, 'spec-1'), { recursive: true });
|
||||
await fs.writeFile(path.join(specsDir, 'spec-1', 'spec.md'), '# Spec 1');
|
||||
|
||||
// First call
|
||||
const firstResult = await provider.getSpecIds();
|
||||
expect(firstResult).toEqual(['spec-1']);
|
||||
|
||||
// Add another spec
|
||||
await fs.mkdir(path.join(specsDir, 'spec-2'), { recursive: true });
|
||||
await fs.writeFile(path.join(specsDir, 'spec-2', 'spec.md'), '# Spec 2');
|
||||
|
||||
// Second call should return cached result
|
||||
const secondResult = await provider.getSpecIds();
|
||||
expect(secondResult).toEqual(['spec-1']);
|
||||
});
|
||||
|
||||
it('should refresh cache after TTL expires', async () => {
|
||||
const shortTTLProvider = new CompletionProvider(50, testDir);
|
||||
|
||||
const specsDir = path.join(testDir, 'openspec', 'specs');
|
||||
await fs.mkdir(specsDir, { recursive: true });
|
||||
|
||||
await fs.mkdir(path.join(specsDir, 'spec-1'), { recursive: true });
|
||||
await fs.writeFile(path.join(specsDir, 'spec-1', 'spec.md'), '# Spec 1');
|
||||
|
||||
const firstResult = await shortTTLProvider.getSpecIds();
|
||||
expect(firstResult).toEqual(['spec-1']);
|
||||
|
||||
// Add another spec
|
||||
await fs.mkdir(path.join(specsDir, 'spec-2'), { recursive: true });
|
||||
await fs.writeFile(path.join(specsDir, 'spec-2', 'spec.md'), '# Spec 2');
|
||||
|
||||
// Wait for cache to expire
|
||||
await new Promise(resolve => setTimeout(resolve, 60));
|
||||
|
||||
const secondResult = await shortTTLProvider.getSpecIds();
|
||||
expect(secondResult).toEqual(['spec-1', 'spec-2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllIds', () => {
|
||||
it('should return both change and spec IDs', async () => {
|
||||
const changesDir = path.join(testDir, 'openspec', 'changes');
|
||||
const specsDir = path.join(testDir, 'openspec', 'specs');
|
||||
await fs.mkdir(changesDir, { recursive: true });
|
||||
await fs.mkdir(specsDir, { recursive: true });
|
||||
|
||||
// Create a change
|
||||
await fs.mkdir(path.join(changesDir, 'my-change'), { recursive: true });
|
||||
await fs.writeFile(path.join(changesDir, 'my-change', 'proposal.md'), '# Change');
|
||||
|
||||
// Create a spec
|
||||
await fs.mkdir(path.join(specsDir, 'my-spec'), { recursive: true });
|
||||
await fs.writeFile(path.join(specsDir, 'my-spec', 'spec.md'), '# Spec');
|
||||
|
||||
const result = await provider.getAllIds();
|
||||
expect(result).toEqual({
|
||||
changeIds: ['my-change'],
|
||||
specIds: ['my-spec'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty arrays when no items exist', async () => {
|
||||
const result = await provider.getAllIds();
|
||||
expect(result).toEqual({
|
||||
changeIds: [],
|
||||
specIds: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearCache', () => {
|
||||
it('should clear all cached data', async () => {
|
||||
const changesDir = path.join(testDir, 'openspec', 'changes');
|
||||
await fs.mkdir(changesDir, { recursive: true });
|
||||
|
||||
await fs.mkdir(path.join(changesDir, 'change-1'), { recursive: true });
|
||||
await fs.writeFile(path.join(changesDir, 'change-1', 'proposal.md'), '# Change 1');
|
||||
|
||||
// Populate cache
|
||||
await provider.getChangeIds();
|
||||
|
||||
// Clear cache
|
||||
provider.clearCache();
|
||||
|
||||
// Add new change
|
||||
await fs.mkdir(path.join(changesDir, 'change-2'), { recursive: true });
|
||||
await fs.writeFile(path.join(changesDir, 'change-2', 'proposal.md'), '# Change 2');
|
||||
|
||||
// Should see new data immediately
|
||||
const result = await provider.getChangeIds();
|
||||
expect(result).toEqual(['change-1', 'change-2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCacheStats', () => {
|
||||
it('should report invalid cache when empty', () => {
|
||||
const stats = provider.getCacheStats();
|
||||
expect(stats.changeCache.valid).toBe(false);
|
||||
expect(stats.specCache.valid).toBe(false);
|
||||
expect(stats.changeCache.age).toBeUndefined();
|
||||
expect(stats.specCache.age).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should report valid cache after data is fetched', async () => {
|
||||
const changesDir = path.join(testDir, 'openspec', 'changes');
|
||||
await fs.mkdir(changesDir, { recursive: true });
|
||||
|
||||
await fs.mkdir(path.join(changesDir, 'change-1'), { recursive: true });
|
||||
await fs.writeFile(path.join(changesDir, 'change-1', 'proposal.md'), '# Change 1');
|
||||
|
||||
await provider.getChangeIds();
|
||||
|
||||
const stats = provider.getCacheStats();
|
||||
expect(stats.changeCache.valid).toBe(true);
|
||||
expect(stats.changeCache.age).toBeDefined();
|
||||
expect(stats.changeCache.age).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it('should report invalid cache after TTL expires', async () => {
|
||||
const shortTTLProvider = new CompletionProvider(50, testDir);
|
||||
|
||||
const changesDir = path.join(testDir, 'openspec', 'changes');
|
||||
await fs.mkdir(changesDir, { recursive: true });
|
||||
|
||||
await fs.mkdir(path.join(changesDir, 'change-1'), { recursive: true });
|
||||
await fs.writeFile(path.join(changesDir, 'change-1', 'proposal.md'), '# Change 1');
|
||||
|
||||
await shortTTLProvider.getChangeIds();
|
||||
|
||||
// Wait for cache to expire
|
||||
await new Promise(resolve => setTimeout(resolve, 60));
|
||||
|
||||
const stats = shortTTLProvider.getCacheStats();
|
||||
expect(stats.changeCache.valid).toBe(false);
|
||||
expect(stats.changeCache.age).toBeGreaterThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should use default TTL of 2000ms', async () => {
|
||||
const defaultProvider = new CompletionProvider();
|
||||
expect(defaultProvider).toBeDefined();
|
||||
// We can verify this behavior by checking cache stats after waiting
|
||||
});
|
||||
|
||||
it('should accept custom TTL', async () => {
|
||||
const customProvider = new CompletionProvider(5000, testDir);
|
||||
expect(customProvider).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use process.cwd() as default project root', () => {
|
||||
const defaultProvider = new CompletionProvider();
|
||||
expect(defaultProvider).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ZshGenerator } from '../../../../src/core/completions/generators/zsh-generator.js';
|
||||
import { CommandDefinition } from '../../../../src/core/completions/types.js';
|
||||
|
||||
describe('ZshGenerator', () => {
|
||||
let generator: ZshGenerator;
|
||||
|
||||
beforeEach(() => {
|
||||
generator = new ZshGenerator();
|
||||
});
|
||||
|
||||
describe('interface compliance', () => {
|
||||
it('should have shell property set to "zsh"', () => {
|
||||
expect(generator.shell).toBe('zsh');
|
||||
});
|
||||
|
||||
it('should implement generate method', () => {
|
||||
expect(typeof generator.generate).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generate', () => {
|
||||
it('should generate valid zsh completion script with header', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize OpenSpec',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('#compdef openspec');
|
||||
expect(script).toContain('# Zsh completion script for OpenSpec CLI');
|
||||
expect(script).toContain('_openspec() {');
|
||||
});
|
||||
|
||||
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:Initialize OpenSpec'");
|
||||
expect(script).toContain("'validate:Validate specs'");
|
||||
expect(script).toContain("'show:Show a spec'");
|
||||
});
|
||||
|
||||
it('should generate command completion functions', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize OpenSpec',
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
name: 'validate',
|
||||
description: 'Validate specs',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('_openspec_init() {');
|
||||
expect(script).toContain('_openspec_validate() {');
|
||||
});
|
||||
|
||||
it('should handle commands with flags', () => {
|
||||
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 --requirement)'{-r,--requirement}'[Show specific requirement]:value:'");
|
||||
expect(script).toContain('[Show specific requirement]');
|
||||
});
|
||||
|
||||
it('should handle flags that take 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('[Specify item type]');
|
||||
expect(script).toContain(':value:(change 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');
|
||||
expect(script).toContain('[Max concurrent validations]');
|
||||
expect(script).toContain(':value:');
|
||||
});
|
||||
|
||||
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("'show:Show a change'");
|
||||
expect(script).toContain("'list:List changes'");
|
||||
expect(script).toContain('_openspec_change_show() {');
|
||||
expect(script).toContain('_openspec_change_list() {');
|
||||
});
|
||||
|
||||
it('should handle positional arguments for change-id', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'archive',
|
||||
description: 'Archive a change',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'change-id',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain("'*: :_openspec_complete_changes'");
|
||||
});
|
||||
|
||||
it('should handle positional arguments for spec-id', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'show-spec',
|
||||
description: 'Show a spec',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'spec-id',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain("'*: :_openspec_complete_specs'");
|
||||
});
|
||||
|
||||
it('should handle positional arguments for change-or-spec-id', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'show',
|
||||
description: 'Show an item',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'change-or-spec-id',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain("'*: :_openspec_complete_items'");
|
||||
});
|
||||
|
||||
it('should handle positional arguments for paths', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize OpenSpec',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'path',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain("'*:path:_files'");
|
||||
});
|
||||
|
||||
it('should escape special characters in descriptions', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'test',
|
||||
description: "Test with 'quotes' and [brackets] and back\\slash and colon:",
|
||||
flags: [
|
||||
{
|
||||
name: 'flag',
|
||||
description: "Special chars: 'quotes' [brackets] back\\slash colon:",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain("\\'quotes\\'");
|
||||
expect(script).toContain('\\[brackets\\]');
|
||||
expect(script).toContain('\\\\slash');
|
||||
expect(script).toContain('\\:');
|
||||
});
|
||||
|
||||
it('should sanitize command names with hyphens for function names', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'my-command',
|
||||
description: 'A hyphenated command',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('_openspec_my_command() {');
|
||||
});
|
||||
|
||||
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('_openspec_spec() {');
|
||||
expect(script).toContain('_openspec_spec_validate() {');
|
||||
expect(script).toContain('--strict');
|
||||
expect(script).toContain('--json');
|
||||
expect(script).toContain("'*: :_openspec_complete_specs'");
|
||||
});
|
||||
|
||||
it('should generate script that ends with compdef registration', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script.trim().endsWith('compdef _openspec openspec')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty command list', () => {
|
||||
const commands: CommandDefinition[] = [];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('#compdef openspec');
|
||||
expect(script).toContain('_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('_openspec_view() {');
|
||||
expect(script).toContain('_arguments');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,748 @@
|
||||
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 { ZshInstaller } from '../../../../src/core/completions/installers/zsh-installer.js';
|
||||
|
||||
describe('ZshInstaller', () => {
|
||||
let testHomeDir: string;
|
||||
let installer: ZshInstaller;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a temporary home directory for testing
|
||||
testHomeDir = path.join(os.tmpdir(), `openspec-zsh-test-${randomUUID()}`);
|
||||
await fs.mkdir(testHomeDir, { recursive: true });
|
||||
installer = new ZshInstaller(testHomeDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test directory
|
||||
await fs.rm(testHomeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('isOhMyZshInstalled', () => {
|
||||
it('should return false when Oh My Zsh is not installed', async () => {
|
||||
const isInstalled = await installer.isOhMyZshInstalled();
|
||||
expect(isInstalled).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when Oh My Zsh directory exists', async () => {
|
||||
// Create .oh-my-zsh directory
|
||||
const ohMyZshPath = path.join(testHomeDir, '.oh-my-zsh');
|
||||
await fs.mkdir(ohMyZshPath, { recursive: true });
|
||||
|
||||
const isInstalled = await installer.isOhMyZshInstalled();
|
||||
expect(isInstalled).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when .oh-my-zsh exists but is a file', async () => {
|
||||
// Create .oh-my-zsh as a file instead of directory
|
||||
const ohMyZshPath = path.join(testHomeDir, '.oh-my-zsh');
|
||||
await fs.writeFile(ohMyZshPath, 'not a directory');
|
||||
|
||||
const isInstalled = await installer.isOhMyZshInstalled();
|
||||
expect(isInstalled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInstallationPath', () => {
|
||||
it('should return Oh My Zsh path when Oh My Zsh is installed', async () => {
|
||||
// Create .oh-my-zsh directory
|
||||
const ohMyZshPath = path.join(testHomeDir, '.oh-my-zsh');
|
||||
await fs.mkdir(ohMyZshPath, { recursive: true });
|
||||
|
||||
const result = await installer.getInstallationPath();
|
||||
|
||||
expect(result.isOhMyZsh).toBe(true);
|
||||
expect(result.path).toBe(path.join(testHomeDir, '.oh-my-zsh', 'custom', 'completions', '_openspec'));
|
||||
});
|
||||
|
||||
it('should return standard Zsh path when Oh My Zsh is not installed', async () => {
|
||||
const result = await installer.getInstallationPath();
|
||||
|
||||
expect(result.isOhMyZsh).toBe(false);
|
||||
expect(result.path).toBe(path.join(testHomeDir, '.zsh', '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 = '#compdef openspec\n_openspec() {\n echo "test"\n}\n';
|
||||
|
||||
it('should install to Oh My Zsh path when Oh My Zsh is present', async () => {
|
||||
// Create .oh-my-zsh directory
|
||||
const ohMyZshPath = path.join(testHomeDir, '.oh-my-zsh');
|
||||
await fs.mkdir(ohMyZshPath, { recursive: true });
|
||||
|
||||
const result = await installer.install(testScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.isOhMyZsh).toBe(true);
|
||||
expect(result.installedPath).toBe(path.join(ohMyZshPath, 'custom', 'completions', '_openspec'));
|
||||
expect(result.message).toContain('Oh My Zsh');
|
||||
|
||||
// Verify file was created with correct content
|
||||
const content = await fs.readFile(result.installedPath!, 'utf-8');
|
||||
expect(content).toBe(testScript);
|
||||
});
|
||||
|
||||
it('should install to standard Zsh path when Oh My Zsh is not present', async () => {
|
||||
const result = await installer.install(testScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.isOhMyZsh).toBe(false);
|
||||
expect(result.installedPath).toBe(path.join(testHomeDir, '.zsh', 'completions', '_openspec'));
|
||||
|
||||
// Verify file was created
|
||||
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, '.zsh', '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 include fpath verification guidance for Oh My Zsh', async () => {
|
||||
const ohMyZshPath = path.join(testHomeDir, '.oh-my-zsh');
|
||||
await fs.mkdir(ohMyZshPath, { recursive: true });
|
||||
|
||||
const result = await installer.install(testScript);
|
||||
|
||||
expect(result.instructions).toBeDefined();
|
||||
expect(result.instructions!.length).toBeGreaterThan(0);
|
||||
// Should include guidance about verifying fpath for Oh My Zsh
|
||||
expect(result.instructions!.join(' ')).toContain('fpath');
|
||||
expect(result.instructions!.join(' ')).toContain('custom/completions');
|
||||
});
|
||||
|
||||
it('should include fpath instructions for standard Zsh 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('fpath');
|
||||
expect(result.instructions!.join('\n')).toContain('.zshrc');
|
||||
expect(result.instructions!.join('\n')).toContain('compinit');
|
||||
|
||||
// Restore env
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
} else {
|
||||
process.env.OPENSPEC_NO_AUTO_CONFIG = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle installation errors gracefully', async () => {
|
||||
// Create installer with non-existent/invalid home directory
|
||||
// Use a path that will fail on both Unix and Windows
|
||||
const invalidPath = process.platform === 'win32'
|
||||
? 'Z:\\nonexistent\\invalid\\path' // Non-existent drive letter on Windows
|
||||
: '/root/invalid/nonexistent/path'; // Permission-denied path on Unix
|
||||
const invalidInstaller = new ZshInstaller(invalidPath);
|
||||
|
||||
const result = await invalidInstaller.install(testScript);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Failed to install');
|
||||
});
|
||||
|
||||
it('should detect already-installed completion with identical content', async () => {
|
||||
// First installation
|
||||
const firstResult = await installer.install(testScript);
|
||||
expect(firstResult.success).toBe(true);
|
||||
|
||||
// Second installation with same script
|
||||
const secondResult = await installer.install(testScript);
|
||||
|
||||
expect(secondResult.success).toBe(true);
|
||||
expect(secondResult.message).toContain('already installed');
|
||||
expect(secondResult.message).toContain('up to date');
|
||||
expect(secondResult.backupPath).toBeUndefined();
|
||||
expect(secondResult.instructions).toBeDefined();
|
||||
expect(secondResult.instructions!.join(' ')).toContain('already installed');
|
||||
});
|
||||
|
||||
it('should update completion when content differs', async () => {
|
||||
// First installation
|
||||
const firstScript = '#compdef openspec\n_openspec() {\n echo "version 1"\n}\n';
|
||||
const firstResult = await installer.install(firstScript);
|
||||
expect(firstResult.success).toBe(true);
|
||||
|
||||
// Second installation with different script
|
||||
const secondScript = '#compdef openspec\n_openspec() {\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.message).toContain('backed up');
|
||||
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 .zshrc config', async () => {
|
||||
// Create a test home directory with spaces
|
||||
const testHomeDirWithSpaces = path.join(os.tmpdir(), `openspec zsh test ${randomUUID()}`);
|
||||
await fs.mkdir(testHomeDirWithSpaces, { recursive: true });
|
||||
const installerWithSpaces = new ZshInstaller(testHomeDirWithSpaces);
|
||||
|
||||
try {
|
||||
const result = await installerWithSpaces.install(testScript);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Check if .zshrc was created (when auto-config is enabled)
|
||||
const zshrcPath = path.join(testHomeDirWithSpaces, '.zshrc');
|
||||
try {
|
||||
const zshrcContent = await fs.readFile(zshrcPath, 'utf-8');
|
||||
// Verify the path is quoted in fpath
|
||||
expect(zshrcContent).toContain(`fpath=("${path.dirname(result.installedPath!)}" $fpath)`);
|
||||
} catch {
|
||||
// .zshrc might not exist if auto-config was disabled
|
||||
}
|
||||
} finally {
|
||||
// Clean up
|
||||
await fs.rm(testHomeDirWithSpaces, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstall', () => {
|
||||
const testScript = '#compdef openspec\n_openspec() {}\n';
|
||||
|
||||
it('should remove installed completion script', async () => {
|
||||
// Install first
|
||||
await installer.install(testScript);
|
||||
|
||||
// Verify it's installed
|
||||
const beforeUninstall = await installer.isInstalled();
|
||||
expect(beforeUninstall).toBe(true);
|
||||
|
||||
// Uninstall
|
||||
const result = await installer.uninstall();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('removed');
|
||||
|
||||
// Verify it's gone
|
||||
const afterUninstall = await installer.isInstalled();
|
||||
expect(afterUninstall).toBe(false);
|
||||
});
|
||||
|
||||
it('should return failure when script and .zshrc config are not installed', async () => {
|
||||
// Don't create .zshrc or completion script - nothing to remove
|
||||
const result = await installer.uninstall();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('not installed');
|
||||
});
|
||||
|
||||
it('should remove from correct location for Oh My Zsh', async () => {
|
||||
const ohMyZshPath = path.join(testHomeDir, '.oh-my-zsh');
|
||||
await fs.mkdir(ohMyZshPath, { recursive: true });
|
||||
|
||||
await installer.install(testScript);
|
||||
|
||||
const result = await installer.uninstall();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain(path.join('.oh-my-zsh', 'custom', 'completions', '_openspec'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInstalled', () => {
|
||||
const testScript = '#compdef openspec\n_openspec() {}\n';
|
||||
|
||||
it('should return false when not installed', async () => {
|
||||
const isInstalled = await installer.isInstalled();
|
||||
expect(isInstalled).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when installed', async () => {
|
||||
await installer.install(testScript);
|
||||
|
||||
const isInstalled = await installer.isInstalled();
|
||||
expect(isInstalled).toBe(true);
|
||||
});
|
||||
|
||||
it('should check correct location for Oh My Zsh', async () => {
|
||||
const ohMyZshPath = path.join(testHomeDir, '.oh-my-zsh');
|
||||
await fs.mkdir(ohMyZshPath, { recursive: true });
|
||||
|
||||
await installer.install(testScript);
|
||||
|
||||
const isInstalled = await installer.isInstalled();
|
||||
expect(isInstalled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInstallationInfo', () => {
|
||||
const testScript = '#compdef openspec\n_openspec() {}\n';
|
||||
|
||||
it('should return not installed when script does not exist', async () => {
|
||||
const info = await installer.getInstallationInfo();
|
||||
|
||||
expect(info.installed).toBe(false);
|
||||
expect(info.path).toBeUndefined();
|
||||
expect(info.isOhMyZsh).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return installation info when installed', async () => {
|
||||
await installer.install(testScript);
|
||||
|
||||
const info = await installer.getInstallationInfo();
|
||||
|
||||
expect(info.installed).toBe(true);
|
||||
expect(info.path).toBeDefined();
|
||||
expect(info.path).toContain('_openspec');
|
||||
expect(info.isOhMyZsh).toBe(false);
|
||||
});
|
||||
|
||||
it('should indicate Oh My Zsh when installed there', async () => {
|
||||
const ohMyZshPath = path.join(testHomeDir, '.oh-my-zsh');
|
||||
await fs.mkdir(ohMyZshPath, { recursive: true });
|
||||
|
||||
await installer.install(testScript);
|
||||
|
||||
const info = await installer.getInstallationInfo();
|
||||
|
||||
expect(info.installed).toBe(true);
|
||||
expect(info.isOhMyZsh).toBe(true);
|
||||
expect(info.path).toContain('.oh-my-zsh');
|
||||
});
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should use provided home directory', () => {
|
||||
const customInstaller = new ZshInstaller('/custom/home');
|
||||
expect(customInstaller).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use os.homedir() by default', () => {
|
||||
const defaultInstaller = new ZshInstaller();
|
||||
expect(defaultInstaller).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('configureZshrc', () => {
|
||||
const completionsDir = '/test/.zsh/completions';
|
||||
|
||||
it('should create .zshrc with markers and config when file does not exist', async () => {
|
||||
const result = await installer.configureZshrc(completionsDir);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const zshrcPath = path.join(testHomeDir, '.zshrc');
|
||||
const content = await fs.readFile(zshrcPath, 'utf-8');
|
||||
|
||||
expect(content).toContain('# OPENSPEC:START');
|
||||
expect(content).toContain('# OPENSPEC:END');
|
||||
expect(content).toContain('# OpenSpec shell completions configuration');
|
||||
expect(content).toContain(`fpath=("${completionsDir}" $fpath)`);
|
||||
expect(content).toContain('autoload -Uz compinit');
|
||||
expect(content).toContain('compinit');
|
||||
});
|
||||
|
||||
it('should prepend markers and config when .zshrc exists without markers', async () => {
|
||||
const zshrcPath = path.join(testHomeDir, '.zshrc');
|
||||
await fs.writeFile(zshrcPath, '# My custom zsh config\nalias ll="ls -la"\n');
|
||||
|
||||
const result = await installer.configureZshrc(completionsDir);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const content = await fs.readFile(zshrcPath, 'utf-8');
|
||||
|
||||
expect(content).toContain('# OPENSPEC:START');
|
||||
expect(content).toContain('# OPENSPEC:END');
|
||||
expect(content).toContain('# My custom zsh 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 .zshrc has existing markers', async () => {
|
||||
const zshrcPath = path.join(testHomeDir, '.zshrc');
|
||||
const initialContent = [
|
||||
'# OPENSPEC:START',
|
||||
'# Old config',
|
||||
'fpath=(/old/path $fpath)',
|
||||
'# OPENSPEC:END',
|
||||
'',
|
||||
'# My custom config',
|
||||
].join('\n');
|
||||
|
||||
await fs.writeFile(zshrcPath, initialContent);
|
||||
|
||||
const result = await installer.configureZshrc(completionsDir);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const content = await fs.readFile(zshrcPath, 'utf-8');
|
||||
|
||||
expect(content).toContain('# OPENSPEC:START');
|
||||
expect(content).toContain('# OPENSPEC:END');
|
||||
expect(content).toContain(`fpath=("${completionsDir}" $fpath)`);
|
||||
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 zshrcPath = path.join(testHomeDir, '.zshrc');
|
||||
const userContent = [
|
||||
'# My zsh config',
|
||||
'export PATH="/custom/path:$PATH"',
|
||||
'',
|
||||
'# OPENSPEC:START',
|
||||
'# Old OpenSpec config',
|
||||
'# OPENSPEC:END',
|
||||
'',
|
||||
'alias ls="ls -G"',
|
||||
].join('\n');
|
||||
|
||||
await fs.writeFile(zshrcPath, userContent);
|
||||
|
||||
const result = await installer.configureZshrc(completionsDir);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const content = await fs.readFile(zshrcPath, 'utf-8');
|
||||
|
||||
expect(content).toContain('# My zsh config');
|
||||
expect(content).toContain('export PATH="/custom/path:$PATH"');
|
||||
expect(content).toContain('alias ls="ls -G"');
|
||||
expect(content).toContain(`fpath=("${completionsDir}" $fpath)`);
|
||||
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.configureZshrc(completionsDir);
|
||||
|
||||
expect(result).toBe(false);
|
||||
|
||||
const zshrcPath = path.join(testHomeDir, '.zshrc');
|
||||
const exists = await fs.access(zshrcPath).then(() => true).catch(() => false);
|
||||
expect(exists).toBe(false);
|
||||
|
||||
// Restore env
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
} else {
|
||||
process.env.OPENSPEC_NO_AUTO_CONFIG = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle write permission errors gracefully', async () => {
|
||||
// Create installer with path that can't be written
|
||||
// Use a path that will fail on both Unix and Windows
|
||||
const invalidPath = process.platform === 'win32'
|
||||
? 'Z:\\nonexistent\\invalid\\path' // Non-existent drive letter on Windows
|
||||
: '/root/invalid/path'; // Permission-denied path on Unix
|
||||
const invalidInstaller = new ZshInstaller(invalidPath);
|
||||
|
||||
const result = await invalidInstaller.configureZshrc(completionsDir);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeZshrcConfig', () => {
|
||||
it('should return true when .zshrc does not exist', async () => {
|
||||
const result = await installer.removeZshrcConfig();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when .zshrc exists but has no markers', async () => {
|
||||
const zshrcPath = path.join(testHomeDir, '.zshrc');
|
||||
await fs.writeFile(zshrcPath, '# My custom config\nalias ll="ls -la"\n');
|
||||
|
||||
const result = await installer.removeZshrcConfig();
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Content should be unchanged
|
||||
const content = await fs.readFile(zshrcPath, 'utf-8');
|
||||
expect(content).toBe('# My custom config\nalias ll="ls -la"\n');
|
||||
});
|
||||
|
||||
it('should remove markers and config when present', async () => {
|
||||
const zshrcPath = path.join(testHomeDir, '.zshrc');
|
||||
const content = [
|
||||
'# My config',
|
||||
'',
|
||||
'# OPENSPEC:START',
|
||||
'# OpenSpec shell completions configuration',
|
||||
'fpath=(~/.zsh/completions $fpath)',
|
||||
'autoload -Uz compinit',
|
||||
'compinit',
|
||||
'# OPENSPEC:END',
|
||||
'',
|
||||
'alias ll="ls -la"',
|
||||
].join('\n');
|
||||
|
||||
await fs.writeFile(zshrcPath, content);
|
||||
|
||||
const result = await installer.removeZshrcConfig();
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const newContent = await fs.readFile(zshrcPath, 'utf-8');
|
||||
|
||||
expect(newContent).not.toContain('# OPENSPEC:START');
|
||||
expect(newContent).not.toContain('# OPENSPEC:END');
|
||||
expect(newContent).not.toContain('OpenSpec shell completions');
|
||||
expect(newContent).toContain('# My config');
|
||||
expect(newContent).toContain('alias ll="ls -la"');
|
||||
});
|
||||
|
||||
it('should remove leading empty lines when markers were at top', async () => {
|
||||
const zshrcPath = path.join(testHomeDir, '.zshrc');
|
||||
const content = [
|
||||
'# OPENSPEC:START',
|
||||
'# OpenSpec config',
|
||||
'# OPENSPEC:END',
|
||||
'',
|
||||
'# User config below',
|
||||
].join('\n');
|
||||
|
||||
await fs.writeFile(zshrcPath, content);
|
||||
|
||||
const result = await installer.removeZshrcConfig();
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const newContent = await fs.readFile(zshrcPath, 'utf-8');
|
||||
|
||||
// Should not start with empty lines
|
||||
expect(newContent).toBe('# User config below');
|
||||
});
|
||||
|
||||
it('should handle invalid marker placement gracefully', async () => {
|
||||
const zshrcPath = path.join(testHomeDir, '.zshrc');
|
||||
|
||||
// End marker before start marker
|
||||
await fs.writeFile(zshrcPath, '# OPENSPEC:END\n# OPENSPEC:START\n');
|
||||
|
||||
const result = await installer.removeZshrcConfig();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when only one marker is present', async () => {
|
||||
const zshrcPath = path.join(testHomeDir, '.zshrc');
|
||||
await fs.writeFile(zshrcPath, '# OPENSPEC:START\nsome config\n');
|
||||
|
||||
const result = await installer.removeZshrcConfig();
|
||||
|
||||
// Should return true (markers don't exist as a pair)
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('install with .zshrc auto-configuration', () => {
|
||||
const testScript = '#compdef openspec\n_openspec() {}\n';
|
||||
|
||||
it('should auto-configure .zshrc for standard Zsh', async () => {
|
||||
const result = await installer.install(testScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.zshrcConfigured).toBe(true);
|
||||
|
||||
// Verify .zshrc was created
|
||||
const zshrcPath = path.join(testHomeDir, '.zshrc');
|
||||
const content = await fs.readFile(zshrcPath, 'utf-8');
|
||||
|
||||
expect(content).toContain('# OPENSPEC:START');
|
||||
expect(content).toContain('fpath=');
|
||||
expect(content).toContain('compinit');
|
||||
});
|
||||
|
||||
it('should configure .zshrc for Oh My Zsh when fpath is missing', async () => {
|
||||
const ohMyZshPath = path.join(testHomeDir, '.oh-my-zsh');
|
||||
await fs.mkdir(ohMyZshPath, { recursive: true });
|
||||
|
||||
const result = await installer.install(testScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.isOhMyZsh).toBe(true);
|
||||
// Should configure .zshrc if fpath doesn't already include the directory
|
||||
expect(result.zshrcConfigured).toBe(true);
|
||||
|
||||
// Verify .zshrc was created with fpath configuration
|
||||
const zshrcPath = path.join(testHomeDir, '.zshrc');
|
||||
const exists = await fs.access(zshrcPath).then(() => true).catch(() => false);
|
||||
expect(exists).toBe(true);
|
||||
|
||||
if (exists) {
|
||||
const content = await fs.readFile(zshrcPath, 'utf-8');
|
||||
expect(content).toContain('fpath=');
|
||||
// Check for custom/completions or custom\completions (Windows path separator)
|
||||
expect(content).toMatch(/custom[/\\]completions/);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not include manual instructions when .zshrc was auto-configured', async () => {
|
||||
const result = await installer.install(testScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.zshrcConfigured).toBe(true);
|
||||
expect(result.instructions).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include instructions when .zshrc auto-config fails', async () => {
|
||||
const originalEnv = process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
process.env.OPENSPEC_NO_AUTO_CONFIG = '1';
|
||||
|
||||
const result = await installer.install(testScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.zshrcConfigured).toBe(false);
|
||||
expect(result.instructions).toBeDefined();
|
||||
expect(result.instructions!.join('\n')).toContain('fpath');
|
||||
|
||||
// Restore env
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
} else {
|
||||
process.env.OPENSPEC_NO_AUTO_CONFIG = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it('should update success message when .zshrc is configured', async () => {
|
||||
const result = await installer.install(testScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('.zshrc configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstall with .zshrc cleanup', () => {
|
||||
const testScript = '#compdef openspec\n_openspec() {}\n';
|
||||
|
||||
it('should remove .zshrc config when uninstalling', async () => {
|
||||
// Install first (which creates .zshrc config)
|
||||
await installer.install(testScript);
|
||||
|
||||
// Verify .zshrc was configured
|
||||
const zshrcPath = path.join(testHomeDir, '.zshrc');
|
||||
let content = await fs.readFile(zshrcPath, 'utf-8');
|
||||
expect(content).toContain('# OPENSPEC:START');
|
||||
|
||||
// Uninstall
|
||||
const result = await installer.uninstall();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Removed OpenSpec configuration from ~/.zshrc');
|
||||
|
||||
// Verify .zshrc config was removed
|
||||
content = await fs.readFile(zshrcPath, 'utf-8');
|
||||
expect(content).not.toContain('# OPENSPEC:START');
|
||||
});
|
||||
|
||||
it('should not remove .zshrc config for Oh My Zsh users', async () => {
|
||||
const ohMyZshPath = path.join(testHomeDir, '.oh-my-zsh');
|
||||
await fs.mkdir(ohMyZshPath, { recursive: true });
|
||||
|
||||
await installer.install(testScript);
|
||||
|
||||
const result = await installer.uninstall();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).not.toContain('.zshrc');
|
||||
});
|
||||
|
||||
it('should succeed even if only .zshrc config is removed', async () => {
|
||||
// Manually create .zshrc config without installing completion script
|
||||
const zshrcPath = path.join(testHomeDir, '.zshrc');
|
||||
await fs.writeFile(zshrcPath, '# OPENSPEC:START\nconfig\n# OPENSPEC:END\n');
|
||||
|
||||
const result = await installer.uninstall();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Removed OpenSpec configuration from ~/.zshrc');
|
||||
});
|
||||
|
||||
it('should include both messages when removing script and .zshrc', async () => {
|
||||
await installer.install(testScript);
|
||||
|
||||
const result = await installer.uninstall();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Completion script removed');
|
||||
expect(result.message).toContain('Removed OpenSpec configuration from ~/.zshrc');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
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 {
|
||||
getGlobalConfigDir,
|
||||
getGlobalConfigPath,
|
||||
getGlobalConfig,
|
||||
saveGlobalConfig,
|
||||
GLOBAL_CONFIG_DIR_NAME,
|
||||
GLOBAL_CONFIG_FILE_NAME
|
||||
} from '../../src/core/global-config.js';
|
||||
|
||||
describe('global-config', () => {
|
||||
let tempDir: string;
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create temp directory for tests
|
||||
tempDir = path.join(os.tmpdir(), `openspec-global-config-test-${Date.now()}`);
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
// Save original env
|
||||
originalEnv = { ...process.env };
|
||||
|
||||
// Spy on console.error for warning tests
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original env
|
||||
process.env = originalEnv;
|
||||
|
||||
// Clean up temp directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
|
||||
// Restore console.error
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('constants', () => {
|
||||
it('should export correct directory name', () => {
|
||||
expect(GLOBAL_CONFIG_DIR_NAME).toBe('openspec');
|
||||
});
|
||||
|
||||
it('should export correct file name', () => {
|
||||
expect(GLOBAL_CONFIG_FILE_NAME).toBe('config.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGlobalConfigDir', () => {
|
||||
it('should use XDG_CONFIG_HOME when set', () => {
|
||||
process.env.XDG_CONFIG_HOME = tempDir;
|
||||
|
||||
const result = getGlobalConfigDir();
|
||||
|
||||
expect(result).toBe(path.join(tempDir, 'openspec'));
|
||||
});
|
||||
|
||||
it('should fall back to ~/.config on Unix/macOS without XDG_CONFIG_HOME', () => {
|
||||
delete process.env.XDG_CONFIG_HOME;
|
||||
|
||||
const result = getGlobalConfigDir();
|
||||
|
||||
// On non-Windows, should use ~/.config/openspec
|
||||
if (os.platform() !== 'win32') {
|
||||
expect(result).toBe(path.join(os.homedir(), '.config', 'openspec'));
|
||||
}
|
||||
});
|
||||
|
||||
it('should use APPDATA on Windows when XDG_CONFIG_HOME is not set', () => {
|
||||
// This test only makes sense conceptually - we can't change os.platform()
|
||||
// But we can verify the APPDATA logic by checking the code path
|
||||
if (os.platform() === 'win32') {
|
||||
delete process.env.XDG_CONFIG_HOME;
|
||||
const appData = process.env.APPDATA;
|
||||
if (appData) {
|
||||
const result = getGlobalConfigDir();
|
||||
expect(result).toBe(path.join(appData, 'openspec'));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGlobalConfigPath', () => {
|
||||
it('should return path to config.json in config directory', () => {
|
||||
process.env.XDG_CONFIG_HOME = tempDir;
|
||||
|
||||
const result = getGlobalConfigPath();
|
||||
|
||||
expect(result).toBe(path.join(tempDir, 'openspec', 'config.json'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGlobalConfig', () => {
|
||||
it('should return defaults when config file does not exist', () => {
|
||||
process.env.XDG_CONFIG_HOME = tempDir;
|
||||
|
||||
const config = getGlobalConfig();
|
||||
|
||||
expect(config).toEqual({ featureFlags: {} });
|
||||
});
|
||||
|
||||
it('should not create directory when reading non-existent config', () => {
|
||||
process.env.XDG_CONFIG_HOME = tempDir;
|
||||
const configDir = path.join(tempDir, 'openspec');
|
||||
|
||||
getGlobalConfig();
|
||||
|
||||
expect(fs.existsSync(configDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('should load valid config from file', () => {
|
||||
process.env.XDG_CONFIG_HOME = tempDir;
|
||||
const configDir = path.join(tempDir, 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
featureFlags: { testFlag: true, anotherFlag: false }
|
||||
}));
|
||||
|
||||
const config = getGlobalConfig();
|
||||
|
||||
expect(config.featureFlags).toEqual({ testFlag: true, anotherFlag: false });
|
||||
});
|
||||
|
||||
it('should return defaults for invalid JSON', () => {
|
||||
process.env.XDG_CONFIG_HOME = tempDir;
|
||||
const configDir = path.join(tempDir, 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, '{ invalid json }');
|
||||
|
||||
const config = getGlobalConfig();
|
||||
|
||||
expect(config).toEqual({ featureFlags: {} });
|
||||
});
|
||||
|
||||
it('should log warning for invalid JSON', () => {
|
||||
process.env.XDG_CONFIG_HOME = tempDir;
|
||||
const configDir = path.join(tempDir, 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, '{ invalid json }');
|
||||
|
||||
getGlobalConfig();
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Invalid JSON')
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve unknown fields from config file', () => {
|
||||
process.env.XDG_CONFIG_HOME = tempDir;
|
||||
const configDir = path.join(tempDir, 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
featureFlags: { x: true },
|
||||
unknownField: 'preserved',
|
||||
futureOption: 123
|
||||
}));
|
||||
|
||||
const config = getGlobalConfig();
|
||||
|
||||
expect((config as any).unknownField).toBe('preserved');
|
||||
expect((config as any).futureOption).toBe(123);
|
||||
});
|
||||
|
||||
it('should merge loaded config with defaults', () => {
|
||||
process.env.XDG_CONFIG_HOME = tempDir;
|
||||
const configDir = path.join(tempDir, 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
// Config with only some fields
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
featureFlags: { customFlag: true }
|
||||
}));
|
||||
|
||||
const config = getGlobalConfig();
|
||||
|
||||
// Should have the custom flag
|
||||
expect(config.featureFlags?.customFlag).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveGlobalConfig', () => {
|
||||
it('should create directory if it does not exist', () => {
|
||||
process.env.XDG_CONFIG_HOME = tempDir;
|
||||
const configDir = path.join(tempDir, 'openspec');
|
||||
|
||||
saveGlobalConfig({ featureFlags: { test: true } });
|
||||
|
||||
expect(fs.existsSync(configDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('should write config to file', () => {
|
||||
process.env.XDG_CONFIG_HOME = tempDir;
|
||||
const configPath = path.join(tempDir, 'openspec', 'config.json');
|
||||
|
||||
saveGlobalConfig({ featureFlags: { myFlag: true } });
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.featureFlags.myFlag).toBe(true);
|
||||
});
|
||||
|
||||
it('should overwrite existing config file', () => {
|
||||
process.env.XDG_CONFIG_HOME = tempDir;
|
||||
const configDir = path.join(tempDir, 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
// Create initial config
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({ featureFlags: { old: true } }));
|
||||
|
||||
// Overwrite
|
||||
saveGlobalConfig({ featureFlags: { new: true } });
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.featureFlags.new).toBe(true);
|
||||
expect(parsed.featureFlags.old).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should write formatted JSON with trailing newline', () => {
|
||||
process.env.XDG_CONFIG_HOME = tempDir;
|
||||
const configPath = path.join(tempDir, 'openspec', 'config.json');
|
||||
|
||||
saveGlobalConfig({ featureFlags: {} });
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
expect(content).toContain('\n');
|
||||
expect(content.endsWith('\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('should round-trip config correctly', () => {
|
||||
process.env.XDG_CONFIG_HOME = tempDir;
|
||||
const originalConfig = {
|
||||
featureFlags: { flag1: true, flag2: false }
|
||||
};
|
||||
|
||||
saveGlobalConfig(originalConfig);
|
||||
const loadedConfig = getGlobalConfig();
|
||||
|
||||
expect(loadedConfig.featureFlags).toEqual(originalConfig.featureFlags);
|
||||
});
|
||||
});
|
||||
});
|
||||
+776
-4
@@ -50,7 +50,7 @@ describe('InitCommand', () => {
|
||||
process.env.CODEX_HOME = path.join(testDir, '.codex');
|
||||
|
||||
// Mock console.log to suppress output during tests
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'log').mockImplementation(() => { });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -136,6 +136,39 @@ describe('InitCommand', () => {
|
||||
expect(updatedContent).toContain('Custom instructions here');
|
||||
});
|
||||
|
||||
it('should create CLINE.md when Cline is selected', async () => {
|
||||
queueSelections('cline', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const clinePath = path.join(testDir, 'CLINE.md');
|
||||
expect(await fileExists(clinePath)).toBe(true);
|
||||
|
||||
const content = await fs.readFile(clinePath, 'utf-8');
|
||||
expect(content).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(content).toContain("@/openspec/AGENTS.md");
|
||||
expect(content).toContain('openspec update');
|
||||
expect(content).toContain('<!-- OPENSPEC:END -->');
|
||||
});
|
||||
|
||||
it('should update existing CLINE.md with markers', async () => {
|
||||
queueSelections('cline', DONE);
|
||||
|
||||
const clinePath = path.join(testDir, 'CLINE.md');
|
||||
const existingContent =
|
||||
'# My Cline Rules\nCustom Cline instructions here';
|
||||
await fs.writeFile(clinePath, existingContent);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const updatedContent = await fs.readFile(clinePath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain("@/openspec/AGENTS.md");
|
||||
expect(updatedContent).toContain('openspec update');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).toContain('Custom Cline instructions here');
|
||||
});
|
||||
|
||||
it('should create Windsurf workflows when Windsurf is selected', async () => {
|
||||
queueSelections('windsurf', DONE);
|
||||
|
||||
@@ -180,6 +213,50 @@ describe('InitCommand', () => {
|
||||
expect(archiveContent).toContain('Run `openspec archive <id> --yes`');
|
||||
});
|
||||
|
||||
it('should create Antigravity workflows when Antigravity is selected', async () => {
|
||||
queueSelections('antigravity', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const agProposal = path.join(
|
||||
testDir,
|
||||
'.agent/workflows/openspec-proposal.md'
|
||||
);
|
||||
const agApply = path.join(
|
||||
testDir,
|
||||
'.agent/workflows/openspec-apply.md'
|
||||
);
|
||||
const agArchive = path.join(
|
||||
testDir,
|
||||
'.agent/workflows/openspec-archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(agProposal)).toBe(true);
|
||||
expect(await fileExists(agApply)).toBe(true);
|
||||
expect(await fileExists(agArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(agProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('---');
|
||||
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(proposalContent).toContain('**Guardrails**');
|
||||
expect(proposalContent).not.toContain('auto_execution_mode');
|
||||
|
||||
const applyContent = await fs.readFile(agApply, 'utf-8');
|
||||
expect(applyContent).toContain('---');
|
||||
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
|
||||
expect(applyContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
expect(applyContent).not.toContain('auto_execution_mode');
|
||||
|
||||
const archiveContent = await fs.readFile(agArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('---');
|
||||
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
|
||||
expect(archiveContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(archiveContent).toContain('Run `openspec archive <id> --yes`');
|
||||
expect(archiveContent).not.toContain('auto_execution_mode');
|
||||
});
|
||||
|
||||
it('should always create AGENTS.md in project root', async () => {
|
||||
queueSelections(DONE);
|
||||
|
||||
@@ -272,6 +349,126 @@ describe('InitCommand', () => {
|
||||
expect(archiveContent).toContain('openspec list --specs');
|
||||
});
|
||||
|
||||
it('should create Gemini CLI TOML files when selected', async () => {
|
||||
queueSelections('gemini', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const geminiProposal = path.join(
|
||||
testDir,
|
||||
'.gemini/commands/openspec/proposal.toml'
|
||||
);
|
||||
const geminiApply = path.join(
|
||||
testDir,
|
||||
'.gemini/commands/openspec/apply.toml'
|
||||
);
|
||||
const geminiArchive = path.join(
|
||||
testDir,
|
||||
'.gemini/commands/openspec/archive.toml'
|
||||
);
|
||||
|
||||
expect(await fileExists(geminiProposal)).toBe(true);
|
||||
expect(await fileExists(geminiApply)).toBe(true);
|
||||
expect(await fileExists(geminiArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(geminiProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('description = "Scaffold a new OpenSpec change and validate strictly."');
|
||||
expect(proposalContent).toContain('prompt = """');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(proposalContent).toContain('**Guardrails**');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:END -->');
|
||||
|
||||
const applyContent = await fs.readFile(geminiApply, 'utf-8');
|
||||
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(geminiArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('description = "Archive a deployed OpenSpec change and update specs."');
|
||||
expect(archiveContent).toContain('openspec archive <id>');
|
||||
});
|
||||
|
||||
it('should update existing Gemini CLI TOML files with refreshed content', async () => {
|
||||
queueSelections('gemini', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const geminiProposal = path.join(
|
||||
testDir,
|
||||
'.gemini/commands/openspec/proposal.toml'
|
||||
);
|
||||
|
||||
// Modify the file to simulate user customization
|
||||
const originalContent = await fs.readFile(geminiProposal, 'utf-8');
|
||||
const modifiedContent = originalContent.replace(
|
||||
'<!-- OPENSPEC:START -->',
|
||||
'<!-- OPENSPEC:START -->\nCustom instruction added by user\n'
|
||||
);
|
||||
await fs.writeFile(geminiProposal, modifiedContent);
|
||||
|
||||
// Run init again to test update/refresh path
|
||||
queueSelections('gemini', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const updatedContent = await fs.readFile(geminiProposal, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain('**Guardrails**');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).not.toContain('Custom instruction added by user');
|
||||
});
|
||||
|
||||
it('should create IFlow CLI slash command files with templates', async () => {
|
||||
queueSelections('iflow', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const iflowProposal = path.join(
|
||||
testDir,
|
||||
'.iflow/commands/openspec-proposal.md'
|
||||
);
|
||||
const iflowApply = path.join(
|
||||
testDir,
|
||||
'.iflow/commands/openspec-apply.md'
|
||||
);
|
||||
const iflowArchive = path.join(
|
||||
testDir,
|
||||
'.iflow/commands/openspec-archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(iflowProposal)).toBe(true);
|
||||
expect(await fileExists(iflowApply)).toBe(true);
|
||||
expect(await fileExists(iflowArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(iflowProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(proposalContent).toContain('**Guardrails**');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:END -->');
|
||||
|
||||
const applyContent = await fs.readFile(iflowApply, 'utf-8');
|
||||
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(iflowArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
|
||||
expect(archiveContent).toContain('openspec archive <id>');
|
||||
});
|
||||
|
||||
it('should update existing IFLOW.md with markers', async () => {
|
||||
queueSelections('iflow', DONE);
|
||||
|
||||
const iflowPath = path.join(testDir, 'IFLOW.md');
|
||||
const existingContent = '# My IFLOW Instructions\nCustom instructions here';
|
||||
await fs.writeFile(iflowPath, existingContent);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const updatedContent = await fs.readFile(iflowPath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain("@/openspec/AGENTS.md");
|
||||
expect(updatedContent).toContain('openspec update');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).toContain('Custom instructions here');
|
||||
});
|
||||
|
||||
it('should create OpenCode slash command files with templates', async () => {
|
||||
queueSelections('opencode', DONE);
|
||||
|
||||
@@ -295,27 +492,126 @@ describe('InitCommand', () => {
|
||||
expect(await fileExists(openCodeArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(openCodeProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('agent: build');
|
||||
expect(proposalContent).not.toContain('agent:');
|
||||
expect(proposalContent).toContain(
|
||||
'description: Scaffold a new OpenSpec change and validate strictly.'
|
||||
);
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
|
||||
const applyContent = await fs.readFile(openCodeApply, 'utf-8');
|
||||
expect(applyContent).toContain('agent: build');
|
||||
expect(applyContent).not.toContain('agent:');
|
||||
expect(applyContent).toContain(
|
||||
'description: Implement an approved OpenSpec change and keep tasks in sync.'
|
||||
);
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
|
||||
const archiveContent = await fs.readFile(openCodeArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('agent: build');
|
||||
expect(archiveContent).not.toContain('agent:');
|
||||
expect(archiveContent).toContain(
|
||||
'description: Archive a deployed OpenSpec change and update specs.'
|
||||
);
|
||||
expect(archiveContent).toContain('openspec list --specs');
|
||||
});
|
||||
|
||||
it('should create Qwen configuration and slash command files with templates', async () => {
|
||||
queueSelections('qwen', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const qwenConfigPath = path.join(testDir, 'QWEN.md');
|
||||
const proposalPath = path.join(
|
||||
testDir,
|
||||
'.qwen/commands/openspec-proposal.toml'
|
||||
);
|
||||
const applyPath = path.join(
|
||||
testDir,
|
||||
'.qwen/commands/openspec-apply.toml'
|
||||
);
|
||||
const archivePath = path.join(
|
||||
testDir,
|
||||
'.qwen/commands/openspec-archive.toml'
|
||||
);
|
||||
|
||||
expect(await fileExists(qwenConfigPath)).toBe(true);
|
||||
expect(await fileExists(proposalPath)).toBe(true);
|
||||
expect(await fileExists(applyPath)).toBe(true);
|
||||
expect(await fileExists(archivePath)).toBe(true);
|
||||
|
||||
const qwenConfigContent = await fs.readFile(qwenConfigPath, 'utf-8');
|
||||
expect(qwenConfigContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(qwenConfigContent).toContain("@/openspec/AGENTS.md");
|
||||
expect(qwenConfigContent).toContain('<!-- OPENSPEC:END -->');
|
||||
|
||||
const proposalContent = await fs.readFile(proposalPath, 'utf-8');
|
||||
expect(proposalContent).toContain('description = "Scaffold a new OpenSpec change and validate strictly."');
|
||||
expect(proposalContent).toContain('prompt = """');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
|
||||
const applyContent = await fs.readFile(applyPath, 'utf-8');
|
||||
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(archivePath, 'utf-8');
|
||||
expect(archiveContent).toContain('description = "Archive a deployed OpenSpec change and update specs."');
|
||||
expect(archiveContent).toContain('openspec archive <id>');
|
||||
});
|
||||
|
||||
it('should update existing QWEN.md with markers', async () => {
|
||||
queueSelections('qwen', DONE);
|
||||
|
||||
const qwenPath = path.join(testDir, 'QWEN.md');
|
||||
const existingContent = '# My Qwen Instructions\nCustom instructions here';
|
||||
await fs.writeFile(qwenPath, existingContent);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const updatedContent = await fs.readFile(qwenPath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain("@/openspec/AGENTS.md");
|
||||
expect(updatedContent).toContain('openspec update');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).toContain('Custom instructions here');
|
||||
});
|
||||
|
||||
it('should create Cline workflow files with templates', async () => {
|
||||
queueSelections('cline', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const clineProposal = path.join(
|
||||
testDir,
|
||||
'.clinerules/workflows/openspec-proposal.md'
|
||||
);
|
||||
const clineApply = path.join(
|
||||
testDir,
|
||||
'.clinerules/workflows/openspec-apply.md'
|
||||
);
|
||||
const clineArchive = path.join(
|
||||
testDir,
|
||||
'.clinerules/workflows/openspec-archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(clineProposal)).toBe(true);
|
||||
expect(await fileExists(clineApply)).toBe(true);
|
||||
expect(await fileExists(clineArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(clineProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('# OpenSpec: Proposal');
|
||||
expect(proposalContent).toContain('Scaffold a new OpenSpec change and validate strictly.');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(proposalContent).toContain('**Guardrails**');
|
||||
|
||||
const applyContent = await fs.readFile(clineApply, 'utf-8');
|
||||
expect(applyContent).toContain('# OpenSpec: Apply');
|
||||
expect(applyContent).toContain('Implement an approved OpenSpec change and keep tasks in sync.');
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
|
||||
const archiveContent = await fs.readFile(clineArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('# OpenSpec: Archive');
|
||||
expect(archiveContent).toContain('Archive a deployed OpenSpec change and update specs.');
|
||||
expect(archiveContent).toContain('openspec archive <id>');
|
||||
});
|
||||
|
||||
it('should create Factory slash command files with templates', async () => {
|
||||
queueSelections('factory', DONE);
|
||||
|
||||
@@ -507,6 +803,44 @@ describe('InitCommand', () => {
|
||||
await expect(initCommand.execute(testDir)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should recreate deleted openspec/AGENTS.md in extend mode', async () => {
|
||||
await testFileRecreationInExtendMode(
|
||||
testDir,
|
||||
initCommand,
|
||||
'openspec/AGENTS.md',
|
||||
'OpenSpec Instructions'
|
||||
);
|
||||
});
|
||||
|
||||
it('should recreate deleted openspec/project.md in extend mode', async () => {
|
||||
await testFileRecreationInExtendMode(
|
||||
testDir,
|
||||
initCommand,
|
||||
'openspec/project.md',
|
||||
'Project Context'
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve existing template files in extend mode', async () => {
|
||||
queueSelections('claude', DONE, DONE);
|
||||
|
||||
// First init
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const agentsPath = path.join(testDir, 'openspec', 'AGENTS.md');
|
||||
const customContent = '# My Custom AGENTS Content\nDo not overwrite this!';
|
||||
|
||||
// Modify the file with custom content
|
||||
await fs.writeFile(agentsPath, customContent);
|
||||
|
||||
// Run init again - should NOT overwrite
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const content = await fs.readFile(agentsPath, 'utf-8');
|
||||
expect(content).toBe(customContent);
|
||||
expect(content).not.toContain('OpenSpec Instructions');
|
||||
});
|
||||
|
||||
it('should handle non-existent target directory', async () => {
|
||||
queueSelections('claude', DONE);
|
||||
|
||||
@@ -578,6 +912,18 @@ describe('InitCommand', () => {
|
||||
expect(claudeChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should mark Qwen as already configured during extend mode', async () => {
|
||||
queueSelections('qwen', DONE, 'qwen', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const secondRunArgs = mockPrompt.mock.calls[1][0];
|
||||
const qwenChoice = secondRunArgs.choices.find(
|
||||
(choice: any) => choice.value === 'qwen'
|
||||
);
|
||||
expect(qwenChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should preselect Kilo Code when workflows already exist', async () => {
|
||||
queueSelections('kilocode', DONE, 'kilocode', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
@@ -600,6 +946,18 @@ describe('InitCommand', () => {
|
||||
expect(wsChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should mark Antigravity as already configured during extend mode', async () => {
|
||||
queueSelections('antigravity', DONE, 'antigravity', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const secondRunArgs = mockPrompt.mock.calls[1][0];
|
||||
const antigravityChoice = secondRunArgs.choices.find(
|
||||
(choice: any) => choice.value === 'antigravity'
|
||||
);
|
||||
expect(antigravityChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should mark Codex as already configured during extend mode', async () => {
|
||||
queueSelections('codex', DONE, 'codex', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
@@ -738,6 +1096,94 @@ describe('InitCommand', () => {
|
||||
expect(auggieChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should create CodeBuddy slash command files with templates', async () => {
|
||||
queueSelections('codebuddy', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const codeBuddyProposal = path.join(
|
||||
testDir,
|
||||
'.codebuddy/commands/openspec/proposal.md'
|
||||
);
|
||||
const codeBuddyApply = path.join(
|
||||
testDir,
|
||||
'.codebuddy/commands/openspec/apply.md'
|
||||
);
|
||||
const codeBuddyArchive = path.join(
|
||||
testDir,
|
||||
'.codebuddy/commands/openspec/archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(codeBuddyProposal)).toBe(true);
|
||||
expect(await fileExists(codeBuddyApply)).toBe(true);
|
||||
expect(await fileExists(codeBuddyArchive)).toBe(true);
|
||||
|
||||
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('<!-- 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('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('openspec archive <id> --yes');
|
||||
});
|
||||
|
||||
it('should mark CodeBuddy as already configured during extend mode', async () => {
|
||||
queueSelections('codebuddy', DONE, 'codebuddy', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const secondRunArgs = mockPrompt.mock.calls[1][0];
|
||||
const codeBuddyChoice = secondRunArgs.choices.find(
|
||||
(choice: any) => choice.value === 'codebuddy'
|
||||
);
|
||||
expect(codeBuddyChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should create CODEBUDDY.md when CodeBuddy is selected', async () => {
|
||||
queueSelections('codebuddy', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const codeBuddyPath = path.join(testDir, 'CODEBUDDY.md');
|
||||
expect(await fileExists(codeBuddyPath)).toBe(true);
|
||||
|
||||
const content = await fs.readFile(codeBuddyPath, 'utf-8');
|
||||
expect(content).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(content).toContain("@/openspec/AGENTS.md");
|
||||
expect(content).toContain('openspec update');
|
||||
expect(content).toContain('<!-- OPENSPEC:END -->');
|
||||
});
|
||||
|
||||
it('should update existing CODEBUDDY.md with markers', async () => {
|
||||
queueSelections('codebuddy', DONE);
|
||||
|
||||
const codeBuddyPath = path.join(testDir, 'CODEBUDDY.md');
|
||||
const existingContent =
|
||||
'# My CodeBuddy Instructions\nCustom instructions here';
|
||||
await fs.writeFile(codeBuddyPath, existingContent);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const updatedContent = await fs.readFile(codeBuddyPath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain("@/openspec/AGENTS.md");
|
||||
expect(updatedContent).toContain('openspec update');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).toContain('Custom instructions here');
|
||||
});
|
||||
|
||||
it('should create Crush slash command files with templates', async () => {
|
||||
queueSelections('crush', DONE);
|
||||
|
||||
@@ -797,6 +1243,225 @@ describe('InitCommand', () => {
|
||||
);
|
||||
expect(crushChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should create CoStrict slash command files with templates', async () => {
|
||||
queueSelections('costrict', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const costrictProposal = path.join(
|
||||
testDir,
|
||||
'.cospec/openspec/commands/openspec-proposal.md'
|
||||
);
|
||||
const costrictApply = path.join(
|
||||
testDir,
|
||||
'.cospec/openspec/commands/openspec-apply.md'
|
||||
);
|
||||
const costrictArchive = path.join(
|
||||
testDir,
|
||||
'.cospec/openspec/commands/openspec-archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(costrictProposal)).toBe(true);
|
||||
expect(await fileExists(costrictApply)).toBe(true);
|
||||
expect(await fileExists(costrictArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(costrictProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('---');
|
||||
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(costrictApply, 'utf-8');
|
||||
expect(applyContent).toContain('---');
|
||||
expect(applyContent).toContain('description: "Implement an approved OpenSpec change and keep tasks in sync."');
|
||||
expect(applyContent).toContain('argument-hint: change-id');
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
|
||||
const archiveContent = await fs.readFile(costrictArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('---');
|
||||
expect(archiveContent).toContain('description: "Archive a deployed OpenSpec change and update specs."');
|
||||
expect(archiveContent).toContain('argument-hint: change-id');
|
||||
expect(archiveContent).toContain('openspec archive <id> --yes');
|
||||
});
|
||||
|
||||
it('should mark CoStrict as already configured during extend mode', async () => {
|
||||
queueSelections('costrict', DONE, 'costrict', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const secondRunArgs = mockPrompt.mock.calls[1][0];
|
||||
const costrictChoice = secondRunArgs.choices.find(
|
||||
(choice: any) => choice.value === 'costrict'
|
||||
);
|
||||
expect(costrictChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should create RooCode slash command files with templates', async () => {
|
||||
queueSelections('roocode', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const rooProposal = path.join(
|
||||
testDir,
|
||||
'.roo/commands/openspec-proposal.md'
|
||||
);
|
||||
const rooApply = path.join(
|
||||
testDir,
|
||||
'.roo/commands/openspec-apply.md'
|
||||
);
|
||||
const rooArchive = path.join(
|
||||
testDir,
|
||||
'.roo/commands/openspec-archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(rooProposal)).toBe(true);
|
||||
expect(await fileExists(rooApply)).toBe(true);
|
||||
expect(await fileExists(rooArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(rooProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('# OpenSpec: Proposal');
|
||||
expect(proposalContent).toContain('**Guardrails**');
|
||||
|
||||
const applyContent = await fs.readFile(rooApply, 'utf-8');
|
||||
expect(applyContent).toContain('# OpenSpec: Apply');
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
|
||||
const archiveContent = await fs.readFile(rooArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('# OpenSpec: Archive');
|
||||
expect(archiveContent).toContain('openspec archive <id> --yes');
|
||||
});
|
||||
|
||||
it('should mark RooCode as already configured during extend mode', async () => {
|
||||
queueSelections('roocode', DONE, 'roocode', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const secondRunArgs = mockPrompt.mock.calls[1][0];
|
||||
const rooChoice = secondRunArgs.choices.find(
|
||||
(choice: any) => choice.value === 'roocode'
|
||||
);
|
||||
expect(rooChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should create Qoder slash command files with templates', async () => {
|
||||
queueSelections('qoder', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const qoderProposal = path.join(
|
||||
testDir,
|
||||
'.qoder/commands/openspec/proposal.md'
|
||||
);
|
||||
const qoderApply = path.join(
|
||||
testDir,
|
||||
'.qoder/commands/openspec/apply.md'
|
||||
);
|
||||
const qoderArchive = path.join(
|
||||
testDir,
|
||||
'.qoder/commands/openspec/archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(qoderProposal)).toBe(true);
|
||||
expect(await fileExists(qoderApply)).toBe(true);
|
||||
expect(await fileExists(qoderArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(qoderProposal, '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('<!-- OPENSPEC:START -->');
|
||||
expect(proposalContent).toContain('**Guardrails**');
|
||||
|
||||
const applyContent = await fs.readFile(qoderApply, '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('Work through tasks sequentially');
|
||||
|
||||
const archiveContent = await fs.readFile(qoderArchive, '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('openspec archive <id> --yes');
|
||||
});
|
||||
|
||||
it('should mark Qoder as already configured during extend mode', async () => {
|
||||
queueSelections('qoder', DONE, 'qoder', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const secondRunArgs = mockPrompt.mock.calls[1][0];
|
||||
const qoderChoice = secondRunArgs.choices.find(
|
||||
(choice: any) => choice.value === 'qoder'
|
||||
);
|
||||
expect(qoderChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should create COSTRICT.md when CoStrict is selected', async () => {
|
||||
queueSelections('costrict', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const costrictPath = path.join(testDir, 'COSTRICT.md');
|
||||
expect(await fileExists(costrictPath)).toBe(true);
|
||||
|
||||
const content = await fs.readFile(costrictPath, 'utf-8');
|
||||
expect(content).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(content).toContain("@/openspec/AGENTS.md");
|
||||
expect(content).toContain('openspec update');
|
||||
expect(content).toContain('<!-- OPENSPEC:END -->');
|
||||
});
|
||||
|
||||
it('should create QODER.md when Qoder is selected', async () => {
|
||||
queueSelections('qoder', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const qoderPath = path.join(testDir, 'QODER.md');
|
||||
expect(await fileExists(qoderPath)).toBe(true);
|
||||
|
||||
const content = await fs.readFile(qoderPath, 'utf-8');
|
||||
expect(content).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(content).toContain("@/openspec/AGENTS.md");
|
||||
expect(content).toContain('openspec update');
|
||||
expect(content).toContain('<!-- OPENSPEC:END -->');
|
||||
});
|
||||
it('should update existing COSTRICT.md with markers', async () => {
|
||||
queueSelections('costrict', DONE);
|
||||
|
||||
const costrictPath = path.join(testDir, 'COSTRICT.md');
|
||||
const existingContent =
|
||||
'# My CoStrict Instructions\nCustom instructions here';
|
||||
await fs.writeFile(costrictPath, existingContent);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const updatedContent = await fs.readFile(costrictPath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain('# My CoStrict Instructions');
|
||||
expect(updatedContent).toContain('Custom instructions here');
|
||||
});
|
||||
|
||||
it('should update existing QODER.md with markers', async () => {
|
||||
queueSelections('qoder', DONE);
|
||||
|
||||
const qoderPath = path.join(testDir, 'QODER.md');
|
||||
const existingContent =
|
||||
'# My Qoder Instructions\nCustom instructions here';
|
||||
await fs.writeFile(qoderPath, existingContent);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const updatedContent = await fs.readFile(qoderPath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain("@/openspec/AGENTS.md");
|
||||
expect(updatedContent).toContain('openspec update');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).toContain('Custom instructions here');
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-interactive mode', () => {
|
||||
@@ -891,6 +1556,87 @@ describe('InitCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('already configured detection', () => {
|
||||
it('should NOT show tools as already configured in fresh project with existing CLAUDE.md', async () => {
|
||||
// Simulate user having their own CLAUDE.md before running openspec init
|
||||
const claudePath = path.join(testDir, 'CLAUDE.md');
|
||||
await fs.writeFile(claudePath, '# My Custom Claude Instructions\n');
|
||||
|
||||
queueSelections('claude', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
// In the first run (non-interactive mode via queueSelections),
|
||||
// the prompt is called with configured: false for claude
|
||||
const firstCallArgs = mockPrompt.mock.calls[0][0];
|
||||
const claudeChoice = firstCallArgs.choices.find(
|
||||
(choice: any) => choice.value === 'claude'
|
||||
);
|
||||
|
||||
expect(claudeChoice.configured).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT show tools as already configured in fresh project with existing slash commands', async () => {
|
||||
// Simulate user having their own custom slash commands
|
||||
const customCommandDir = path.join(testDir, '.claude/commands/custom');
|
||||
await fs.mkdir(customCommandDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(customCommandDir, 'mycommand.md'),
|
||||
'# My Custom Command\n'
|
||||
);
|
||||
|
||||
queueSelections('claude', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const firstCallArgs = mockPrompt.mock.calls[0][0];
|
||||
const claudeChoice = firstCallArgs.choices.find(
|
||||
(choice: any) => choice.value === 'claude'
|
||||
);
|
||||
|
||||
expect(claudeChoice.configured).toBe(false);
|
||||
});
|
||||
|
||||
it('should show tools as already configured in extend mode', async () => {
|
||||
// First initialization
|
||||
queueSelections('claude', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
// Second initialization (extend mode)
|
||||
queueSelections('cursor', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const secondCallArgs = mockPrompt.mock.calls[1][0];
|
||||
const claudeChoice = secondCallArgs.choices.find(
|
||||
(choice: any) => choice.value === 'claude'
|
||||
);
|
||||
|
||||
expect(claudeChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT show already configured for Codex in fresh init even with global prompts', async () => {
|
||||
// Create global Codex prompts (simulating previous installation)
|
||||
const codexPromptsDir = path.join(testDir, '.codex/prompts');
|
||||
await fs.mkdir(codexPromptsDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(codexPromptsDir, 'openspec-proposal.md'),
|
||||
'# Existing prompt\n'
|
||||
);
|
||||
|
||||
queueSelections('claude', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const firstCallArgs = mockPrompt.mock.calls[0][0];
|
||||
const codexChoice = firstCallArgs.choices.find(
|
||||
(choice: any) => choice.value === 'codex'
|
||||
);
|
||||
|
||||
// In fresh init, even global tools should not show as configured
|
||||
expect(codexChoice.configured).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should provide helpful error for insufficient permissions', async () => {
|
||||
// This is tricky to test cross-platform, but we can test the error message
|
||||
@@ -919,6 +1665,32 @@ describe('InitCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function testFileRecreationInExtendMode(
|
||||
testDir: string,
|
||||
initCommand: InitCommand,
|
||||
relativePath: string,
|
||||
expectedContent: string
|
||||
): Promise<void> {
|
||||
queueSelections('claude', DONE, DONE);
|
||||
|
||||
// First init
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const filePath = path.join(testDir, relativePath);
|
||||
expect(await fileExists(filePath)).toBe(true);
|
||||
|
||||
// Delete the file
|
||||
await fs.unlink(filePath);
|
||||
expect(await fileExists(filePath)).toBe(false);
|
||||
|
||||
// Run init again - should recreate the file
|
||||
await initCommand.execute(testDir);
|
||||
expect(await fileExists(filePath)).toBe(true);
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
expect(content).toContain(expectedContent);
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user