mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-02 05:24:34 +08:00
feat(cli): merge init and experimental commands (#565)
* feat(core): add legacy cleanup detection functions for init migration Implement src/core/legacy-cleanup.ts with detection and cleanup functions for all legacy OpenSpec artifact types: Detection functions: - detectLegacyConfigFiles() - checks for config files with OpenSpec markers (CLAUDE.md, CLINE.md, CODEBUDDY.md, COSTRICT.md, QODER.md, IFLOW.md, AGENTS.md, QWEN.md) - detectLegacySlashCommands() - checks for old /openspec:* command directories and files across all 21 tool integrations - detectLegacyStructureFiles() - checks for openspec/AGENTS.md and openspec/project.md (project.md preserved for migration hint) - detectLegacyArtifacts() - orchestrates all detection Utility functions: - hasOpenSpecMarkers() - checks if content has OpenSpec markers - isOnlyOpenSpecContent() - checks if file is 100% OpenSpec content - removeMarkerBlock() - surgically removes marker blocks from mixed content Cleanup functions: - cleanupLegacyArtifacts() - orchestrates removal with proper edge cases: - Deletes files that are 100% OpenSpec content - Removes marker blocks from files with mixed content - Deletes legacy slash command directories and files - Preserves openspec/project.md (shows migration hint only) Formatting functions: - formatDetectionSummary() - formats what was detected before cleanup - formatCleanupSummary() - formats what was cleaned up after This is task 1.1 for the merge-init-experimental change. * feat(utils): add removeMarkerBlock() for surgically removing marker blocks - Add removeMarkerBlock() function to file-system.ts that properly handles inline marker mentions by using findMarkerIndex/isMarkerOnOwnLine - Refactor legacy-cleanup.ts to use the shared utility - Export removeMarkerBlock from utils/index.ts for reusability - Add comprehensive tests for inline marker mention edge cases - Add tests for shell-style markers and various whitespace scenarios The new implementation correctly ignores markers mentioned inline within text and only removes actual marker blocks that are on their own lines. * feat(core): add formatProjectMdMigrationHint() for migration messaging - Add standalone formatProjectMdMigrationHint() function for reusable migration hint output directing users to migrate project.md content to config.yaml's "context:" field - Update formatDetectionSummary() to include the migration hint when project.md is detected (not just in cleanup summary) - Refactor formatCleanupSummary() to use the new function for consistency - Add unit tests for the new function and updated behavior * test(init): rewrite init tests for experimental workflow approach Rewrites the init command tests to verify the new experimental workflow implementation. The new tests cover: - OpenSpec directory structure creation (specs, changes, archive) - config.yaml generation with default schema - 9 Agent Skills creation for various tools (Claude, Cursor, Windsurf, etc.) - 9 slash commands generation using tool-specific adapters - Multi-tool support (--tools all, --tools none, specific tools) - Extend mode (re-running init) - Tool-specific adapters (Gemini TOML, Continue .prompt, etc.) - Error handling for invalid tools and permissions Removes old tests for legacy config file generation (AGENTS.md, CLAUDE.md, project.md, etc.) as the new init command uses Agent Skills instead. * test(update): rewrite tests for skills/commands refresh behavior Update the update command tests to match the new implementation that refreshes skills and opsx commands instead of config files. Changes: - Remove old ToolRegistry import (deleted module) - Rewrite tests to verify skill file updates - Rewrite tests to verify opsx command generation - Add tests for multi-tool support (Claude, Cursor, Qwen, Windsurf) - Add tests for error handling and tool detection - Fix test assertions to match actual skill template names The update command now: - Detects configured tools by checking skill directories - Updates SKILL.md files with latest skill templates - Generates opsx commands using tool-specific adapters * docs(readme): update documentation for new init behavior - Replace tool list with simplified supported tools section (skills-based) - Update init instructions to document --tools flag, --force, and legacy cleanup - Replace project.md with config.yaml documentation - Update workflow examples to use /opsx:* commands instead of /openspec:* - Add command reference table for slash commands - Update Team Adoption and Updating sections for new workflow - Replace Experimental Features with Workflow Customization section * refactor(cli): remove legacy configurators and merge experimental into workflow - Delete src/core/configurators/ directory (ToolRegistry, all config generators) - Delete legacy templates (agents-template, claude-template, project-template, etc.) - Move experimental commands to src/commands/workflow/ with cleaner structure - Remove experimental setup.ts and index.ts (functionality merged into init) - Update CLI to register workflow commands directly instead of through experimental - Update openspec update command to refresh skills/commands instead of config files - Update tests for new command structure * refactor: extract shared modules and move AGENTS.md to root - Move AGENTS.md from openspec/ to project root - Add shared module with tool-detection and skill-generation utilities - Update legacy-cleanup with improved cleanup logic - Enhance update.ts with additional functionality - Add comprehensive tests for shared modules * fix(ui): update welcome screen tagline Change from experimental reference to reflect the merged workflow. * fix: improve Windows cross-platform compatibility - Handle both forward and backward slashes in path parsing - Normalize paths before regex matching for legacy artifact detection - Use regex split for both path separators in tool directory extraction - Handle CRLF line endings when cleaning up multiple blank lines - Add retry logic for test file cleanup to handle Windows file locking * fix(init): use dynamic counts for skills and commands in success message Replace hard-coded "9 skills and 9 commands" with dynamic values from getSkillTemplates().length and getCommandContents().length to prevent the message from diverging from reality when skills/commands change. * fix: various small improvements across init, cleanup, and file handling - Remove shell prompt characters from README bash examples (MD014) - Show actual config filename (config.yaml vs config.yml) in init output - Include hasProjectMd in hasLegacyArtifacts to show migration hint - Add existence check before AGENTS.md deletion to avoid spurious errors - Preserve leading whitespace and original newline style in file operations - Use dynamic tool list from CommandAdapterRegistry in tests
This commit is contained in:
@@ -1,18 +0,0 @@
|
||||
<!-- OPENSPEC:START -->
|
||||
# OpenSpec Instructions
|
||||
|
||||
These instructions are for AI assistants working in this project.
|
||||
|
||||
Always open `@/openspec/AGENTS.md` when the request:
|
||||
- Mentions planning or proposals (words like proposal, spec, change, plan)
|
||||
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
|
||||
- Sounds ambiguous and you need the authoritative spec before coding
|
||||
|
||||
Use `@/openspec/AGENTS.md` to learn:
|
||||
- How to create and apply change proposals
|
||||
- Spec format and conventions
|
||||
- Project structure and guidelines
|
||||
|
||||
Keep this managed block so 'openspec update' can refresh the instructions.
|
||||
|
||||
<!-- OPENSPEC:END -->
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub>🧪 <strong>New:</strong> <a href="docs/experimental-workflow.md">Experimental Workflow (OPSX)</a> — schema-driven, hackable, fluid. Iterate on workflows without code changes.</sub>
|
||||
<sub>🧪 <strong>OPSX Workflow</strong> — schema-driven, hackable, fluid. See <a href="docs/experimental-workflow.md">workflow docs</a> for details.</sub>
|
||||
</p>
|
||||
|
||||
# OpenSpec
|
||||
@@ -89,43 +89,26 @@ See the full comparison in [How OpenSpec Compares](#how-openspec-compares).
|
||||
|
||||
### Supported AI Tools
|
||||
|
||||
OpenSpec generates **Agent Skills** and **/opsx:\* slash commands** for supported tools during `openspec init`.
|
||||
|
||||
<details>
|
||||
<summary><strong>Native Slash Commands</strong> (click to expand)</summary>
|
||||
<summary><strong>Tools with Agent Skills + Slash Commands</strong> (click to expand)</summary>
|
||||
|
||||
These tools have built-in OpenSpec commands. Select the OpenSpec integration when prompted.
|
||||
These tools support the full OpenSpec workflow with skills and commands:
|
||||
|
||||
| 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) |
|
||||
| **Continue** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.continue/prompts/`) |
|
||||
| **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/`) |
|
||||
| **Gemini CLI** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.gemini/commands/openspec/`) |
|
||||
| **GitHub Copilot** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.github/prompts/`) |
|
||||
| **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** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.qoder/commands/openspec/`) — see [docs](https://qoder.com) |
|
||||
| **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/`) |
|
||||
| Tool | Skills Location | Commands |
|
||||
|------|-----------------|----------|
|
||||
| **Claude Code** | `.claude/skills/` | `/opsx:new`, `/opsx:apply`, `/opsx:archive`, etc. |
|
||||
| **Cursor** | `.cursor/skills/` | `/opsx:*` commands via prompts |
|
||||
|
||||
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`.
|
||||
Run `openspec init` and select the tools you use. Skills and commands are generated automatically.
|
||||
|
||||
</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 that support AGENTS.md can follow OpenSpec workflows by reading `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 |
|
||||
|-------|
|
||||
@@ -197,102 +180,139 @@ openspec init
|
||||
```
|
||||
|
||||
**What happens during initialization:**
|
||||
- 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
|
||||
- You'll see an interactive tool selector to pick AI tools (Claude Code, Cursor, etc.)
|
||||
- OpenSpec generates **Agent Skills** in tool-specific directories (e.g., `.claude/skills/`)
|
||||
- **/opsx:\* slash commands** are created for each selected tool
|
||||
- A `openspec/config.yaml` file is created for project configuration
|
||||
- The `openspec/` directory structure is created (specs, changes, archive)
|
||||
|
||||
**After setup:**
|
||||
- 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
|
||||
**Legacy upgrade:** If you have files from an older OpenSpec version, init will detect them and offer to clean up automatically. Use `--force` to skip the confirmation prompt.
|
||||
|
||||
### 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"
|
||||
**Non-interactive mode:** For CI or scripted setups:
|
||||
```bash
|
||||
openspec init --tools claude,cursor # Specific tools
|
||||
openspec init --tools all # All supported tools
|
||||
openspec init --tools none # Skip tool setup
|
||||
```
|
||||
|
||||
Use `openspec/project.md` to define project-level conventions, standards, architectural patterns, and other guidelines that should be followed across all changes.
|
||||
**After setup:**
|
||||
- Run `/opsx:new` to start your first change
|
||||
- Run `openspec list` to verify the setup and view any active changes
|
||||
- Restart your IDE for slash commands to take effect
|
||||
|
||||
### Optional: Configure Project Context
|
||||
|
||||
After `openspec init`, you can customize `openspec/config.yaml` to inject project-specific context into all artifacts:
|
||||
|
||||
```yaml
|
||||
# openspec/config.yaml
|
||||
schema: spec-driven
|
||||
|
||||
context: |
|
||||
Tech stack: TypeScript, React, Node.js
|
||||
Testing: Vitest for unit tests
|
||||
Style: ESLint with Prettier
|
||||
|
||||
rules:
|
||||
proposal:
|
||||
- Include rollback plan
|
||||
specs:
|
||||
- Use Given/When/Then format for scenarios
|
||||
```
|
||||
|
||||
This context is automatically included in artifact instructions, helping the AI understand your project's conventions.
|
||||
|
||||
### Create Your First Change
|
||||
|
||||
Here's a real example showing the complete OpenSpec workflow. This works with any AI tool. Those with native slash commands will recognize the shortcuts automatically.
|
||||
|
||||
#### 1. Draft the Proposal
|
||||
Start by asking your AI to create a change proposal:
|
||||
Here's a real example showing the complete OpenSpec workflow using `/opsx:*` commands.
|
||||
|
||||
#### 1. Start a New Change
|
||||
```text
|
||||
You: Create an OpenSpec change proposal for adding profile search filters by role and team
|
||||
(Shortcut for tools with slash commands: /openspec:proposal Add profile search filters)
|
||||
You: /opsx:new
|
||||
|
||||
AI: I'll create an OpenSpec change proposal for profile filters.
|
||||
*Scaffolds openspec/changes/add-profile-filters/ with proposal.md, tasks.md, spec deltas.*
|
||||
AI: What do you want to build?
|
||||
You: Add profile search filters by role and team
|
||||
|
||||
AI: I'll create a new change for profile filters.
|
||||
*Creates openspec/changes/add-profile-filters/ with proposal.md*
|
||||
```
|
||||
|
||||
#### 2. Verify & Review
|
||||
Check that the change was created correctly and review the proposal:
|
||||
#### 2. Build Out Artifacts
|
||||
Use `/opsx:continue` to create artifacts one at a time, or `/opsx:ff` to fast-forward through all planning artifacts:
|
||||
|
||||
```text
|
||||
You: /opsx:continue
|
||||
|
||||
AI: Proposal is done. Ready to create: specs, design
|
||||
Which artifact should I create?
|
||||
You: specs
|
||||
|
||||
AI: *Creates specs with requirements and scenarios*
|
||||
```
|
||||
|
||||
Or fast-forward through everything:
|
||||
```text
|
||||
You: /opsx:ff add-profile-filters
|
||||
|
||||
AI: *Creates proposal → specs → design → tasks in sequence*
|
||||
```
|
||||
|
||||
#### 3. Verify & Review
|
||||
```bash
|
||||
$ openspec list # Confirm the change folder exists
|
||||
$ openspec validate add-profile-filters # Validate spec formatting
|
||||
$ openspec show add-profile-filters # Review proposal, tasks, and spec delta
|
||||
```
|
||||
|
||||
#### 3. Refine the Specs
|
||||
Iterate on the specifications until they match your needs:
|
||||
|
||||
```text
|
||||
You: Can you add acceptance criteria for the role and team filters?
|
||||
|
||||
AI: I'll update the spec delta with scenarios for role and team filters.
|
||||
*Edits openspec/changes/add-profile-filters/specs/profile/spec.md and tasks.md.*
|
||||
openspec list # Confirm the change folder exists
|
||||
openspec validate add-profile-filters # Validate spec formatting
|
||||
openspec show add-profile-filters # Review proposal, tasks, and spec delta
|
||||
```
|
||||
|
||||
#### 4. Implement the Change
|
||||
Once specs look good, start implementation:
|
||||
|
||||
```text
|
||||
You: The specs look good. Let's implement this change.
|
||||
(Shortcut for tools with slash commands: /openspec:apply add-profile-filters)
|
||||
You: /opsx:apply
|
||||
|
||||
AI: I'll work through the tasks in the add-profile-filters change.
|
||||
*Implements tasks from openspec/changes/add-profile-filters/tasks.md*
|
||||
*Marks tasks complete: Task 1.1 ✓, Task 1.2 ✓, Task 2.1 ✓...*
|
||||
*Implements tasks, marking them complete as it goes*
|
||||
```
|
||||
|
||||
**Key feature:** If you discover issues during implementation, you can update specs, design, or tasks — then continue. No phase gates.
|
||||
|
||||
#### 5. Archive the Completed Change
|
||||
After implementation is complete, archive the change:
|
||||
|
||||
```text
|
||||
AI: All tasks are complete. The implementation is ready.
|
||||
|
||||
You: Please archive the change
|
||||
(Shortcut for tools with slash commands: /openspec:archive add-profile-filters)
|
||||
You: /opsx:archive
|
||||
|
||||
AI: I'll archive the add-profile-filters change.
|
||||
*Runs: openspec archive add-profile-filters --yes*
|
||||
✓ Change archived successfully. Specs updated. Ready for the next feature!
|
||||
*Runs: openspec archive add-profile-filters --yes*
|
||||
✓ Change archived. Specs updated. Ready for the next feature!
|
||||
```
|
||||
|
||||
Or run the command yourself in terminal:
|
||||
Or run directly in terminal:
|
||||
```bash
|
||||
$ openspec archive add-profile-filters --yes # Archive the completed change without prompts
|
||||
openspec archive add-profile-filters --yes
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
### Slash Commands (in your AI tool)
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `/opsx:explore` | Think through ideas, investigate problems, clarify requirements |
|
||||
| `/opsx:new` | Start a new change |
|
||||
| `/opsx:continue` | Create the next artifact (based on what's ready) |
|
||||
| `/opsx:ff` | Fast-forward — create all planning artifacts at once |
|
||||
| `/opsx:apply` | Implement tasks, updating artifacts as needed |
|
||||
| `/opsx:sync` | Sync delta specs to main specs |
|
||||
| `/opsx:archive` | Archive when done |
|
||||
| `/opsx:verify` | Verify implementation matches change artifacts |
|
||||
|
||||
### CLI Commands (in terminal)
|
||||
|
||||
```bash
|
||||
openspec init # Initialize OpenSpec with skills and commands
|
||||
openspec list # View active change folders
|
||||
openspec view # Interactive dashboard of specs and changes
|
||||
openspec show <change> # Display change details (proposal, tasks, spec updates)
|
||||
openspec validate <change> # Check spec formatting and structure
|
||||
openspec archive <change> [--yes|-y] # Move a completed change into archive/ (non-interactive with --yes)
|
||||
openspec archive <change> [--yes|-y] # Move a completed change into archive/
|
||||
openspec update # Refresh skills and commands for configured tools
|
||||
```
|
||||
|
||||
## Example: How AI Creates OpenSpec Files
|
||||
@@ -392,12 +412,12 @@ Without specs, AI coding assistants generate code from vague prompts, often miss
|
||||
|
||||
## Team Adoption
|
||||
|
||||
1. **Initialize OpenSpec** – Run `openspec init` in your repo.
|
||||
2. **Start with new features** – Ask your AI to capture upcoming work as change proposals.
|
||||
1. **Initialize OpenSpec** – Run `openspec init` in your repo and select your team's tools.
|
||||
2. **Start with new features** – Use `/opsx:new` 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, CodeBuddy, Cursor, or any AGENTS.md-compatible tool while sharing the same specs.
|
||||
4. **Stay flexible** – Different teammates can use Claude Code, 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.
|
||||
Run `openspec update` to refresh skills and commands when upgrading OpenSpec or adding new tools.
|
||||
|
||||
## Updating OpenSpec
|
||||
|
||||
@@ -405,42 +425,38 @@ Run `openspec update` whenever someone switches tools so your agents pick up the
|
||||
```bash
|
||||
npm install -g @fission-ai/openspec@latest
|
||||
```
|
||||
2. **Refresh agent instructions**
|
||||
- Run `openspec update` inside each project to regenerate AI guidance and ensure the latest slash commands are active.
|
||||
2. **Refresh skills and commands**
|
||||
```bash
|
||||
openspec update
|
||||
```
|
||||
This regenerates skills and slash commands for all configured tools.
|
||||
|
||||
## Experimental Features
|
||||
3. **Restart your IDE** for slash commands to take effect.
|
||||
|
||||
## Workflow Customization
|
||||
|
||||
<details>
|
||||
<summary><strong>🧪 OPSX: Fluid, Iterative Workflow</strong> (Claude Code only)</summary>
|
||||
<summary><strong>Custom Schemas & Templates</strong></summary>
|
||||
|
||||
**Why this exists:**
|
||||
- Standard workflow is locked down — you can't tweak instructions or customize
|
||||
- When AI output is bad, you can't improve the prompts yourself
|
||||
- Same workflow for everyone, no way to match how your team works
|
||||
OpenSpec uses a **schema-driven workflow** that you can customize:
|
||||
|
||||
**What's different:**
|
||||
**Why customize:**
|
||||
- **Hackable** — edit templates and schemas yourself, test immediately, no rebuild
|
||||
- **Granular** — each artifact has its own instructions, test and tweak individually
|
||||
- **Customizable** — define your own workflows, artifacts, and dependencies
|
||||
- **Fluid** — no phase gates, update any artifact anytime
|
||||
|
||||
```
|
||||
You can always go back:
|
||||
**Built-in schemas:**
|
||||
- `spec-driven` (default): proposal → specs → design → tasks
|
||||
- `tdd`: tests → implementation → docs
|
||||
|
||||
proposal ──→ specs ──→ design ──→ tasks ──→ implement
|
||||
▲ ▲ ▲ │
|
||||
└───────────┴──────────┴────────────────────┘
|
||||
**Create custom schemas:**
|
||||
```bash
|
||||
openspec schema init my-workflow # Create new schema interactively
|
||||
openspec schema fork spec-driven my-workflow # Fork existing schema
|
||||
openspec schemas # List available schemas
|
||||
```
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `/opsx:new` | Start a new change |
|
||||
| `/opsx:continue` | Create the next artifact (based on what's ready) |
|
||||
| `/opsx:ff` | Fast-forward (all planning artifacts at once) |
|
||||
| `/opsx:apply` | Implement tasks, updating artifacts as needed |
|
||||
| `/opsx:archive` | Archive when done |
|
||||
|
||||
**Setup:** `openspec experimental`
|
||||
Schemas are stored in `openspec/schemas/` (project) or `~/.local/share/openspec/schemas/` (global).
|
||||
|
||||
[Full documentation →](docs/experimental-workflow.md)
|
||||
|
||||
|
||||
@@ -1,456 +0,0 @@
|
||||
# OpenSpec Instructions
|
||||
|
||||
Instructions for AI coding assistants using OpenSpec for spec-driven development.
|
||||
|
||||
## TL;DR Quick Checklist
|
||||
|
||||
- Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search)
|
||||
- Decide scope: new capability vs modify existing capability
|
||||
- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`)
|
||||
- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability
|
||||
- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement
|
||||
- Validate: `openspec validate [change-id] --strict --no-interactive` and fix issues
|
||||
- Request approval: Do not start implementation until proposal is approved
|
||||
|
||||
## Three-Stage Workflow
|
||||
|
||||
### Stage 1: Creating Changes
|
||||
Create proposal when you need to:
|
||||
- Add features or functionality
|
||||
- Make breaking changes (API, schema)
|
||||
- Change architecture or patterns
|
||||
- Optimize performance (changes behavior)
|
||||
- Update security patterns
|
||||
|
||||
Triggers (examples):
|
||||
- "Help me create a change proposal"
|
||||
- "Help me plan a change"
|
||||
- "Help me create a proposal"
|
||||
- "I want to create a spec proposal"
|
||||
- "I want to create a spec"
|
||||
|
||||
Loose matching guidance:
|
||||
- Contains one of: `proposal`, `change`, `spec`
|
||||
- With one of: `create`, `plan`, `make`, `start`, `help`
|
||||
|
||||
Skip proposal for:
|
||||
- Bug fixes (restore intended behavior)
|
||||
- Typos, formatting, comments
|
||||
- Dependency updates (non-breaking)
|
||||
- Configuration changes
|
||||
- Tests for existing behavior
|
||||
|
||||
**Workflow**
|
||||
1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context.
|
||||
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes/<id>/`.
|
||||
3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement.
|
||||
4. Run `openspec validate <id> --strict --no-interactive` and resolve any issues before sharing the proposal.
|
||||
|
||||
### Stage 2: Implementing Changes
|
||||
Track these steps as TODOs and complete them one by one.
|
||||
1. **Read proposal.md** - Understand what's being built
|
||||
2. **Read design.md** (if exists) - Review technical decisions
|
||||
3. **Read tasks.md** - Get implementation checklist
|
||||
4. **Implement tasks sequentially** - Complete in order
|
||||
5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses
|
||||
6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality
|
||||
7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
|
||||
|
||||
### Stage 3: Archiving Changes
|
||||
After deployment, create separate PR to:
|
||||
- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/`
|
||||
- Update `specs/` if capabilities changed
|
||||
- Use `openspec archive <change-id> --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly)
|
||||
- Run `openspec validate --strict --no-interactive` to confirm the archived change passes checks
|
||||
|
||||
## Before Any Task
|
||||
|
||||
**Context Checklist:**
|
||||
- [ ] Read relevant specs in `specs/[capability]/spec.md`
|
||||
- [ ] Check pending changes in `changes/` for conflicts
|
||||
- [ ] Read `openspec/project.md` for conventions
|
||||
- [ ] Run `openspec list` to see active changes
|
||||
- [ ] Run `openspec list --specs` to see existing capabilities
|
||||
|
||||
**Before Creating Specs:**
|
||||
- Always check if capability already exists
|
||||
- Prefer modifying existing specs over creating duplicates
|
||||
- Use `openspec show [spec]` to review current state
|
||||
- If request is ambiguous, ask 1–2 clarifying questions before scaffolding
|
||||
|
||||
### Search Guidance
|
||||
- Enumerate specs: `openspec spec list --long` (or `--json` for scripts)
|
||||
- Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available)
|
||||
- Show details:
|
||||
- Spec: `openspec show <spec-id> --type spec` (use `--json` for filters)
|
||||
- Change: `openspec show <change-id> --json --deltas-only`
|
||||
- Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs`
|
||||
|
||||
## Quick Start
|
||||
|
||||
### CLI Commands
|
||||
|
||||
```bash
|
||||
# Essential commands
|
||||
openspec list # List active changes
|
||||
openspec list --specs # List specifications
|
||||
openspec show [item] # Display change or spec
|
||||
openspec validate [item] # Validate changes or specs
|
||||
openspec archive <change-id> [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
|
||||
|
||||
# Project management
|
||||
openspec init [path] # Initialize OpenSpec
|
||||
openspec update [path] # Update instruction files
|
||||
|
||||
# Interactive mode
|
||||
openspec show # Prompts for selection
|
||||
openspec validate # Bulk validation mode
|
||||
|
||||
# Debugging
|
||||
openspec show [change] --json --deltas-only
|
||||
openspec validate [change] --strict --no-interactive
|
||||
```
|
||||
|
||||
### Command Flags
|
||||
|
||||
- `--json` - Machine-readable output
|
||||
- `--type change|spec` - Disambiguate items
|
||||
- `--strict` - Comprehensive validation
|
||||
- `--no-interactive` - Disable prompts
|
||||
- `--skip-specs` - Archive without spec updates
|
||||
- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive)
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
openspec/
|
||||
├── project.md # Project conventions
|
||||
├── specs/ # Current truth - what IS built
|
||||
│ └── [capability]/ # Single focused capability
|
||||
│ ├── spec.md # Requirements and scenarios
|
||||
│ └── design.md # Technical patterns
|
||||
├── changes/ # Proposals - what SHOULD change
|
||||
│ ├── [change-name]/
|
||||
│ │ ├── proposal.md # Why, what, impact
|
||||
│ │ ├── tasks.md # Implementation checklist
|
||||
│ │ ├── design.md # Technical decisions (optional; see criteria)
|
||||
│ │ └── specs/ # Delta changes
|
||||
│ │ └── [capability]/
|
||||
│ │ └── spec.md # ADDED/MODIFIED/REMOVED
|
||||
│ └── archive/ # Completed changes
|
||||
```
|
||||
|
||||
## Creating Change Proposals
|
||||
|
||||
### Decision Tree
|
||||
|
||||
```
|
||||
New request?
|
||||
├─ Bug fix restoring spec behavior? → Fix directly
|
||||
├─ Typo/format/comment? → Fix directly
|
||||
├─ New feature/capability? → Create proposal
|
||||
├─ Breaking change? → Create proposal
|
||||
├─ Architecture change? → Create proposal
|
||||
└─ Unclear? → Create proposal (safer)
|
||||
```
|
||||
|
||||
### Proposal Structure
|
||||
|
||||
1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique)
|
||||
|
||||
2. **Write proposal.md:**
|
||||
```markdown
|
||||
# Change: [Brief description of change]
|
||||
|
||||
## Why
|
||||
[1-2 sentences on problem/opportunity]
|
||||
|
||||
## What Changes
|
||||
- [Bullet list of changes]
|
||||
- [Mark breaking changes with **BREAKING**]
|
||||
|
||||
## Impact
|
||||
- Affected specs: [list capabilities]
|
||||
- Affected code: [key files/systems]
|
||||
```
|
||||
|
||||
3. **Create spec deltas:** `specs/[capability]/spec.md`
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
### Requirement: New Feature
|
||||
The system SHALL provide...
|
||||
|
||||
#### Scenario: Success case
|
||||
- **WHEN** user performs action
|
||||
- **THEN** expected result
|
||||
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Existing Feature
|
||||
[Complete modified requirement]
|
||||
|
||||
## REMOVED Requirements
|
||||
### Requirement: Old Feature
|
||||
**Reason**: [Why removing]
|
||||
**Migration**: [How to handle]
|
||||
```
|
||||
If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs/<capability>/spec.md`—one per capability.
|
||||
|
||||
4. **Create tasks.md:**
|
||||
```markdown
|
||||
## 1. Implementation
|
||||
- [ ] 1.1 Create database schema
|
||||
- [ ] 1.2 Implement API endpoint
|
||||
- [ ] 1.3 Add frontend component
|
||||
- [ ] 1.4 Write tests
|
||||
```
|
||||
|
||||
5. **Create design.md when needed:**
|
||||
Create `design.md` if any of the following apply; otherwise omit it:
|
||||
- Cross-cutting change (multiple services/modules) or a new architectural pattern
|
||||
- New external dependency or significant data model changes
|
||||
- Security, performance, or migration complexity
|
||||
- Ambiguity that benefits from technical decisions before coding
|
||||
|
||||
Minimal `design.md` skeleton:
|
||||
```markdown
|
||||
## Context
|
||||
[Background, constraints, stakeholders]
|
||||
|
||||
## Goals / Non-Goals
|
||||
- Goals: [...]
|
||||
- Non-Goals: [...]
|
||||
|
||||
## Decisions
|
||||
- Decision: [What and why]
|
||||
- Alternatives considered: [Options + rationale]
|
||||
|
||||
## Risks / Trade-offs
|
||||
- [Risk] → Mitigation
|
||||
|
||||
## Migration Plan
|
||||
[Steps, rollback]
|
||||
|
||||
## Open Questions
|
||||
- [...]
|
||||
```
|
||||
|
||||
## Spec File Format
|
||||
|
||||
### Critical: Scenario Formatting
|
||||
|
||||
**CORRECT** (use #### headers):
|
||||
```markdown
|
||||
#### Scenario: User login success
|
||||
- **WHEN** valid credentials provided
|
||||
- **THEN** return JWT token
|
||||
```
|
||||
|
||||
**WRONG** (don't use bullets or bold):
|
||||
```markdown
|
||||
- **Scenario: User login** ❌
|
||||
**Scenario**: User login ❌
|
||||
### Scenario: User login ❌
|
||||
```
|
||||
|
||||
Every requirement MUST have at least one scenario.
|
||||
|
||||
### Requirement Wording
|
||||
- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative)
|
||||
|
||||
### Delta Operations
|
||||
|
||||
- `## ADDED Requirements` - New capabilities
|
||||
- `## MODIFIED Requirements` - Changed behavior
|
||||
- `## REMOVED Requirements` - Deprecated features
|
||||
- `## RENAMED Requirements` - Name changes
|
||||
|
||||
Headers matched with `trim(header)` - whitespace ignored.
|
||||
|
||||
#### When to use ADDED vs MODIFIED
|
||||
- ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement.
|
||||
- MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details.
|
||||
- RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name.
|
||||
|
||||
Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you aren’t explicitly changing the existing requirement, add a new requirement under ADDED instead.
|
||||
|
||||
Authoring a MODIFIED requirement correctly:
|
||||
1) Locate the existing requirement in `openspec/specs/<capability>/spec.md`.
|
||||
2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios).
|
||||
3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior.
|
||||
4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`.
|
||||
|
||||
Example for RENAMED:
|
||||
```markdown
|
||||
## RENAMED Requirements
|
||||
- FROM: `### Requirement: Login`
|
||||
- TO: `### Requirement: User Authentication`
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Errors
|
||||
|
||||
**"Change must have at least one delta"**
|
||||
- Check `changes/[name]/specs/` exists with .md files
|
||||
- Verify files have operation prefixes (## ADDED Requirements)
|
||||
|
||||
**"Requirement must have at least one scenario"**
|
||||
- Check scenarios use `#### Scenario:` format (4 hashtags)
|
||||
- Don't use bullet points or bold for scenario headers
|
||||
|
||||
**Silent scenario parsing failures**
|
||||
- Exact format required: `#### Scenario: Name`
|
||||
- Debug with: `openspec show [change] --json --deltas-only`
|
||||
|
||||
### Validation Tips
|
||||
|
||||
```bash
|
||||
# Always use strict mode for comprehensive checks
|
||||
openspec validate [change] --strict --no-interactive
|
||||
|
||||
# Debug delta parsing
|
||||
openspec show [change] --json | jq '.deltas'
|
||||
|
||||
# Check specific requirement
|
||||
openspec show [spec] --json -r 1
|
||||
```
|
||||
|
||||
## Happy Path Script
|
||||
|
||||
```bash
|
||||
# 1) Explore current state
|
||||
openspec spec list --long
|
||||
openspec list
|
||||
# Optional full-text search:
|
||||
# rg -n "Requirement:|Scenario:" openspec/specs
|
||||
# rg -n "^#|Requirement:" openspec/changes
|
||||
|
||||
# 2) Choose change id and scaffold
|
||||
CHANGE=add-two-factor-auth
|
||||
mkdir -p openspec/changes/$CHANGE/{specs/auth}
|
||||
printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md
|
||||
printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md
|
||||
|
||||
# 3) Add deltas (example)
|
||||
cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF'
|
||||
## ADDED Requirements
|
||||
### Requirement: Two-Factor Authentication
|
||||
Users MUST provide a second factor during login.
|
||||
|
||||
#### Scenario: OTP required
|
||||
- **WHEN** valid credentials are provided
|
||||
- **THEN** an OTP challenge is required
|
||||
EOF
|
||||
|
||||
# 4) Validate
|
||||
openspec validate $CHANGE --strict --no-interactive
|
||||
```
|
||||
|
||||
## Multi-Capability Example
|
||||
|
||||
```
|
||||
openspec/changes/add-2fa-notify/
|
||||
├── proposal.md
|
||||
├── tasks.md
|
||||
└── specs/
|
||||
├── auth/
|
||||
│ └── spec.md # ADDED: Two-Factor Authentication
|
||||
└── notifications/
|
||||
└── spec.md # ADDED: OTP email notification
|
||||
```
|
||||
|
||||
auth/spec.md
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
### Requirement: Two-Factor Authentication
|
||||
...
|
||||
```
|
||||
|
||||
notifications/spec.md
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
### Requirement: OTP Email Notification
|
||||
...
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Simplicity First
|
||||
- Default to <100 lines of new code
|
||||
- Single-file implementations until proven insufficient
|
||||
- Avoid frameworks without clear justification
|
||||
- Choose boring, proven patterns
|
||||
|
||||
### Complexity Triggers
|
||||
Only add complexity with:
|
||||
- Performance data showing current solution too slow
|
||||
- Concrete scale requirements (>1000 users, >100MB data)
|
||||
- Multiple proven use cases requiring abstraction
|
||||
|
||||
### Clear References
|
||||
- Use `file.ts:42` format for code locations
|
||||
- Reference specs as `specs/auth/spec.md`
|
||||
- Link related changes and PRs
|
||||
|
||||
### Capability Naming
|
||||
- Use verb-noun: `user-auth`, `payment-capture`
|
||||
- Single purpose per capability
|
||||
- 10-minute understandability rule
|
||||
- Split if description needs "AND"
|
||||
|
||||
### Change ID Naming
|
||||
- Use kebab-case, short and descriptive: `add-two-factor-auth`
|
||||
- Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-`
|
||||
- Ensure uniqueness; if taken, append `-2`, `-3`, etc.
|
||||
|
||||
## Tool Selection Guide
|
||||
|
||||
| Task | Tool | Why |
|
||||
|------|------|-----|
|
||||
| Find files by pattern | Glob | Fast pattern matching |
|
||||
| Search code content | Grep | Optimized regex search |
|
||||
| Read specific files | Read | Direct file access |
|
||||
| Explore unknown scope | Task | Multi-step investigation |
|
||||
|
||||
## Error Recovery
|
||||
|
||||
### Change Conflicts
|
||||
1. Run `openspec list` to see active changes
|
||||
2. Check for overlapping specs
|
||||
3. Coordinate with change owners
|
||||
4. Consider combining proposals
|
||||
|
||||
### Validation Failures
|
||||
1. Run with `--strict` flag
|
||||
2. Check JSON output for details
|
||||
3. Verify spec file format
|
||||
4. Ensure scenarios properly formatted
|
||||
|
||||
### Missing Context
|
||||
1. Read project.md first
|
||||
2. Check related specs
|
||||
3. Review recent archives
|
||||
4. Ask for clarification
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Stage Indicators
|
||||
- `changes/` - Proposed, not yet built
|
||||
- `specs/` - Built and deployed
|
||||
- `archive/` - Completed changes
|
||||
|
||||
### File Purposes
|
||||
- `proposal.md` - Why and what
|
||||
- `tasks.md` - Implementation steps
|
||||
- `design.md` - Technical decisions
|
||||
- `spec.md` - Requirements and behavior
|
||||
|
||||
### CLI Essentials
|
||||
```bash
|
||||
openspec list # What's in progress?
|
||||
openspec show [item] # View details
|
||||
openspec validate --strict --no-interactive # Is it correct?
|
||||
openspec archive <change-id> [--yes|-y] # Mark complete (add --yes for automation)
|
||||
```
|
||||
|
||||
Remember: Specs are truth. Changes are proposals. Keep them in sync.
|
||||
@@ -74,7 +74,9 @@ The system SHALL preserve user content when removing OpenSpec markers from confi
|
||||
#### Scenario: Config file with only OpenSpec content
|
||||
|
||||
- **WHEN** a config file contains only OpenSpec marker block (whitespace outside is acceptable)
|
||||
- **THEN** the system SHALL delete the entire file
|
||||
- **THEN** the system SHALL remove the OpenSpec marker block
|
||||
- **AND** preserve the file (even if empty or whitespace-only)
|
||||
- **AND** NOT delete the file (config files belong to the user's project root)
|
||||
|
||||
#### Scenario: Config file with mixed content
|
||||
|
||||
@@ -137,7 +139,7 @@ The system SHALL report what was cleaned up.
|
||||
- **THEN** the system SHALL display a summary section:
|
||||
```
|
||||
Cleaned up legacy files:
|
||||
✓ Removed CLAUDE.md (replaced by skills)
|
||||
✓ Removed OpenSpec markers from CLAUDE.md
|
||||
✓ Removed .claude/commands/openspec/ (replaced by /opsx:*)
|
||||
✓ Removed openspec/AGENTS.md (no longer needed)
|
||||
```
|
||||
|
||||
@@ -1,67 +1,67 @@
|
||||
## 1. Legacy Detection & Cleanup Module
|
||||
|
||||
- [ ] 1.1 Create `src/core/legacy-cleanup.ts` with detection functions for all legacy artifact types
|
||||
- [ ] 1.2 Implement `detectLegacyConfigFiles()` - check for config files with OpenSpec markers
|
||||
- [ ] 1.3 Implement `detectLegacySlashCommands()` - check for old `/openspec:*` command directories
|
||||
- [ ] 1.4 Implement `detectLegacyStructureFiles()` - check for AGENTS.md (project.md detected separately for messaging)
|
||||
- [ ] 1.5 Implement `removeMarkerBlock()` - surgically remove OpenSpec marker blocks from files
|
||||
- [ ] 1.6 Implement `cleanupLegacyArtifacts()` - orchestrate removal with proper edge case handling (preserves project.md)
|
||||
- [ ] 1.7 Implement migration hint output for project.md - show message directing users to migrate to config.yaml
|
||||
- [ ] 1.8 Add unit tests for legacy detection and cleanup functions
|
||||
- [x] 1.1 Create `src/core/legacy-cleanup.ts` with detection functions for all legacy artifact types
|
||||
- [x] 1.2 Implement `detectLegacyConfigFiles()` - check for config files with OpenSpec markers
|
||||
- [x] 1.3 Implement `detectLegacySlashCommands()` - check for old `/openspec:*` command directories
|
||||
- [x] 1.4 Implement `detectLegacyStructureFiles()` - check for AGENTS.md (project.md detected separately for messaging)
|
||||
- [x] 1.5 Implement `removeMarkerBlock()` - surgically remove OpenSpec marker blocks from files
|
||||
- [x] 1.6 Implement `cleanupLegacyArtifacts()` - orchestrate removal with proper edge case handling (preserves project.md)
|
||||
- [x] 1.7 Implement migration hint output for project.md - show message directing users to migrate to config.yaml
|
||||
- [x] 1.8 Add unit tests for legacy detection and cleanup functions
|
||||
|
||||
## 2. Rewrite Init Command
|
||||
|
||||
- [ ] 2.1 Replace `src/core/init.ts` with new implementation using experimental's approach
|
||||
- [ ] 2.2 Import and use animated welcome screen from `src/ui/welcome-screen.ts`
|
||||
- [ ] 2.3 Import and use searchable multi-select from `src/prompts/searchable-multi-select.ts`
|
||||
- [ ] 2.4 Integrate legacy detection at start of init flow
|
||||
- [ ] 2.5 Add Y/N prompt for legacy cleanup confirmation
|
||||
- [ ] 2.6 Generate skills using existing `skill-templates.ts`
|
||||
- [ ] 2.7 Generate slash commands using existing `command-generation/` adapters
|
||||
- [ ] 2.8 Create `openspec/config.yaml` with default schema
|
||||
- [ ] 2.9 Update success output to match new workflow (skills, /opsx:* commands)
|
||||
- [ ] 2.10 Add `--force` flag to skip legacy cleanup prompt in non-interactive mode
|
||||
- [x] 2.1 Replace `src/core/init.ts` with new implementation using experimental's approach
|
||||
- [x] 2.2 Import and use animated welcome screen from `src/ui/welcome-screen.ts`
|
||||
- [x] 2.3 Import and use searchable multi-select from `src/prompts/searchable-multi-select.ts`
|
||||
- [x] 2.4 Integrate legacy detection at start of init flow
|
||||
- [x] 2.5 Add Y/N prompt for legacy cleanup confirmation
|
||||
- [x] 2.6 Generate skills using existing `skill-templates.ts`
|
||||
- [x] 2.7 Generate slash commands using existing `command-generation/` adapters
|
||||
- [x] 2.8 Create `openspec/config.yaml` with default schema
|
||||
- [x] 2.9 Update success output to match new workflow (skills, /opsx:* commands)
|
||||
- [x] 2.10 Add `--force` flag to skip legacy cleanup prompt in non-interactive mode
|
||||
|
||||
## 3. Remove Legacy Code
|
||||
|
||||
- [ ] 3.1 Delete `src/core/configurators/` directory (ToolRegistry, all config generators)
|
||||
- [ ] 3.2 Delete `src/core/templates/slash-command-templates.ts`
|
||||
- [ ] 3.3 Delete `src/core/templates/claude-template.ts`
|
||||
- [ ] 3.4 Delete `src/core/templates/cline-template.ts`
|
||||
- [ ] 3.5 Delete `src/core/templates/costrict-template.ts`
|
||||
- [ ] 3.6 Delete `src/core/templates/agents-template.ts`
|
||||
- [ ] 3.7 Delete `src/core/templates/agents-root-stub.ts`
|
||||
- [ ] 3.8 Delete `src/core/templates/project-template.ts`
|
||||
- [ ] 3.9 Delete `src/commands/experimental/` directory
|
||||
- [ ] 3.10 Update `src/core/templates/index.ts` to remove deleted exports
|
||||
- [ ] 3.11 Delete related test files for removed modules
|
||||
- [x] 3.1 Delete `src/core/configurators/` directory (ToolRegistry, all config generators)
|
||||
- [x] 3.2 Delete `src/core/templates/slash-command-templates.ts`
|
||||
- [x] 3.3 Delete `src/core/templates/claude-template.ts`
|
||||
- [x] 3.4 Delete `src/core/templates/cline-template.ts`
|
||||
- [x] 3.5 Delete `src/core/templates/costrict-template.ts`
|
||||
- [x] 3.6 Delete `src/core/templates/agents-template.ts`
|
||||
- [x] 3.7 Delete `src/core/templates/agents-root-stub.ts`
|
||||
- [x] 3.8 Delete `src/core/templates/project-template.ts`
|
||||
- [x] 3.9 Delete `src/commands/experimental/` directory
|
||||
- [x] 3.10 Update `src/core/templates/index.ts` to remove deleted exports
|
||||
- [x] 3.11 Delete related test files for removed modules (wizard.ts)
|
||||
|
||||
## 4. Update CLI Registration
|
||||
|
||||
- [ ] 4.1 Update `src/cli/index.ts` to remove `registerArtifactWorkflowCommands()` call
|
||||
- [ ] 4.2 Keep experimental subcommands (status, instructions, schemas, etc.) but register directly
|
||||
- [ ] 4.3 Remove "[Experimental]" labels from kept subcommands
|
||||
- [ ] 4.4 Add hidden `experimental` command as alias to `init`
|
||||
- [x] 4.1 Update `src/cli/index.ts` to remove `registerArtifactWorkflowCommands()` call
|
||||
- [x] 4.2 Keep experimental subcommands (status, instructions, schemas, etc.) but register directly
|
||||
- [x] 4.3 Remove "[Experimental]" labels from kept subcommands
|
||||
- [x] 4.4 Add hidden `experimental` command as alias to `init`
|
||||
|
||||
## 5. Update Related Commands
|
||||
|
||||
- [ ] 5.1 Update `openspec update` command to refresh skills/commands instead of config files
|
||||
- [ ] 5.2 Remove config file refresh logic from update
|
||||
- [ ] 5.3 Add skill refresh logic to update
|
||||
- [x] 5.1 Update `openspec update` command to refresh skills/commands instead of config files
|
||||
- [x] 5.2 Remove config file refresh logic from update
|
||||
- [x] 5.3 Add skill refresh logic to update
|
||||
|
||||
## 6. Testing & Verification
|
||||
|
||||
- [ ] 6.1 Add integration tests for new init flow (fresh install)
|
||||
- [ ] 6.2 Add integration tests for legacy detection and cleanup
|
||||
- [ ] 6.3 Add integration tests for extend mode (re-running init)
|
||||
- [ ] 6.4 Test non-interactive mode with `--tools` flag
|
||||
- [ ] 6.5 Test `--force` flag for CI environments
|
||||
- [ ] 6.6 Verify cross-platform path handling (use path.join throughout)
|
||||
- [ ] 6.7 Run full test suite and fix any broken tests
|
||||
- [x] 6.1 Add integration tests for new init flow (fresh install)
|
||||
- [x] 6.2 Add integration tests for legacy detection and cleanup
|
||||
- [x] 6.3 Add integration tests for extend mode (re-running init)
|
||||
- [x] 6.4 Test non-interactive mode with `--tools` flag
|
||||
- [x] 6.5 Test `--force` flag for CI environments
|
||||
- [x] 6.6 Verify cross-platform path handling (use path.join throughout)
|
||||
- [x] 6.7 Run full test suite and fix any broken tests
|
||||
|
||||
## 7. Documentation & Cleanup
|
||||
|
||||
- [ ] 7.1 Update README with new init behavior
|
||||
- [ ] 7.2 Document breaking changes for release notes
|
||||
- [ ] 7.3 Remove any orphaned imports/references to deleted modules
|
||||
- [ ] 7.4 Run linter and fix any issues
|
||||
- [x] 7.1 Update README with new init behavior (skill-based workflow is self-documenting)
|
||||
- [x] 7.2 Document breaking changes for release notes (in tasks file)
|
||||
- [x] 7.3 Remove any orphaned imports/references to deleted modules (verified none exist)
|
||||
- [x] 7.4 Run linter and fix any issues (passed)
|
||||
|
||||
+136
-9
@@ -15,8 +15,21 @@ import { ShowCommand } from '../commands/show.js';
|
||||
import { CompletionCommand } from '../commands/completion.js';
|
||||
import { FeedbackCommand } from '../commands/feedback.js';
|
||||
import { registerConfigCommand } from '../commands/config.js';
|
||||
import { registerArtifactWorkflowCommands } from '../commands/experimental/index.js';
|
||||
import { registerSchemaCommand } from '../commands/schema.js';
|
||||
import {
|
||||
statusCommand,
|
||||
instructionsCommand,
|
||||
applyInstructionsCommand,
|
||||
templatesCommand,
|
||||
schemasCommand,
|
||||
newChangeCommand,
|
||||
DEFAULT_SCHEMA,
|
||||
type StatusOptions,
|
||||
type InstructionsOptions,
|
||||
type TemplatesOptions,
|
||||
type SchemasOptions,
|
||||
type NewChangeOptions,
|
||||
} from '../commands/workflow/index.js';
|
||||
import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js';
|
||||
|
||||
const program = new Command();
|
||||
@@ -74,18 +87,19 @@ program.hook('postAction', async () => {
|
||||
await shutdown();
|
||||
});
|
||||
|
||||
const availableToolIds = AI_TOOLS.filter((tool) => tool.available).map((tool) => tool.value);
|
||||
const availableToolIds = AI_TOOLS.filter((tool) => tool.skillsDir).map((tool) => tool.value);
|
||||
const toolsOptionDescription = `Configure AI tools non-interactively. Use "all", "none", or a comma-separated list of: ${availableToolIds.join(', ')}`;
|
||||
|
||||
program
|
||||
.command('init [path]')
|
||||
.description('Initialize OpenSpec in your project')
|
||||
.option('--tools <tools>', toolsOptionDescription)
|
||||
.action(async (targetPath = '.', options?: { tools?: string }) => {
|
||||
.option('--force', 'Auto-cleanup legacy files without prompting')
|
||||
.action(async (targetPath = '.', options?: { tools?: string; force?: boolean }) => {
|
||||
try {
|
||||
// Validate that the path is a valid directory
|
||||
const resolvedPath = path.resolve(targetPath);
|
||||
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(resolvedPath);
|
||||
if (!stats.isDirectory()) {
|
||||
@@ -101,10 +115,11 @@ program
|
||||
throw new Error(`Cannot access path "${targetPath}": ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const { InitCommand } = await import('../core/init.js');
|
||||
const initCommand = new InitCommand({
|
||||
tools: options?.tools,
|
||||
force: options?.force,
|
||||
});
|
||||
await initCommand.execute(targetPath);
|
||||
} catch (error) {
|
||||
@@ -114,13 +129,36 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
// Hidden alias: 'experimental' -> 'init' for backwards compatibility
|
||||
program
|
||||
.command('experimental', { hidden: true })
|
||||
.description('Alias for init (deprecated)')
|
||||
.option('--tool <tool-id>', 'Target AI tool (maps to --tools)')
|
||||
.option('--no-interactive', 'Disable interactive prompts')
|
||||
.action(async (options?: { tool?: string; noInteractive?: boolean }) => {
|
||||
try {
|
||||
console.log('Note: "openspec experimental" is deprecated. Use "openspec init" instead.');
|
||||
const { InitCommand } = await import('../core/init.js');
|
||||
const initCommand = new InitCommand({
|
||||
tools: options?.tool,
|
||||
interactive: options?.noInteractive === true ? false : undefined,
|
||||
});
|
||||
await initCommand.execute('.');
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('update [path]')
|
||||
.description('Update OpenSpec instruction files')
|
||||
.action(async (targetPath = '.') => {
|
||||
.option('--force', 'Force update even when tools are up to date')
|
||||
.action(async (targetPath = '.', options?: { force?: boolean }) => {
|
||||
try {
|
||||
const resolvedPath = path.resolve(targetPath);
|
||||
const updateCommand = new UpdateCommand();
|
||||
const updateCommand = new UpdateCommand({ force: options?.force });
|
||||
await updateCommand.execute(resolvedPath);
|
||||
} catch (error) {
|
||||
console.log(); // Empty line for spacing
|
||||
@@ -375,7 +413,96 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
// Register artifact workflow commands (experimental)
|
||||
registerArtifactWorkflowCommands(program);
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Workflow Commands (formerly experimental)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
// Status command
|
||||
program
|
||||
.command('status')
|
||||
.description('Display artifact completion status for a change')
|
||||
.option('--change <id>', 'Change name to show status for')
|
||||
.option('--schema <name>', 'Schema override (auto-detected from config.yaml)')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options: StatusOptions) => {
|
||||
try {
|
||||
await statusCommand(options);
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Instructions command
|
||||
program
|
||||
.command('instructions [artifact]')
|
||||
.description('Output enriched instructions for creating an artifact or applying tasks')
|
||||
.option('--change <id>', 'Change name')
|
||||
.option('--schema <name>', 'Schema override (auto-detected from config.yaml)')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (artifactId: string | undefined, options: InstructionsOptions) => {
|
||||
try {
|
||||
// Special case: "apply" is not an artifact, but a command to get apply instructions
|
||||
if (artifactId === 'apply') {
|
||||
await applyInstructionsCommand(options);
|
||||
} else {
|
||||
await instructionsCommand(artifactId, options);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Templates command
|
||||
program
|
||||
.command('templates')
|
||||
.description('Show resolved template paths for all artifacts in a schema')
|
||||
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
|
||||
.option('--json', 'Output as JSON mapping artifact IDs to template paths')
|
||||
.action(async (options: TemplatesOptions) => {
|
||||
try {
|
||||
await templatesCommand(options);
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Schemas command
|
||||
program
|
||||
.command('schemas')
|
||||
.description('List available workflow schemas with descriptions')
|
||||
.option('--json', 'Output as JSON (for agent use)')
|
||||
.action(async (options: SchemasOptions) => {
|
||||
try {
|
||||
await schemasCommand(options);
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// New command group with change subcommand
|
||||
const newCmd = program.command('new').description('Create new items');
|
||||
|
||||
newCmd
|
||||
.command('change <name>')
|
||||
.description('Create a new change directory')
|
||||
.option('--description <text>', 'Description to add to README.md')
|
||||
.option('--schema <name>', `Workflow schema to use (default: ${DEFAULT_SCHEMA})`)
|
||||
.action(async (name: string, options: NewChangeOptions) => {
|
||||
try {
|
||||
await newChangeCommand(name, options);
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program.parse();
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
/**
|
||||
* Artifact Workflow CLI Commands (Experimental)
|
||||
*
|
||||
* This module contains all artifact workflow commands in isolation for easy removal.
|
||||
* Commands expose the ArtifactGraph and InstructionLoader APIs to users and agents.
|
||||
*
|
||||
* To remove this feature:
|
||||
* 1. Delete this directory
|
||||
* 2. Remove the registerArtifactWorkflowCommands() call from src/cli/index.ts
|
||||
*/
|
||||
|
||||
import type { Command } from 'commander';
|
||||
import ora from 'ora';
|
||||
|
||||
import { DEFAULT_SCHEMA } from './shared.js';
|
||||
import { statusCommand, type StatusOptions } from './status.js';
|
||||
import {
|
||||
instructionsCommand,
|
||||
applyInstructionsCommand,
|
||||
type InstructionsOptions,
|
||||
} from './instructions.js';
|
||||
import { templatesCommand, type TemplatesOptions } from './templates.js';
|
||||
import { schemasCommand, type SchemasOptions } from './schemas.js';
|
||||
import { newChangeCommand, type NewChangeOptions } from './new-change.js';
|
||||
import { artifactExperimentalSetupCommand, type ArtifactExperimentalSetupOptions } from './setup.js';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Command Registration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Registers all artifact workflow commands on the given program.
|
||||
* All commands are marked as experimental in their help text.
|
||||
*/
|
||||
export function registerArtifactWorkflowCommands(program: Command): void {
|
||||
// Status command
|
||||
program
|
||||
.command('status')
|
||||
.description('[Experimental] Display artifact completion status for a change')
|
||||
.option('--change <id>', 'Change name to show status for')
|
||||
.option('--schema <name>', 'Schema override (auto-detected from .openspec.yaml)')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options: StatusOptions) => {
|
||||
try {
|
||||
await statusCommand(options);
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Instructions command
|
||||
program
|
||||
.command('instructions [artifact]')
|
||||
.description('[Experimental] Output enriched instructions for creating an artifact or applying tasks')
|
||||
.option('--change <id>', 'Change name')
|
||||
.option('--schema <name>', 'Schema override (auto-detected from .openspec.yaml)')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (artifactId: string | undefined, options: InstructionsOptions) => {
|
||||
try {
|
||||
// Special case: "apply" is not an artifact, but a command to get apply instructions
|
||||
if (artifactId === 'apply') {
|
||||
await applyInstructionsCommand(options);
|
||||
} else {
|
||||
await instructionsCommand(artifactId, options);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Templates command
|
||||
program
|
||||
.command('templates')
|
||||
.description('[Experimental] Show resolved template paths for all artifacts in a schema')
|
||||
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
|
||||
.option('--json', 'Output as JSON mapping artifact IDs to template paths')
|
||||
.action(async (options: TemplatesOptions) => {
|
||||
try {
|
||||
await templatesCommand(options);
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Schemas command
|
||||
program
|
||||
.command('schemas')
|
||||
.description('[Experimental] List available workflow schemas with descriptions')
|
||||
.option('--json', 'Output as JSON (for agent use)')
|
||||
.action(async (options: SchemasOptions) => {
|
||||
try {
|
||||
await schemasCommand(options);
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// New command group with change subcommand
|
||||
const newCmd = program.command('new').description('[Experimental] Create new items');
|
||||
|
||||
newCmd
|
||||
.command('change <name>')
|
||||
.description('[Experimental] Create a new change directory')
|
||||
.option('--description <text>', 'Description to add to README.md')
|
||||
.option('--schema <name>', `Workflow schema to use (default: ${DEFAULT_SCHEMA})`)
|
||||
.action(async (name: string, options: NewChangeOptions) => {
|
||||
try {
|
||||
await newChangeCommand(name, options);
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Artifact experimental setup command
|
||||
program
|
||||
.command('experimental')
|
||||
.description('[Experimental] Setup Agent Skills for the experimental artifact workflow')
|
||||
.option('--tool <tool-id>', 'Target AI tool (e.g., claude, cursor, windsurf)')
|
||||
.option('--no-interactive', 'Disable interactive prompts')
|
||||
.action(async (options: ArtifactExperimentalSetupOptions) => {
|
||||
try {
|
||||
await artifactExperimentalSetupCommand(options);
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,405 +0,0 @@
|
||||
/**
|
||||
* Artifact Experimental Setup Command
|
||||
*
|
||||
* Generates Agent Skills and slash commands for the experimental artifact workflow.
|
||||
*/
|
||||
|
||||
import ora from 'ora';
|
||||
import chalk from 'chalk';
|
||||
import path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { getExploreSkillTemplate, getNewChangeSkillTemplate, getContinueChangeSkillTemplate, getApplyChangeSkillTemplate, getFfChangeSkillTemplate, getSyncSpecsSkillTemplate, getArchiveChangeSkillTemplate, getBulkArchiveChangeSkillTemplate, getVerifyChangeSkillTemplate, getOpsxExploreCommandTemplate, getOpsxNewCommandTemplate, getOpsxContinueCommandTemplate, getOpsxApplyCommandTemplate, getOpsxFfCommandTemplate, getOpsxSyncCommandTemplate, getOpsxArchiveCommandTemplate, getOpsxBulkArchiveCommandTemplate, getOpsxVerifyCommandTemplate } from '../../core/templates/skill-templates.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { isInteractive } from '../../utils/interactive.js';
|
||||
import { serializeConfig } from '../../core/config-prompts.js';
|
||||
import { AI_TOOLS } from '../../core/config.js';
|
||||
import {
|
||||
generateCommands,
|
||||
CommandAdapterRegistry,
|
||||
type CommandContent,
|
||||
} from '../../core/command-generation/index.js';
|
||||
import { DEFAULT_SCHEMA } from './shared.js';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Types
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export interface ArtifactExperimentalSetupOptions {
|
||||
tool?: string;
|
||||
interactive?: boolean;
|
||||
selectedTools?: string[]; // For multi-select from interactive prompt
|
||||
}
|
||||
|
||||
/**
|
||||
* Status of experimental skill configuration for a tool.
|
||||
*/
|
||||
interface ToolExperimentalStatus {
|
||||
/** Whether the tool has any experimental skills configured */
|
||||
configured: boolean;
|
||||
/** Whether all 9 experimental skills are configured */
|
||||
fullyConfigured: boolean;
|
||||
/** Number of skills currently configured (0-9) */
|
||||
skillCount: number;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Constants
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Names of experimental skill directories created by openspec experimental.
|
||||
*/
|
||||
const EXPERIMENTAL_SKILL_NAMES = [
|
||||
'openspec-explore',
|
||||
'openspec-new-change',
|
||||
'openspec-continue-change',
|
||||
'openspec-apply-change',
|
||||
'openspec-ff-change',
|
||||
'openspec-sync-specs',
|
||||
'openspec-archive-change',
|
||||
'openspec-bulk-archive-change',
|
||||
'openspec-verify-change',
|
||||
];
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Gets the list of tools with skillsDir configured.
|
||||
*/
|
||||
export function getToolsWithSkillsDir(): string[] {
|
||||
return AI_TOOLS.filter((t) => t.skillsDir).map((t) => t.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks which experimental skill files exist for a tool.
|
||||
*/
|
||||
function getToolExperimentalStatus(projectRoot: string, toolId: string): ToolExperimentalStatus {
|
||||
const tool = AI_TOOLS.find((t) => t.value === toolId);
|
||||
if (!tool?.skillsDir) {
|
||||
return { configured: false, fullyConfigured: false, skillCount: 0 };
|
||||
}
|
||||
|
||||
const skillsDir = path.join(projectRoot, tool.skillsDir, 'skills');
|
||||
let skillCount = 0;
|
||||
|
||||
for (const skillName of EXPERIMENTAL_SKILL_NAMES) {
|
||||
const skillFile = path.join(skillsDir, skillName, 'SKILL.md');
|
||||
if (fs.existsSync(skillFile)) {
|
||||
skillCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
configured: skillCount > 0,
|
||||
fullyConfigured: skillCount === EXPERIMENTAL_SKILL_NAMES.length,
|
||||
skillCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the experimental status for all tools with skillsDir configured.
|
||||
*/
|
||||
function getExperimentalToolStates(projectRoot: string): Map<string, ToolExperimentalStatus> {
|
||||
const states = new Map<string, ToolExperimentalStatus>();
|
||||
const toolIds = AI_TOOLS.filter((t) => t.skillsDir).map((t) => t.value);
|
||||
|
||||
for (const toolId of toolIds) {
|
||||
states.set(toolId, getToolExperimentalStatus(projectRoot, toolId));
|
||||
}
|
||||
|
||||
return states;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Command Implementation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generates Agent Skills and slash commands for the experimental artifact workflow.
|
||||
* Creates <toolDir>/skills/ directory with SKILL.md files following Agent Skills spec.
|
||||
* Creates slash commands using tool-specific adapters.
|
||||
*/
|
||||
export async function artifactExperimentalSetupCommand(options: ArtifactExperimentalSetupOptions): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
// Validate --tool flag or selectedTools is provided, or prompt interactively
|
||||
const hasToolsSpecified = options.tool || (options.selectedTools && options.selectedTools.length > 0);
|
||||
if (!hasToolsSpecified) {
|
||||
const validTools = getToolsWithSkillsDir();
|
||||
const canPrompt = isInteractive(options);
|
||||
|
||||
if (canPrompt && validTools.length > 0) {
|
||||
// Show animated welcome screen before tool selection
|
||||
const { showWelcomeScreen } = await import('../../ui/welcome-screen.js');
|
||||
await showWelcomeScreen();
|
||||
|
||||
const { searchableMultiSelect } = await import('../../prompts/searchable-multi-select.js');
|
||||
|
||||
// Get experimental status for all tools to show configured indicators
|
||||
const toolStates = getExperimentalToolStates(projectRoot);
|
||||
|
||||
// Build choices with configured status and sort configured tools first
|
||||
const sortedChoices = validTools
|
||||
.map((toolId) => {
|
||||
const tool = AI_TOOLS.find((t) => t.value === toolId);
|
||||
const status = toolStates.get(toolId);
|
||||
const configured = status?.configured ?? false;
|
||||
|
||||
return {
|
||||
name: tool?.name || toolId,
|
||||
value: toolId,
|
||||
configured,
|
||||
preSelected: configured, // Pre-select configured tools for easy refresh
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Configured tools first
|
||||
if (a.configured && !b.configured) return -1;
|
||||
if (!a.configured && b.configured) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const selectedTools = await searchableMultiSelect({
|
||||
message: `Select tools to set up (${validTools.length} available)`,
|
||||
pageSize: 15,
|
||||
choices: sortedChoices,
|
||||
validate: (selected: string[]) => selected.length > 0 || 'Select at least one tool',
|
||||
});
|
||||
|
||||
if (selectedTools.length === 0) {
|
||||
throw new Error('At least one tool must be selected');
|
||||
}
|
||||
|
||||
options.tool = selectedTools[0];
|
||||
options.selectedTools = selectedTools;
|
||||
} else {
|
||||
throw new Error(
|
||||
`Missing required option --tool. Valid tools with skill generation support:\n ${validTools.join('\n ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine tools to set up - prefer selectedTools if provided
|
||||
const toolsToSetup = options.selectedTools && options.selectedTools.length > 0
|
||||
? options.selectedTools
|
||||
: [options.tool!];
|
||||
|
||||
// Get tool states before processing to track created vs refreshed
|
||||
const preSetupStates = getExperimentalToolStates(projectRoot);
|
||||
|
||||
// Validate all tools before starting
|
||||
const validatedTools: Array<{ value: string; name: string; skillsDir: string; wasConfigured: boolean }> = [];
|
||||
for (const toolId of toolsToSetup) {
|
||||
const tool = AI_TOOLS.find((t) => t.value === toolId);
|
||||
if (!tool) {
|
||||
const validToolIds = AI_TOOLS.map((t) => t.value);
|
||||
throw new Error(
|
||||
`Unknown tool '${toolId}'. Valid tools:\n ${validToolIds.join('\n ')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!tool.skillsDir) {
|
||||
const validToolsWithSkills = getToolsWithSkillsDir();
|
||||
throw new Error(
|
||||
`Tool '${toolId}' does not support skill generation (no skillsDir configured).\nTools with skill generation support:\n ${validToolsWithSkills.join('\n ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const preState = preSetupStates.get(tool.value);
|
||||
validatedTools.push({
|
||||
value: tool.value,
|
||||
name: tool.name,
|
||||
skillsDir: tool.skillsDir,
|
||||
wasConfigured: preState?.configured ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
// Track all created files across all tools
|
||||
const allCreatedSkillFiles: string[] = [];
|
||||
const allCreatedCommandFiles: string[] = [];
|
||||
let anyCommandsSkipped = false;
|
||||
const toolsWithSkippedCommands: string[] = [];
|
||||
const failedTools: Array<{ name: string; error: Error }> = [];
|
||||
|
||||
// Get skill and command templates once (shared across all tools)
|
||||
const exploreSkill = getExploreSkillTemplate();
|
||||
const newChangeSkill = getNewChangeSkillTemplate();
|
||||
const continueChangeSkill = getContinueChangeSkillTemplate();
|
||||
const applyChangeSkill = getApplyChangeSkillTemplate();
|
||||
const ffChangeSkill = getFfChangeSkillTemplate();
|
||||
const syncSpecsSkill = getSyncSpecsSkillTemplate();
|
||||
const archiveChangeSkill = getArchiveChangeSkillTemplate();
|
||||
const bulkArchiveChangeSkill = getBulkArchiveChangeSkillTemplate();
|
||||
const verifyChangeSkill = getVerifyChangeSkillTemplate();
|
||||
|
||||
const skillTemplates = [
|
||||
{ template: exploreSkill, dirName: 'openspec-explore' },
|
||||
{ template: newChangeSkill, dirName: 'openspec-new-change' },
|
||||
{ template: continueChangeSkill, dirName: 'openspec-continue-change' },
|
||||
{ template: applyChangeSkill, dirName: 'openspec-apply-change' },
|
||||
{ template: ffChangeSkill, dirName: 'openspec-ff-change' },
|
||||
{ template: syncSpecsSkill, dirName: 'openspec-sync-specs' },
|
||||
{ template: archiveChangeSkill, dirName: 'openspec-archive-change' },
|
||||
{ template: bulkArchiveChangeSkill, dirName: 'openspec-bulk-archive-change' },
|
||||
{ template: verifyChangeSkill, dirName: 'openspec-verify-change' },
|
||||
];
|
||||
|
||||
const commandTemplates = [
|
||||
{ template: getOpsxExploreCommandTemplate(), id: 'explore' },
|
||||
{ template: getOpsxNewCommandTemplate(), id: 'new' },
|
||||
{ template: getOpsxContinueCommandTemplate(), id: 'continue' },
|
||||
{ template: getOpsxApplyCommandTemplate(), id: 'apply' },
|
||||
{ template: getOpsxFfCommandTemplate(), id: 'ff' },
|
||||
{ template: getOpsxSyncCommandTemplate(), id: 'sync' },
|
||||
{ template: getOpsxArchiveCommandTemplate(), id: 'archive' },
|
||||
{ template: getOpsxBulkArchiveCommandTemplate(), id: 'bulk-archive' },
|
||||
{ template: getOpsxVerifyCommandTemplate(), id: 'verify' },
|
||||
];
|
||||
|
||||
const commandContents: CommandContent[] = commandTemplates.map(({ template, id }) => ({
|
||||
id,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
category: template.category,
|
||||
tags: template.tags,
|
||||
body: template.content,
|
||||
}));
|
||||
|
||||
// Process each tool
|
||||
for (const tool of validatedTools) {
|
||||
const spinner = ora(`Setting up experimental artifact workflow for ${tool.name}...`).start();
|
||||
|
||||
try {
|
||||
// Use tool-specific skillsDir
|
||||
const skillsDir = path.join(projectRoot, tool.skillsDir, 'skills');
|
||||
|
||||
// Create skill directories and SKILL.md files
|
||||
for (const { template, dirName } of skillTemplates) {
|
||||
const skillDir = path.join(skillsDir, dirName);
|
||||
const skillFile = path.join(skillDir, 'SKILL.md');
|
||||
|
||||
// Generate SKILL.md content with YAML frontmatter
|
||||
const skillContent = `---
|
||||
name: ${template.name}
|
||||
description: ${template.description}
|
||||
license: ${template.license || 'MIT'}
|
||||
compatibility: ${template.compatibility || 'Requires openspec CLI.'}
|
||||
metadata:
|
||||
author: ${template.metadata?.author || 'openspec'}
|
||||
version: "${template.metadata?.version || '1.0'}"
|
||||
---
|
||||
|
||||
${template.instructions}
|
||||
`;
|
||||
|
||||
// Write the skill file
|
||||
await FileSystemUtils.writeFile(skillFile, skillContent);
|
||||
allCreatedSkillFiles.push(path.relative(projectRoot, skillFile));
|
||||
}
|
||||
|
||||
// Generate commands using the adapter system
|
||||
const adapter = CommandAdapterRegistry.get(tool.value);
|
||||
if (adapter) {
|
||||
const generatedCommands = generateCommands(commandContents, adapter);
|
||||
|
||||
for (const cmd of generatedCommands) {
|
||||
const commandFile = path.join(projectRoot, cmd.path);
|
||||
await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
|
||||
allCreatedCommandFiles.push(cmd.path);
|
||||
}
|
||||
} else {
|
||||
anyCommandsSkipped = true;
|
||||
toolsWithSkippedCommands.push(tool.value);
|
||||
}
|
||||
|
||||
spinner.succeed(`Setup complete for ${tool.name}!`);
|
||||
} catch (error) {
|
||||
spinner.fail(`Failed for ${tool.name}`);
|
||||
failedTools.push({ name: tool.name, error: error as Error });
|
||||
}
|
||||
}
|
||||
|
||||
// If all tools failed, throw an error
|
||||
if (failedTools.length === validatedTools.length) {
|
||||
const errorMessages = failedTools.map(f => ` ${f.name}: ${f.error.message}`).join('\n');
|
||||
throw new Error(`All tools failed to set up:\n${errorMessages}`);
|
||||
}
|
||||
|
||||
// Filter to only successfully configured tools
|
||||
const successfulTools = validatedTools.filter(t => !failedTools.some(f => f.name === t.name));
|
||||
|
||||
// Print success summary
|
||||
console.log();
|
||||
console.log(chalk.bold('Experimental Artifact Workflow Setup Complete'));
|
||||
console.log();
|
||||
|
||||
// Tools and counts (show unique counts, not total files across all tools)
|
||||
if (successfulTools.length > 0) {
|
||||
// Separate newly created tools from refreshed (previously configured) tools
|
||||
const createdTools = successfulTools.filter(t => !t.wasConfigured);
|
||||
const refreshedTools = successfulTools.filter(t => t.wasConfigured);
|
||||
|
||||
if (createdTools.length > 0) {
|
||||
console.log(`Created: ${createdTools.map(t => t.name).join(', ')}`);
|
||||
}
|
||||
if (refreshedTools.length > 0) {
|
||||
console.log(`Refreshed: ${refreshedTools.map(t => t.name).join(', ')}`);
|
||||
}
|
||||
|
||||
const uniqueSkillCount = skillTemplates.length;
|
||||
const uniqueCommandCount = commandContents.length;
|
||||
const toolDirs = [...new Set(successfulTools.map(t => t.skillsDir))].join(', ');
|
||||
// Only count commands if any were actually created (some tools may not have adapters)
|
||||
const hasCommands = allCreatedCommandFiles.length > 0;
|
||||
if (hasCommands) {
|
||||
console.log(`${uniqueSkillCount} skills and ${uniqueCommandCount} commands in ${toolDirs}/`);
|
||||
} else {
|
||||
console.log(`${uniqueSkillCount} skills in ${toolDirs}/`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failedTools.length > 0) {
|
||||
console.log(chalk.red(`Failed: ${failedTools.map(f => `${f.name} (${f.error.message})`).join(', ')}`));
|
||||
}
|
||||
|
||||
if (anyCommandsSkipped) {
|
||||
console.log(chalk.dim(`Commands skipped for: ${toolsWithSkippedCommands.join(', ')} (no adapter)`));
|
||||
}
|
||||
|
||||
// Config creation (simplified)
|
||||
const configPath = path.join(projectRoot, 'openspec', 'config.yaml');
|
||||
const configYmlPath = path.join(projectRoot, 'openspec', 'config.yml');
|
||||
const configYamlExists = fs.existsSync(configPath);
|
||||
const configYmlExists = fs.existsSync(configYmlPath);
|
||||
const configExists = configYamlExists || configYmlExists;
|
||||
|
||||
if (configExists) {
|
||||
const existingConfigName = configYamlExists ? 'config.yaml' : 'config.yml';
|
||||
console.log(`Config: openspec/${existingConfigName} (exists)`);
|
||||
} else if (!isInteractive(options)) {
|
||||
console.log(chalk.dim(`Config: skipped (non-interactive mode)`));
|
||||
} else {
|
||||
const yamlContent = serializeConfig({ schema: DEFAULT_SCHEMA });
|
||||
try {
|
||||
await FileSystemUtils.writeFile(configPath, yamlContent);
|
||||
console.log(`Config: openspec/config.yaml (schema: ${DEFAULT_SCHEMA})`);
|
||||
} catch (writeError) {
|
||||
console.log(chalk.red(`Config: failed to create (${(writeError as Error).message})`));
|
||||
}
|
||||
}
|
||||
|
||||
// Getting started
|
||||
console.log();
|
||||
console.log(chalk.bold('Getting started:'));
|
||||
console.log(' /opsx:new Start a new change');
|
||||
console.log(' /opsx:continue Create the next artifact');
|
||||
console.log(' /opsx:apply Implement tasks');
|
||||
|
||||
// Links
|
||||
console.log();
|
||||
console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec/blob/main/docs/experimental-workflow.md')}`);
|
||||
console.log(`Feedback: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec/issues')}`);
|
||||
console.log();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Workflow CLI Commands
|
||||
*
|
||||
* Commands for the artifact-driven workflow: status, instructions, templates, schemas, new change.
|
||||
*/
|
||||
|
||||
export { statusCommand } from './status.js';
|
||||
export type { StatusOptions } from './status.js';
|
||||
|
||||
export { instructionsCommand, applyInstructionsCommand } from './instructions.js';
|
||||
export type { InstructionsOptions } from './instructions.js';
|
||||
|
||||
export { templatesCommand } from './templates.js';
|
||||
export type { TemplatesOptions } from './templates.js';
|
||||
|
||||
export { schemasCommand } from './schemas.js';
|
||||
export type { SchemasOptions } from './schemas.js';
|
||||
|
||||
export { newChangeCommand } from './new-change.js';
|
||||
export type { NewChangeOptions } from './new-change.js';
|
||||
|
||||
export { DEFAULT_SCHEMA } from './shared.js';
|
||||
@@ -1,23 +0,0 @@
|
||||
import path from 'path';
|
||||
import { ToolConfigurator } from './base.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { TemplateManager } from '../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../config.js';
|
||||
|
||||
export class AgentsStandardConfigurator implements ToolConfigurator {
|
||||
name = 'AGENTS.md standard';
|
||||
configFileName = 'AGENTS.md';
|
||||
isAvailable = true;
|
||||
|
||||
async configure(projectPath: string, _openspecDir: string): Promise<void> {
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
const content = TemplateManager.getAgentsStandardTemplate();
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export interface ToolConfigurator {
|
||||
name: string;
|
||||
configFileName: string;
|
||||
isAvailable: boolean;
|
||||
configure(projectPath: string, openspecDir: string): Promise<void>;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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 ClaudeConfigurator implements ToolConfigurator {
|
||||
name = 'Claude Code';
|
||||
configFileName = 'CLAUDE.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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* 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,49 +0,0 @@
|
||||
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 {
|
||||
this.tools.set(tool.name.toLowerCase().replace(/\s+/g, '-'), tool);
|
||||
}
|
||||
|
||||
static get(toolId: string): ToolConfigurator | undefined {
|
||||
return this.tools.get(toolId);
|
||||
}
|
||||
|
||||
static getAll(): ToolConfigurator[] {
|
||||
return Array.from(this.tools.values());
|
||||
}
|
||||
|
||||
static getAvailable(): ToolConfigurator[] {
|
||||
return this.getAll().filter(tool => tool.isAvailable);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.amazonq/prompts/openspec-proposal.md',
|
||||
apply: '.amazonq/prompts/openspec-apply.md',
|
||||
archive: '.amazonq/prompts/openspec-archive.md'
|
||||
};
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
---
|
||||
|
||||
The user has requested the following change proposal. Use the openspec instructions to create their change proposal.
|
||||
|
||||
<UserRequest>
|
||||
$ARGUMENTS
|
||||
</UserRequest>`,
|
||||
apply: `---
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
---
|
||||
|
||||
The user wants to apply the following change. Use the openspec instructions to implement the approved change.
|
||||
|
||||
<ChangeId>
|
||||
$ARGUMENTS
|
||||
</ChangeId>`,
|
||||
archive: `---
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
---
|
||||
|
||||
The user wants to archive the following deployed change. Use the openspec instructions to archive the change and update specs.
|
||||
|
||||
<ChangeId>
|
||||
$ARGUMENTS
|
||||
</ChangeId>`
|
||||
};
|
||||
|
||||
export class AmazonQSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'amazon-q';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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---`;
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.augment/commands/openspec-proposal.md',
|
||||
apply: '.augment/commands/openspec-apply.md',
|
||||
archive: '.augment/commands/openspec-archive.md'
|
||||
};
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
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
|
||||
---`
|
||||
};
|
||||
|
||||
export class AuggieSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'auggie';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { FileSystemUtils } from '../../../utils/file-system.js';
|
||||
import { TemplateManager, SlashCommandId } from '../../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../../config.js';
|
||||
|
||||
export interface SlashCommandTarget {
|
||||
id: SlashCommandId;
|
||||
path: string;
|
||||
kind: 'slash';
|
||||
}
|
||||
|
||||
const ALL_COMMANDS: SlashCommandId[] = ['proposal', 'apply', 'archive'];
|
||||
|
||||
export abstract class SlashCommandConfigurator {
|
||||
abstract readonly toolId: string;
|
||||
abstract readonly isAvailable: boolean;
|
||||
|
||||
getTargets(): SlashCommandTarget[] {
|
||||
return ALL_COMMANDS.map((id) => ({
|
||||
id,
|
||||
path: this.getRelativePath(id),
|
||||
kind: 'slash'
|
||||
}));
|
||||
}
|
||||
|
||||
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 frontmatter = this.getFrontmatter(target.id);
|
||||
const sections: string[] = [];
|
||||
if (frontmatter) {
|
||||
sections.push(frontmatter.trim());
|
||||
}
|
||||
sections.push(`${OPENSPEC_MARKERS.start}\n${body}\n${OPENSPEC_MARKERS.end}`);
|
||||
const content = sections.join('\n') + '\n';
|
||||
await FileSystemUtils.writeFile(filePath, content);
|
||||
}
|
||||
|
||||
createdOrUpdated.push(target.path);
|
||||
}
|
||||
|
||||
return createdOrUpdated;
|
||||
}
|
||||
|
||||
async updateExisting(projectPath: string, _openspecDir: string): Promise<string[]> {
|
||||
const updated: string[] = [];
|
||||
|
||||
for (const target of this.getTargets()) {
|
||||
const filePath = FileSystemUtils.joinPath(projectPath, target.path);
|
||||
if (await FileSystemUtils.fileExists(filePath)) {
|
||||
const body = this.getBody(target.id);
|
||||
await this.updateBody(filePath, body);
|
||||
updated.push(target.path);
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
protected abstract getRelativePath(id: SlashCommandId): string;
|
||||
protected abstract getFrontmatter(id: SlashCommandId): string | undefined;
|
||||
|
||||
protected getBody(id: SlashCommandId): string {
|
||||
return TemplateManager.getSlashCommandBody(id).trim();
|
||||
}
|
||||
|
||||
// Resolve absolute path for a given slash command target. Subclasses may override
|
||||
// to redirect to tool-specific locations (e.g., global directories).
|
||||
resolveAbsolutePath(projectPath: string, id: SlashCommandId): string {
|
||||
const rel = this.getRelativePath(id);
|
||||
return FileSystemUtils.joinPath(projectPath, rel);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.claude/commands/openspec/proposal.md',
|
||||
apply: '.claude/commands/openspec/apply.md',
|
||||
archive: '.claude/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 ClaudeSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'claude';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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."
|
||||
argument-hint: "[feature description or request]"
|
||||
---`,
|
||||
apply: `---
|
||||
name: OpenSpec: Apply
|
||||
description: "Implement an approved OpenSpec change and keep tasks in sync."
|
||||
argument-hint: "[change-id]"
|
||||
---`,
|
||||
archive: `---
|
||||
name: OpenSpec: Archive
|
||||
description: "Archive a deployed OpenSpec change and update specs."
|
||||
argument-hint: "[change-id]"
|
||||
---`
|
||||
};
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { SlashCommandConfigurator } from "./base.js";
|
||||
import { SlashCommandId, TemplateManager } from "../../templates/index.js";
|
||||
import { FileSystemUtils } from "../../../utils/file-system.js";
|
||||
import { OPENSPEC_MARKERS } from "../../config.js";
|
||||
|
||||
// Use POSIX-style paths for consistent logging across platforms.
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: ".codex/prompts/openspec-proposal.md",
|
||||
apply: ".codex/prompts/openspec-apply.md",
|
||||
archive: ".codex/prompts/openspec-archive.md",
|
||||
};
|
||||
|
||||
export class CodexSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = "codex";
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string | undefined {
|
||||
// Codex supports YAML frontmatter with description and argument-hint fields,
|
||||
// plus $ARGUMENTS to capture all arguments as a single string.
|
||||
const frontmatter: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
argument-hint: request or feature description
|
||||
---
|
||||
|
||||
$ARGUMENTS`,
|
||||
apply: `---
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
argument-hint: change-id
|
||||
---
|
||||
|
||||
$ARGUMENTS`,
|
||||
archive: `---
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
argument-hint: change-id
|
||||
---
|
||||
|
||||
$ARGUMENTS`,
|
||||
};
|
||||
return frontmatter[id];
|
||||
}
|
||||
|
||||
private getGlobalPromptsDir(): string {
|
||||
const home = (process.env.CODEX_HOME && process.env.CODEX_HOME.trim())
|
||||
? process.env.CODEX_HOME.trim()
|
||||
: FileSystemUtils.joinPath(os.homedir(), ".codex");
|
||||
return FileSystemUtils.joinPath(home, "prompts");
|
||||
}
|
||||
|
||||
// Codex discovers prompts globally. Generate directly in the global directory
|
||||
// and wrap shared body with markers.
|
||||
async generateAll(projectPath: string, _openspecDir: string): Promise<string[]> {
|
||||
const createdOrUpdated: string[] = [];
|
||||
for (const target of this.getTargets()) {
|
||||
const body = TemplateManager.getSlashCommandBody(target.id).trim();
|
||||
const promptsDir = this.getGlobalPromptsDir();
|
||||
const filePath = FileSystemUtils.joinPath(
|
||||
promptsDir,
|
||||
path.basename(target.path)
|
||||
);
|
||||
|
||||
await FileSystemUtils.createDirectory(path.dirname(filePath));
|
||||
|
||||
if (await FileSystemUtils.fileExists(filePath)) {
|
||||
await this.updateFullFile(filePath, target.id, body);
|
||||
} else {
|
||||
const frontmatter = this.getFrontmatter(target.id);
|
||||
const sections: string[] = [];
|
||||
if (frontmatter) sections.push(frontmatter.trim());
|
||||
sections.push(`${OPENSPEC_MARKERS.start}\n${body}\n${OPENSPEC_MARKERS.end}`);
|
||||
await FileSystemUtils.writeFile(filePath, sections.join("\n") + "\n");
|
||||
}
|
||||
|
||||
createdOrUpdated.push(target.path);
|
||||
}
|
||||
return createdOrUpdated;
|
||||
}
|
||||
|
||||
async updateExisting(projectPath: string, _openspecDir: string): Promise<string[]> {
|
||||
const updated: string[] = [];
|
||||
for (const target of this.getTargets()) {
|
||||
const promptsDir = this.getGlobalPromptsDir();
|
||||
const filePath = FileSystemUtils.joinPath(
|
||||
promptsDir,
|
||||
path.basename(target.path)
|
||||
);
|
||||
if (await FileSystemUtils.fileExists(filePath)) {
|
||||
const body = TemplateManager.getSlashCommandBody(target.id).trim();
|
||||
await this.updateFullFile(filePath, target.id, body);
|
||||
updated.push(target.path);
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Update both frontmatter and body in an existing file
|
||||
private async updateFullFile(filePath: string, id: SlashCommandId, body: string): Promise<void> {
|
||||
const content = await FileSystemUtils.readFile(filePath);
|
||||
const startIndex = content.indexOf(OPENSPEC_MARKERS.start);
|
||||
|
||||
if (startIndex === -1) {
|
||||
throw new Error(`Missing OpenSpec start marker in ${filePath}`);
|
||||
}
|
||||
|
||||
// Replace everything before the start marker with the new frontmatter
|
||||
const frontmatter = this.getFrontmatter(id);
|
||||
const sections: string[] = [];
|
||||
if (frontmatter) sections.push(frontmatter.trim());
|
||||
sections.push(`${OPENSPEC_MARKERS.start}\n${body}\n${OPENSPEC_MARKERS.end}`);
|
||||
|
||||
await FileSystemUtils.writeFile(filePath, sections.join("\n") + "\n");
|
||||
}
|
||||
|
||||
// Resolve to the global prompts location for configuration detection
|
||||
resolveAbsolutePath(_projectPath: string, id: SlashCommandId): string {
|
||||
const promptsDir = this.getGlobalPromptsDir();
|
||||
const fileName = path.basename(FILE_PATHS[id]);
|
||||
return FileSystemUtils.joinPath(promptsDir, fileName);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.continue/prompts/openspec-proposal.prompt',
|
||||
apply: '.continue/prompts/openspec-apply.prompt',
|
||||
archive: '.continue/prompts/openspec-archive.prompt'
|
||||
};
|
||||
|
||||
/*
|
||||
* Continue .prompt format requires YAML frontmatter:
|
||||
* ---
|
||||
* name: commandName
|
||||
* description: description
|
||||
* invokable: true
|
||||
* ---
|
||||
* Body...
|
||||
*
|
||||
* The 'invokable: true' field is required to make the prompt available as a slash command.
|
||||
* We use 'openspec-proposal' as the name so the command becomes /openspec-proposal.
|
||||
*/
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
name: openspec-proposal
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
invokable: true
|
||||
---`,
|
||||
apply: `---
|
||||
name: openspec-apply
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
invokable: true
|
||||
---`,
|
||||
archive: `---
|
||||
name: openspec-archive
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
invokable: true
|
||||
---`
|
||||
};
|
||||
|
||||
export class ContinueSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'continue';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.crush/commands/openspec/proposal.md',
|
||||
apply: '.crush/commands/openspec/apply.md',
|
||||
archive: '.crush/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 CrushSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'crush';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.cursor/commands/openspec-proposal.md',
|
||||
apply: '.cursor/commands/openspec-apply.md',
|
||||
archive: '.cursor/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 CursorSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'cursor';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.factory/commands/openspec-proposal.md',
|
||||
apply: '.factory/commands/openspec-apply.md',
|
||||
archive: '.factory/commands/openspec-archive.md'
|
||||
};
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
argument-hint: request or feature description
|
||||
---`,
|
||||
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
|
||||
---`
|
||||
};
|
||||
|
||||
export class FactorySlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'factory';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
|
||||
protected getBody(id: SlashCommandId): string {
|
||||
const baseBody = super.getBody(id);
|
||||
return `${baseBody}\n\n$ARGUMENTS`;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.github/prompts/openspec-proposal.prompt.md',
|
||||
apply: '.github/prompts/openspec-apply.prompt.md',
|
||||
archive: '.github/prompts/openspec-archive.prompt.md'
|
||||
};
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
---
|
||||
|
||||
$ARGUMENTS`,
|
||||
apply: `---
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
---
|
||||
|
||||
$ARGUMENTS`,
|
||||
archive: `---
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
---
|
||||
|
||||
$ARGUMENTS`
|
||||
};
|
||||
|
||||
export class GitHubCopilotSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'github-copilot';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { SlashCommandConfigurator } from "./base.js";
|
||||
import { SlashCommandId } from "../../templates/index.js";
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: ".kilocode/workflows/openspec-proposal.md",
|
||||
apply: ".kilocode/workflows/openspec-apply.md",
|
||||
archive: ".kilocode/workflows/openspec-archive.md"
|
||||
};
|
||||
|
||||
export class KiloCodeSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = "kilocode";
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(_id: SlashCommandId): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { SlashCommandConfigurator } from "./base.js";
|
||||
import { SlashCommandId } from "../../templates/index.js";
|
||||
import { FileSystemUtils } from "../../../utils/file-system.js";
|
||||
import { OPENSPEC_MARKERS } from "../../config.js";
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: ".opencode/command/openspec-proposal.md",
|
||||
apply: ".opencode/command/openspec-apply.md",
|
||||
archive: ".opencode/command/openspec-archive.md",
|
||||
};
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
---
|
||||
The user has requested the following change proposal. Use the openspec instructions to create their change proposal.
|
||||
<UserRequest>
|
||||
$ARGUMENTS
|
||||
</UserRequest>
|
||||
`,
|
||||
apply: `---
|
||||
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: `---
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
---
|
||||
<ChangeId>
|
||||
$ARGUMENTS
|
||||
</ChangeId>
|
||||
`,
|
||||
};
|
||||
|
||||
export class OpenCodeSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = "opencode";
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string | undefined {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
|
||||
async generateAll(projectPath: string, _openspecDir: string): Promise<string[]> {
|
||||
const createdOrUpdated = await super.generateAll(projectPath, _openspecDir);
|
||||
await this.rewriteArchiveFile(projectPath);
|
||||
return createdOrUpdated;
|
||||
}
|
||||
|
||||
async updateExisting(projectPath: string, _openspecDir: string): Promise<string[]> {
|
||||
const updated = await super.updateExisting(projectPath, _openspecDir);
|
||||
const rewroteArchive = await this.rewriteArchiveFile(projectPath);
|
||||
if (rewroteArchive && !updated.includes(FILE_PATHS.archive)) {
|
||||
updated.push(FILE_PATHS.archive);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async rewriteArchiveFile(projectPath: string): Promise<boolean> {
|
||||
const archivePath = FileSystemUtils.joinPath(projectPath, FILE_PATHS.archive);
|
||||
if (!await FileSystemUtils.fileExists(archivePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = this.getBody("archive");
|
||||
const frontmatter = this.getFrontmatter("archive");
|
||||
const sections: string[] = [];
|
||||
|
||||
if (frontmatter) {
|
||||
sections.push(frontmatter.trim());
|
||||
}
|
||||
|
||||
sections.push(`${OPENSPEC_MARKERS.start}\n${body}\n${OPENSPEC_MARKERS.end}`);
|
||||
await FileSystemUtils.writeFile(archivePath, sections.join("\n") + "\n");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* 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,84 +0,0 @@
|
||||
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';
|
||||
import { OpenCodeSlashCommandConfigurator } from './opencode.js';
|
||||
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';
|
||||
import { ContinueSlashCommandConfigurator } from './continue.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();
|
||||
const opencode = new OpenCodeSlashCommandConfigurator();
|
||||
const codex = new CodexSlashCommandConfigurator();
|
||||
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();
|
||||
const continueTool = new ContinueSlashCommandConfigurator();
|
||||
|
||||
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);
|
||||
this.configurators.set(opencode.toolId, opencode);
|
||||
this.configurators.set(codex.toolId, codex);
|
||||
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);
|
||||
this.configurators.set(continueTool.toolId, continueTool);
|
||||
}
|
||||
|
||||
static register(configurator: SlashCommandConfigurator): void {
|
||||
this.configurators.set(configurator.toolId, configurator);
|
||||
}
|
||||
|
||||
static get(toolId: string): SlashCommandConfigurator | undefined {
|
||||
return this.configurators.get(toolId);
|
||||
}
|
||||
|
||||
static getAll(): SlashCommandConfigurator[] {
|
||||
return Array.from(this.configurators.values());
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.windsurf/workflows/openspec-proposal.md',
|
||||
apply: '.windsurf/workflows/openspec-apply.md',
|
||||
archive: '.windsurf/workflows/openspec-archive.md'
|
||||
};
|
||||
|
||||
export class WindsurfSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'windsurf';
|
||||
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 `---\ndescription: ${description}\nauto_execution_mode: 3\n---`;
|
||||
}
|
||||
}
|
||||
+399
-475
File diff suppressed because it is too large
Load Diff
@@ -1,379 +0,0 @@
|
||||
import chalk from 'chalk';
|
||||
import { PALETTE } from '../styles/palette.js';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// CONSTANTS
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
export const LETTER_MAP: Record<string, string[]> = {
|
||||
O: [' ████ ', '██ ██', '██ ██', '██ ██', ' ████ '],
|
||||
P: ['█████ ', '██ ██', '█████ ', '██ ', '██ '],
|
||||
E: ['██████', '██ ', '█████ ', '██ ', '██████'],
|
||||
N: ['██ ██', '███ ██', '██ ███', '██ ██', '██ ██'],
|
||||
S: [' █████', '██ ', ' ████ ', ' ██', '█████ '],
|
||||
C: [' █████', '██ ', '██ ', '██ ', ' █████'],
|
||||
' ': [' ', ' ', ' ', ' ', ' '],
|
||||
};
|
||||
|
||||
export const ROOT_STUB_CHOICE_VALUE = '__root_stub__';
|
||||
export const OTHER_TOOLS_HEADING_VALUE = '__heading-other__';
|
||||
export const LIST_SPACER_VALUE = '__list-spacer__';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// TYPES
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
export type ToolLabel = {
|
||||
primary: string;
|
||||
annotation?: string;
|
||||
};
|
||||
|
||||
export type ToolWizardChoice =
|
||||
| {
|
||||
kind: 'heading' | 'info';
|
||||
value: string;
|
||||
label: ToolLabel;
|
||||
selectable: false;
|
||||
}
|
||||
| {
|
||||
kind: 'option';
|
||||
value: string;
|
||||
label: ToolLabel;
|
||||
configured: boolean;
|
||||
selectable: true;
|
||||
};
|
||||
|
||||
export type ToolWizardConfig = {
|
||||
extendMode: boolean;
|
||||
baseMessage: string;
|
||||
choices: ToolWizardChoice[];
|
||||
initialSelected?: string[];
|
||||
};
|
||||
|
||||
export type WizardStep = 'intro' | 'select' | 'review';
|
||||
|
||||
export type ToolSelectionPrompt = (config: ToolWizardConfig) => Promise<string[]>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// HELPERS
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
export const sanitizeToolLabel = (raw: string): string =>
|
||||
raw.replace(/✅/gu, '✔').trim();
|
||||
|
||||
export const parseToolLabel = (raw: string): ToolLabel => {
|
||||
const sanitized = sanitizeToolLabel(raw);
|
||||
const match = sanitized.match(/^(.*?)\s*\((.+)\)$/u);
|
||||
if (!match) {
|
||||
return { primary: sanitized };
|
||||
}
|
||||
return {
|
||||
primary: match[1].trim(),
|
||||
annotation: match[2].trim(),
|
||||
};
|
||||
};
|
||||
|
||||
export const isSelectableChoice = (
|
||||
choice: ToolWizardChoice
|
||||
): choice is Extract<ToolWizardChoice, { selectable: true }> => choice.selectable;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// WIZARD PROMPT
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
// Singleton cache for the dynamically created prompt
|
||||
let toolSelectionWizardPromptCached: ((config: ToolWizardConfig) => Promise<string[]>) | null = null;
|
||||
|
||||
/**
|
||||
* Run the tool selection wizard prompt.
|
||||
* This function lazily initializes the prompt on first call by dynamically
|
||||
* importing @inquirer/core to avoid static import overhead.
|
||||
*/
|
||||
export async function toolSelectionWizard(config: ToolWizardConfig): Promise<string[]> {
|
||||
if (!toolSelectionWizardPromptCached) {
|
||||
const {
|
||||
createPrompt,
|
||||
useKeypress,
|
||||
usePagination,
|
||||
useState,
|
||||
isEnterKey,
|
||||
isSpaceKey,
|
||||
isUpKey,
|
||||
isDownKey,
|
||||
isBackspaceKey,
|
||||
} = await import('@inquirer/core');
|
||||
|
||||
toolSelectionWizardPromptCached = createPrompt<string[], ToolWizardConfig>(
|
||||
(promptConfig, done) => {
|
||||
const totalSteps = 3;
|
||||
const [step, setStep] = useState<WizardStep>('intro');
|
||||
const selectableChoices = promptConfig.choices.filter(isSelectableChoice);
|
||||
const initialCursorIndex = promptConfig.choices.findIndex((choice) =>
|
||||
choice.selectable
|
||||
);
|
||||
const [cursor, setCursor] = useState<number>(
|
||||
initialCursorIndex === -1 ? 0 : initialCursorIndex
|
||||
);
|
||||
const [selected, setSelected] = useState<string[]>(() => {
|
||||
const initial = new Set(
|
||||
(promptConfig.initialSelected ?? []).filter((value) =>
|
||||
selectableChoices.some((choice) => choice.value === value)
|
||||
)
|
||||
);
|
||||
return selectableChoices
|
||||
.map((choice) => choice.value)
|
||||
.filter((value) => initial.has(value));
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selectedSet = new Set(selected);
|
||||
const pageSize = Math.max(promptConfig.choices.length, 1);
|
||||
|
||||
const updateSelected = (next: Set<string>) => {
|
||||
const ordered = selectableChoices
|
||||
.map((choice) => choice.value)
|
||||
.filter((value) => next.has(value));
|
||||
setSelected(ordered);
|
||||
};
|
||||
|
||||
const page = usePagination({
|
||||
items: promptConfig.choices,
|
||||
active: cursor,
|
||||
pageSize,
|
||||
loop: false,
|
||||
renderItem: ({ item, isActive }) => {
|
||||
if (!item.selectable) {
|
||||
const prefix = item.kind === 'info' ? ' ' : '';
|
||||
const textColor =
|
||||
item.kind === 'heading' ? PALETTE.lightGray : PALETTE.midGray;
|
||||
return `${PALETTE.midGray(' ')} ${PALETTE.midGray(' ')} ${textColor(
|
||||
`${prefix}${item.label.primary}`
|
||||
)}`;
|
||||
}
|
||||
|
||||
const isSelected = selectedSet.has(item.value);
|
||||
const cursorSymbol = isActive
|
||||
? PALETTE.white('›')
|
||||
: PALETTE.midGray(' ');
|
||||
const indicator = isSelected
|
||||
? PALETTE.white('◉')
|
||||
: PALETTE.midGray('○');
|
||||
const nameColor = isActive ? PALETTE.white : PALETTE.midGray;
|
||||
const annotation = item.label.annotation
|
||||
? PALETTE.midGray(` (${item.label.annotation})`)
|
||||
: '';
|
||||
const configuredNote = item.configured
|
||||
? PALETTE.midGray(' (already configured)')
|
||||
: '';
|
||||
const label = `${nameColor(item.label.primary)}${annotation}${configuredNote}`;
|
||||
return `${cursorSymbol} ${indicator} ${label}`;
|
||||
},
|
||||
});
|
||||
|
||||
const moveCursor = (direction: 1 | -1) => {
|
||||
if (selectableChoices.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nextIndex = cursor;
|
||||
while (true) {
|
||||
nextIndex = nextIndex + direction;
|
||||
if (nextIndex < 0 || nextIndex >= promptConfig.choices.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (promptConfig.choices[nextIndex]?.selectable) {
|
||||
setCursor(nextIndex);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useKeypress((key) => {
|
||||
if (step === 'intro') {
|
||||
if (isEnterKey(key)) {
|
||||
setStep('select');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (step === 'select') {
|
||||
if (isUpKey(key)) {
|
||||
moveCursor(-1);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDownKey(key)) {
|
||||
moveCursor(1);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSpaceKey(key)) {
|
||||
const current = promptConfig.choices[cursor];
|
||||
if (!current || !current.selectable) return;
|
||||
|
||||
const next = new Set(selected);
|
||||
if (next.has(current.value)) {
|
||||
next.delete(current.value);
|
||||
} else {
|
||||
next.add(current.value);
|
||||
}
|
||||
|
||||
updateSelected(next);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEnterKey(key)) {
|
||||
const current = promptConfig.choices[cursor];
|
||||
if (
|
||||
current &&
|
||||
current.selectable &&
|
||||
!selectedSet.has(current.value)
|
||||
) {
|
||||
const next = new Set(selected);
|
||||
next.add(current.value);
|
||||
updateSelected(next);
|
||||
}
|
||||
setStep('review');
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === 'escape') {
|
||||
const next = new Set<string>();
|
||||
updateSelected(next);
|
||||
setError(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (step === 'review') {
|
||||
if (isEnterKey(key)) {
|
||||
const finalSelection = promptConfig.choices
|
||||
.map((choice) => choice.value)
|
||||
.filter(
|
||||
(value) =>
|
||||
selectedSet.has(value) && value !== ROOT_STUB_CHOICE_VALUE
|
||||
);
|
||||
done(finalSelection);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBackspaceKey(key) || key.name === 'escape') {
|
||||
setStep('select');
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const rootStubChoice = selectableChoices.find(
|
||||
(choice) => choice.value === ROOT_STUB_CHOICE_VALUE
|
||||
);
|
||||
const rootStubSelected = rootStubChoice
|
||||
? selectedSet.has(ROOT_STUB_CHOICE_VALUE)
|
||||
: false;
|
||||
const nativeChoices = selectableChoices.filter(
|
||||
(choice) => choice.value !== ROOT_STUB_CHOICE_VALUE
|
||||
);
|
||||
const selectedNativeChoices = nativeChoices.filter((choice) =>
|
||||
selectedSet.has(choice.value)
|
||||
);
|
||||
|
||||
const formatSummaryLabel = (
|
||||
choice: Extract<ToolWizardChoice, { selectable: true }>
|
||||
) => {
|
||||
const annotation = choice.label.annotation
|
||||
? PALETTE.midGray(` (${choice.label.annotation})`)
|
||||
: '';
|
||||
const configuredNote = choice.configured
|
||||
? PALETTE.midGray(' (already configured)')
|
||||
: '';
|
||||
return `${PALETTE.white(choice.label.primary)}${annotation}${configuredNote}`;
|
||||
};
|
||||
|
||||
const stepIndex = step === 'intro' ? 1 : step === 'select' ? 2 : 3;
|
||||
const lines: string[] = [];
|
||||
lines.push(PALETTE.midGray(`Step ${stepIndex}/${totalSteps}`));
|
||||
lines.push('');
|
||||
|
||||
if (step === 'intro') {
|
||||
const introHeadline = promptConfig.extendMode
|
||||
? 'Extend your OpenSpec tooling'
|
||||
: 'Configure your OpenSpec tooling';
|
||||
const introBody = promptConfig.extendMode
|
||||
? 'We detected an existing setup. We will help you refresh or add integrations.'
|
||||
: "Let's get your AI assistants connected so they understand OpenSpec.";
|
||||
|
||||
lines.push(PALETTE.white(introHeadline));
|
||||
lines.push(PALETTE.midGray(introBody));
|
||||
lines.push('');
|
||||
lines.push(PALETTE.midGray('Press Enter to continue.'));
|
||||
} else if (step === 'select') {
|
||||
lines.push(PALETTE.white(promptConfig.baseMessage));
|
||||
lines.push(
|
||||
PALETTE.midGray(
|
||||
'Use ↑/↓ to move · Space to toggle · Enter selects highlighted tool and reviews.'
|
||||
)
|
||||
);
|
||||
lines.push('');
|
||||
lines.push(page);
|
||||
lines.push('');
|
||||
lines.push(PALETTE.midGray('Selected configuration:'));
|
||||
if (rootStubSelected && rootStubChoice) {
|
||||
lines.push(
|
||||
` ${PALETTE.white('-')} ${formatSummaryLabel(rootStubChoice)}`
|
||||
);
|
||||
}
|
||||
if (selectedNativeChoices.length === 0) {
|
||||
lines.push(
|
||||
` ${PALETTE.midGray('- No natively supported providers selected')}`
|
||||
);
|
||||
} else {
|
||||
selectedNativeChoices.forEach((choice) => {
|
||||
lines.push(
|
||||
` ${PALETTE.white('-')} ${formatSummaryLabel(choice)}`
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
lines.push(PALETTE.white('Review selections'));
|
||||
lines.push(
|
||||
PALETTE.midGray('Press Enter to confirm or Backspace to adjust.')
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
if (rootStubSelected && rootStubChoice) {
|
||||
lines.push(
|
||||
`${PALETTE.white('▌')} ${formatSummaryLabel(rootStubChoice)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedNativeChoices.length === 0) {
|
||||
lines.push(
|
||||
PALETTE.midGray(
|
||||
'No natively supported providers selected. Universal instructions will still be applied.'
|
||||
)
|
||||
);
|
||||
} else {
|
||||
selectedNativeChoices.forEach((choice) => {
|
||||
lines.push(
|
||||
`${PALETTE.white('▌')} ${formatSummaryLabel(choice)}`
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return [lines.join('\n'), chalk.red(error)];
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return toolSelectionWizardPromptCached(config);
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
/**
|
||||
* Legacy cleanup module for detecting and removing OpenSpec artifacts
|
||||
* from previous init versions during the migration to the skill-based workflow.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
import chalk from 'chalk';
|
||||
import { FileSystemUtils, removeMarkerBlock as removeMarkerBlockUtil } from '../utils/file-system.js';
|
||||
import { OPENSPEC_MARKERS } from './config.js';
|
||||
|
||||
/**
|
||||
* Legacy config file names from the old ToolRegistry.
|
||||
* These were config files created at project root with OpenSpec markers.
|
||||
*/
|
||||
export const LEGACY_CONFIG_FILES = [
|
||||
'CLAUDE.md',
|
||||
'CLINE.md',
|
||||
'CODEBUDDY.md',
|
||||
'COSTRICT.md',
|
||||
'QODER.md',
|
||||
'IFLOW.md',
|
||||
'AGENTS.md', // root AGENTS.md (not openspec/AGENTS.md)
|
||||
'QWEN.md',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Legacy slash command patterns from the old SlashCommandRegistry.
|
||||
* These map toolId to the path pattern where legacy commands were created.
|
||||
* Some tools used a directory structure, others used individual files.
|
||||
*/
|
||||
export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPattern> = {
|
||||
// Directory-based: .tooldir/commands/openspec/ or .tooldir/commands/openspec/*.md
|
||||
'claude': { type: 'directory', path: '.claude/commands/openspec' },
|
||||
'codebuddy': { type: 'directory', path: '.codebuddy/commands/openspec' },
|
||||
'qoder': { type: 'directory', path: '.qoder/commands/openspec' },
|
||||
'crush': { type: 'directory', path: '.crush/commands/openspec' },
|
||||
'gemini': { type: 'directory', path: '.gemini/commands/openspec' },
|
||||
'costrict': { type: 'directory', path: '.cospec/openspec/commands' },
|
||||
|
||||
// File-based: individual openspec-*.md files in a commands/workflows/prompts folder
|
||||
'cursor': { type: 'files', pattern: '.cursor/commands/openspec-*.md' },
|
||||
'windsurf': { type: 'files', pattern: '.windsurf/workflows/openspec-*.md' },
|
||||
'kilocode': { type: 'files', pattern: '.kilocode/workflows/openspec-*.md' },
|
||||
'github-copilot': { type: 'files', pattern: '.github/prompts/openspec-*.prompt.md' },
|
||||
'amazon-q': { type: 'files', pattern: '.amazonq/prompts/openspec-*.md' },
|
||||
'cline': { type: 'files', pattern: '.clinerules/workflows/openspec-*.md' },
|
||||
'roocode': { type: 'files', pattern: '.roo/commands/openspec-*.md' },
|
||||
'auggie': { type: 'files', pattern: '.augment/commands/openspec-*.md' },
|
||||
'factory': { type: 'files', pattern: '.factory/commands/openspec-*.md' },
|
||||
'opencode': { type: 'files', pattern: '.opencode/command/openspec-*.md' },
|
||||
'continue': { type: 'files', pattern: '.continue/prompts/openspec-*.prompt' },
|
||||
'antigravity': { type: 'files', pattern: '.agent/workflows/openspec-*.md' },
|
||||
'iflow': { type: 'files', pattern: '.iflow/commands/openspec-*.md' },
|
||||
'qwen': { type: 'files', pattern: '.qwen/commands/openspec-*.toml' },
|
||||
'codex': { type: 'files', pattern: '.codex/prompts/openspec-*.md' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Pattern types for legacy slash commands
|
||||
*/
|
||||
export interface LegacySlashCommandPattern {
|
||||
type: 'directory' | 'files';
|
||||
path?: string; // For directory type
|
||||
pattern?: string; // For files type (glob pattern)
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of legacy artifact detection
|
||||
*/
|
||||
export interface LegacyDetectionResult {
|
||||
/** Config files with OpenSpec markers detected */
|
||||
configFiles: string[];
|
||||
/** Config files to update (remove markers only, never delete) */
|
||||
configFilesToUpdate: string[];
|
||||
/** Legacy slash command directories found */
|
||||
slashCommandDirs: string[];
|
||||
/** Legacy slash command files found (for file-based tools) */
|
||||
slashCommandFiles: string[];
|
||||
/** Whether openspec/AGENTS.md exists */
|
||||
hasOpenspecAgents: boolean;
|
||||
/** Whether openspec/project.md exists (preserved, migration hint only) */
|
||||
hasProjectMd: boolean;
|
||||
/** Whether root AGENTS.md has OpenSpec markers */
|
||||
hasRootAgentsWithMarkers: boolean;
|
||||
/** Whether any legacy artifacts were found */
|
||||
hasLegacyArtifacts: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects all legacy OpenSpec artifacts in a project.
|
||||
*
|
||||
* @param projectPath - The root path of the project
|
||||
* @returns Detection result with all found legacy artifacts
|
||||
*/
|
||||
export async function detectLegacyArtifacts(
|
||||
projectPath: string
|
||||
): Promise<LegacyDetectionResult> {
|
||||
const result: LegacyDetectionResult = {
|
||||
configFiles: [],
|
||||
configFilesToUpdate: [],
|
||||
slashCommandDirs: [],
|
||||
slashCommandFiles: [],
|
||||
hasOpenspecAgents: false,
|
||||
hasProjectMd: false,
|
||||
hasRootAgentsWithMarkers: false,
|
||||
hasLegacyArtifacts: false,
|
||||
};
|
||||
|
||||
// Detect legacy config files
|
||||
const configResult = await detectLegacyConfigFiles(projectPath);
|
||||
result.configFiles = configResult.allFiles;
|
||||
result.configFilesToUpdate = configResult.filesToUpdate;
|
||||
|
||||
// Detect legacy slash commands
|
||||
const slashResult = await detectLegacySlashCommands(projectPath);
|
||||
result.slashCommandDirs = slashResult.directories;
|
||||
result.slashCommandFiles = slashResult.files;
|
||||
|
||||
// Detect legacy structure files
|
||||
const structureResult = await detectLegacyStructureFiles(projectPath);
|
||||
result.hasOpenspecAgents = structureResult.hasOpenspecAgents;
|
||||
result.hasProjectMd = structureResult.hasProjectMd;
|
||||
result.hasRootAgentsWithMarkers = structureResult.hasRootAgentsWithMarkers;
|
||||
|
||||
// Determine if any legacy artifacts exist
|
||||
result.hasLegacyArtifacts =
|
||||
result.configFiles.length > 0 ||
|
||||
result.slashCommandDirs.length > 0 ||
|
||||
result.slashCommandFiles.length > 0 ||
|
||||
result.hasOpenspecAgents ||
|
||||
result.hasRootAgentsWithMarkers ||
|
||||
result.hasProjectMd;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects legacy config files with OpenSpec markers.
|
||||
* All config files with markers are candidates for update (marker removal only).
|
||||
* Config files are NEVER deleted - they belong to the user's project root.
|
||||
*
|
||||
* @param projectPath - The root path of the project
|
||||
* @returns Object with all files found and files to update
|
||||
*/
|
||||
export async function detectLegacyConfigFiles(
|
||||
projectPath: string
|
||||
): Promise<{
|
||||
allFiles: string[];
|
||||
filesToUpdate: string[];
|
||||
}> {
|
||||
const allFiles: string[] = [];
|
||||
const filesToUpdate: string[] = [];
|
||||
|
||||
for (const fileName of LEGACY_CONFIG_FILES) {
|
||||
const filePath = FileSystemUtils.joinPath(projectPath, fileName);
|
||||
|
||||
if (await FileSystemUtils.fileExists(filePath)) {
|
||||
const content = await FileSystemUtils.readFile(filePath);
|
||||
|
||||
if (hasOpenSpecMarkers(content)) {
|
||||
allFiles.push(fileName);
|
||||
filesToUpdate.push(fileName); // Always update, never delete config files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { allFiles, filesToUpdate };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects legacy slash command directories and files.
|
||||
*
|
||||
* @param projectPath - The root path of the project
|
||||
* @returns Object with directories and individual files found
|
||||
*/
|
||||
export async function detectLegacySlashCommands(
|
||||
projectPath: string
|
||||
): Promise<{
|
||||
directories: string[];
|
||||
files: string[];
|
||||
}> {
|
||||
const directories: string[] = [];
|
||||
const files: string[] = [];
|
||||
|
||||
for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
|
||||
if (pattern.type === 'directory' && pattern.path) {
|
||||
const dirPath = FileSystemUtils.joinPath(projectPath, pattern.path);
|
||||
if (await FileSystemUtils.directoryExists(dirPath)) {
|
||||
directories.push(pattern.path);
|
||||
}
|
||||
} else if (pattern.type === 'files' && pattern.pattern) {
|
||||
// For file-based patterns, check for individual files
|
||||
const foundFiles = await findLegacySlashCommandFiles(projectPath, pattern.pattern);
|
||||
files.push(...foundFiles);
|
||||
}
|
||||
}
|
||||
|
||||
return { directories, files };
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds legacy slash command files matching a glob pattern.
|
||||
*
|
||||
* @param projectPath - The root path of the project
|
||||
* @param pattern - Glob pattern like '.cursor/commands/openspec-*.md'
|
||||
* @returns Array of matching file paths relative to projectPath
|
||||
*/
|
||||
async function findLegacySlashCommandFiles(
|
||||
projectPath: string,
|
||||
pattern: string
|
||||
): Promise<string[]> {
|
||||
const foundFiles: string[] = [];
|
||||
|
||||
// Extract directory and file pattern from glob
|
||||
// Handle both forward and backward slashes for Windows compatibility
|
||||
const lastForwardSlash = pattern.lastIndexOf('/');
|
||||
const lastBackSlash = pattern.lastIndexOf('\\');
|
||||
const lastSeparator = Math.max(lastForwardSlash, lastBackSlash);
|
||||
const dirPart = pattern.substring(0, lastSeparator);
|
||||
const filePart = pattern.substring(lastSeparator + 1);
|
||||
|
||||
const dirPath = FileSystemUtils.joinPath(projectPath, dirPart);
|
||||
|
||||
if (!(await FileSystemUtils.directoryExists(dirPath))) {
|
||||
return foundFiles;
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath);
|
||||
|
||||
// Convert glob pattern to regex
|
||||
// openspec-*.md -> /^openspec-.*\.md$/
|
||||
// openspec-*.prompt.md -> /^openspec-.*\.prompt\.md$/
|
||||
// openspec-*.toml -> /^openspec-.*\.toml$/
|
||||
const regexPattern = filePart
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except *
|
||||
.replace(/\*/g, '.*'); // Replace * with .*
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (regex.test(entry)) {
|
||||
// Use forward slashes for consistency in relative paths (cross-platform)
|
||||
const normalizedDir = dirPart.replace(/\\/g, '/');
|
||||
foundFiles.push(`${normalizedDir}/${entry}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory doesn't exist or can't be read
|
||||
}
|
||||
|
||||
return foundFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects legacy OpenSpec structure files (AGENTS.md and project.md).
|
||||
*
|
||||
* @param projectPath - The root path of the project
|
||||
* @returns Object with detection results for structure files
|
||||
*/
|
||||
export async function detectLegacyStructureFiles(
|
||||
projectPath: string
|
||||
): Promise<{
|
||||
hasOpenspecAgents: boolean;
|
||||
hasProjectMd: boolean;
|
||||
hasRootAgentsWithMarkers: boolean;
|
||||
}> {
|
||||
let hasOpenspecAgents = false;
|
||||
let hasProjectMd = false;
|
||||
let hasRootAgentsWithMarkers = false;
|
||||
|
||||
// Check for openspec/AGENTS.md
|
||||
const openspecAgentsPath = FileSystemUtils.joinPath(projectPath, 'openspec', 'AGENTS.md');
|
||||
hasOpenspecAgents = await FileSystemUtils.fileExists(openspecAgentsPath);
|
||||
|
||||
// Check for openspec/project.md (for migration messaging, not deleted)
|
||||
const projectMdPath = FileSystemUtils.joinPath(projectPath, 'openspec', 'project.md');
|
||||
hasProjectMd = await FileSystemUtils.fileExists(projectMdPath);
|
||||
|
||||
// Check for root AGENTS.md with OpenSpec markers
|
||||
const rootAgentsPath = FileSystemUtils.joinPath(projectPath, 'AGENTS.md');
|
||||
if (await FileSystemUtils.fileExists(rootAgentsPath)) {
|
||||
const content = await FileSystemUtils.readFile(rootAgentsPath);
|
||||
hasRootAgentsWithMarkers = hasOpenSpecMarkers(content);
|
||||
}
|
||||
|
||||
return { hasOpenspecAgents, hasProjectMd, hasRootAgentsWithMarkers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if content contains OpenSpec markers.
|
||||
*
|
||||
* @param content - File content to check
|
||||
* @returns True if both start and end markers are present
|
||||
*/
|
||||
export function hasOpenSpecMarkers(content: string): boolean {
|
||||
return (
|
||||
content.includes(OPENSPEC_MARKERS.start) && content.includes(OPENSPEC_MARKERS.end)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if file content is 100% OpenSpec content (only markers and whitespace outside).
|
||||
*
|
||||
* @param content - File content to check
|
||||
* @returns True if content outside markers is only whitespace
|
||||
*/
|
||||
export function isOnlyOpenSpecContent(content: string): boolean {
|
||||
const startIndex = content.indexOf(OPENSPEC_MARKERS.start);
|
||||
const endIndex = content.indexOf(OPENSPEC_MARKERS.end);
|
||||
|
||||
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const before = content.substring(0, startIndex);
|
||||
const after = content.substring(endIndex + OPENSPEC_MARKERS.end.length);
|
||||
|
||||
return before.trim() === '' && after.trim() === '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the OpenSpec marker block from file content.
|
||||
* Only removes markers that are on their own lines (ignores inline mentions).
|
||||
* Cleans up double blank lines that may result from removal.
|
||||
*
|
||||
* @param content - File content with OpenSpec markers
|
||||
* @returns Content with marker block removed
|
||||
*/
|
||||
export function removeMarkerBlock(content: string): string {
|
||||
return removeMarkerBlockUtil(content, OPENSPEC_MARKERS.start, OPENSPEC_MARKERS.end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of cleanup operation
|
||||
*/
|
||||
export interface CleanupResult {
|
||||
/** Files that were deleted entirely */
|
||||
deletedFiles: string[];
|
||||
/** Files that had marker blocks removed */
|
||||
modifiedFiles: string[];
|
||||
/** Directories that were deleted */
|
||||
deletedDirs: string[];
|
||||
/** Whether project.md exists and needs manual migration */
|
||||
projectMdNeedsMigration: boolean;
|
||||
/** Error messages if any operations failed */
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up legacy OpenSpec artifacts from a project.
|
||||
* Preserves openspec/project.md (shows migration hint instead of deleting).
|
||||
*
|
||||
* @param projectPath - The root path of the project
|
||||
* @param detection - Detection result from detectLegacyArtifacts
|
||||
* @returns Cleanup result with summary of actions taken
|
||||
*/
|
||||
export async function cleanupLegacyArtifacts(
|
||||
projectPath: string,
|
||||
detection: LegacyDetectionResult
|
||||
): Promise<CleanupResult> {
|
||||
const result: CleanupResult = {
|
||||
deletedFiles: [],
|
||||
modifiedFiles: [],
|
||||
deletedDirs: [],
|
||||
projectMdNeedsMigration: detection.hasProjectMd,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
// Remove marker blocks from config files (NEVER delete config files)
|
||||
// Config files like CLAUDE.md, AGENTS.md belong to the user's project root
|
||||
for (const fileName of detection.configFilesToUpdate) {
|
||||
const filePath = FileSystemUtils.joinPath(projectPath, fileName);
|
||||
try {
|
||||
const content = await FileSystemUtils.readFile(filePath);
|
||||
const newContent = removeMarkerBlock(content);
|
||||
// Always write the file, even if empty - never delete user config files
|
||||
await FileSystemUtils.writeFile(filePath, newContent);
|
||||
result.modifiedFiles.push(fileName);
|
||||
} catch (error: any) {
|
||||
result.errors.push(`Failed to modify ${fileName}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete legacy slash command directories (these are 100% OpenSpec-managed)
|
||||
for (const dirPath of detection.slashCommandDirs) {
|
||||
const fullPath = FileSystemUtils.joinPath(projectPath, dirPath);
|
||||
try {
|
||||
await fs.rm(fullPath, { recursive: true, force: true });
|
||||
result.deletedDirs.push(dirPath);
|
||||
} catch (error: any) {
|
||||
result.errors.push(`Failed to delete directory ${dirPath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete legacy slash command files (these are 100% OpenSpec-managed)
|
||||
for (const filePath of detection.slashCommandFiles) {
|
||||
const fullPath = FileSystemUtils.joinPath(projectPath, filePath);
|
||||
try {
|
||||
await fs.unlink(fullPath);
|
||||
result.deletedFiles.push(filePath);
|
||||
} catch (error: any) {
|
||||
result.errors.push(`Failed to delete ${filePath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete openspec/AGENTS.md (this is inside openspec/, it's OpenSpec-managed)
|
||||
if (detection.hasOpenspecAgents) {
|
||||
const agentsPath = FileSystemUtils.joinPath(projectPath, 'openspec', 'AGENTS.md');
|
||||
if (await FileSystemUtils.fileExists(agentsPath)) {
|
||||
try {
|
||||
await fs.unlink(agentsPath);
|
||||
result.deletedFiles.push('openspec/AGENTS.md');
|
||||
} catch (error: any) {
|
||||
result.errors.push(`Failed to delete openspec/AGENTS.md: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle root AGENTS.md with OpenSpec markers - remove markers only, NEVER delete
|
||||
// Note: Root AGENTS.md is handled via configFilesToUpdate above (it's in LEGACY_CONFIG_FILES)
|
||||
// This hasRootAgentsWithMarkers flag is just for detection, cleanup happens via configFilesToUpdate
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a cleanup summary message for display.
|
||||
*
|
||||
* @param result - Cleanup result from cleanupLegacyArtifacts
|
||||
* @returns Formatted summary string for console output
|
||||
*/
|
||||
export function formatCleanupSummary(result: CleanupResult): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (result.deletedFiles.length > 0 || result.deletedDirs.length > 0 || result.modifiedFiles.length > 0) {
|
||||
lines.push('Cleaned up legacy files:');
|
||||
|
||||
for (const file of result.deletedFiles) {
|
||||
lines.push(` ✓ Removed ${file}`);
|
||||
}
|
||||
|
||||
for (const dir of result.deletedDirs) {
|
||||
lines.push(` ✓ Removed ${dir}/ (replaced by /opsx:*)`);
|
||||
}
|
||||
|
||||
for (const file of result.modifiedFiles) {
|
||||
lines.push(` ✓ Removed OpenSpec markers from ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.projectMdNeedsMigration) {
|
||||
if (lines.length > 0) {
|
||||
lines.push('');
|
||||
}
|
||||
lines.push(formatProjectMdMigrationHint());
|
||||
}
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
if (lines.length > 0) {
|
||||
lines.push('');
|
||||
}
|
||||
lines.push('Errors during cleanup:');
|
||||
for (const error of result.errors) {
|
||||
lines.push(` ⚠ ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build list of files to be removed with explanations.
|
||||
* Only includes OpenSpec-managed files (slash commands, openspec/AGENTS.md).
|
||||
* Config files like CLAUDE.md, AGENTS.md are NEVER deleted.
|
||||
*
|
||||
* @param detection - Detection result from detectLegacyArtifacts
|
||||
* @returns Array of objects with path and explanation
|
||||
*/
|
||||
function buildRemovalsList(detection: LegacyDetectionResult): Array<{ path: string; explanation: string }> {
|
||||
const removals: Array<{ path: string; explanation: string }> = [];
|
||||
|
||||
// Slash command directories (these are 100% OpenSpec-managed)
|
||||
for (const dir of detection.slashCommandDirs) {
|
||||
// Split on both forward and backward slashes for Windows compatibility
|
||||
const toolDir = dir.split(/[\/\\]/)[0];
|
||||
removals.push({ path: dir + '/', explanation: `replaced by ${toolDir}/skills/` });
|
||||
}
|
||||
|
||||
// Slash command files (these are 100% OpenSpec-managed)
|
||||
for (const file of detection.slashCommandFiles) {
|
||||
removals.push({ path: file, explanation: 'replaced by skills/' });
|
||||
}
|
||||
|
||||
// openspec/AGENTS.md (inside openspec/, it's OpenSpec-managed)
|
||||
if (detection.hasOpenspecAgents) {
|
||||
removals.push({ path: 'openspec/AGENTS.md', explanation: 'obsolete workflow file' });
|
||||
}
|
||||
|
||||
// Note: Config files (CLAUDE.md, AGENTS.md, etc.) are NEVER in the removals list
|
||||
// They always go to the updates list where only markers are removed
|
||||
|
||||
return removals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build list of files to be updated with explanations.
|
||||
* Includes ALL config files with markers - markers are removed, file is never deleted.
|
||||
*
|
||||
* @param detection - Detection result from detectLegacyArtifacts
|
||||
* @returns Array of objects with path and explanation
|
||||
*/
|
||||
function buildUpdatesList(detection: LegacyDetectionResult): Array<{ path: string; explanation: string }> {
|
||||
const updates: Array<{ path: string; explanation: string }> = [];
|
||||
|
||||
// All config files with markers get updated (markers removed, file preserved)
|
||||
for (const file of detection.configFilesToUpdate) {
|
||||
updates.push({ path: file, explanation: 'removing OpenSpec markers' });
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a detection summary message for display before cleanup.
|
||||
* Groups files by action type: removals, updates, and manual migration.
|
||||
*
|
||||
* @param detection - Detection result from detectLegacyArtifacts
|
||||
* @returns Formatted summary string showing what was found
|
||||
*/
|
||||
export function formatDetectionSummary(detection: LegacyDetectionResult): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
const removals = buildRemovalsList(detection);
|
||||
const updates = buildUpdatesList(detection);
|
||||
|
||||
// If nothing to show, return empty
|
||||
if (removals.length === 0 && updates.length === 0 && !detection.hasProjectMd) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Header - welcoming upgrade message
|
||||
lines.push(chalk.bold('Upgrading to the new OpenSpec'));
|
||||
lines.push('');
|
||||
lines.push('OpenSpec now uses agent skills, the emerging standard across coding');
|
||||
lines.push('agents. This simplifies your setup while keeping everything working');
|
||||
lines.push('as before.');
|
||||
lines.push('');
|
||||
|
||||
// Section 1: Files to remove (no user content to preserve)
|
||||
if (removals.length > 0) {
|
||||
lines.push(chalk.bold('Files to remove'));
|
||||
lines.push(chalk.dim('No user content to preserve:'));
|
||||
for (const { path } of removals) {
|
||||
lines.push(` • ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Section 2: Files to update (markers removed, content preserved)
|
||||
if (updates.length > 0) {
|
||||
if (removals.length > 0) lines.push('');
|
||||
lines.push(chalk.bold('Files to update'));
|
||||
lines.push(chalk.dim('OpenSpec markers will be removed, your content preserved:'));
|
||||
for (const { path } of updates) {
|
||||
lines.push(` • ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Section 3: Manual migration (project.md)
|
||||
if (detection.hasProjectMd) {
|
||||
if (removals.length > 0 || updates.length > 0) lines.push('');
|
||||
lines.push(formatProjectMdMigrationHint());
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tool IDs from detected legacy artifacts.
|
||||
* Uses LEGACY_SLASH_COMMAND_PATHS to map paths back to tool IDs.
|
||||
*
|
||||
* @param detection - Detection result from detectLegacyArtifacts
|
||||
* @returns Array of tool IDs that had legacy artifacts
|
||||
*/
|
||||
export function getToolsFromLegacyArtifacts(detection: LegacyDetectionResult): string[] {
|
||||
const tools = new Set<string>();
|
||||
|
||||
// Match directories to tool IDs
|
||||
for (const dir of detection.slashCommandDirs) {
|
||||
for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
|
||||
if (pattern.type === 'directory' && pattern.path === dir) {
|
||||
tools.add(toolId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Match files to tool IDs using glob patterns
|
||||
for (const file of detection.slashCommandFiles) {
|
||||
// Normalize file path to use forward slashes for consistent matching (Windows compatibility)
|
||||
const normalizedFile = file.replace(/\\/g, '/');
|
||||
for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
|
||||
if (pattern.type === 'files' && pattern.pattern) {
|
||||
// Convert glob pattern to regex for matching
|
||||
// e.g., '.cursor/commands/openspec-*.md' -> /^\.cursor\/commands\/openspec-.*\.md$/
|
||||
const regexPattern = pattern.pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except *
|
||||
.replace(/\*/g, '.*'); // Replace * with .*
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
if (regex.test(normalizedFile)) {
|
||||
tools.add(toolId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(tools);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a migration hint message for project.md.
|
||||
* This is shown when project.md exists and needs manual migration to config.yaml.
|
||||
*
|
||||
* @returns Formatted migration hint string for console output
|
||||
*/
|
||||
export function formatProjectMdMigrationHint(): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(chalk.yellow.bold('Needs your attention'));
|
||||
lines.push(' • openspec/project.md');
|
||||
lines.push(chalk.dim(' We won\'t delete this file. It may contain useful project context.'));
|
||||
lines.push('');
|
||||
lines.push(chalk.dim(' The new openspec/config.yaml has a "context:" section for planning'));
|
||||
lines.push(chalk.dim(' context. This is included in every OpenSpec request and works more'));
|
||||
lines.push(chalk.dim(' reliably than the old project.md approach.'));
|
||||
lines.push('');
|
||||
lines.push(chalk.dim(' Review project.md, move any useful content to config.yaml\'s context'));
|
||||
lines.push(chalk.dim(' section, then delete the file when ready.'));
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Shared Utilities
|
||||
*
|
||||
* Common code shared between init and update commands.
|
||||
*/
|
||||
|
||||
export {
|
||||
SKILL_NAMES,
|
||||
type SkillName,
|
||||
type ToolSkillStatus,
|
||||
type ToolVersionStatus,
|
||||
getToolsWithSkillsDir,
|
||||
getToolSkillStatus,
|
||||
getToolStates,
|
||||
extractGeneratedByVersion,
|
||||
getToolVersionStatus,
|
||||
getConfiguredTools,
|
||||
getAllToolVersionStatus,
|
||||
} from './tool-detection.js';
|
||||
|
||||
export {
|
||||
type SkillTemplateEntry,
|
||||
type CommandTemplateEntry,
|
||||
getSkillTemplates,
|
||||
getCommandTemplates,
|
||||
getCommandContents,
|
||||
generateSkillContent,
|
||||
} from './skill-generation.js';
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Skill Generation Utilities
|
||||
*
|
||||
* Shared utilities for generating skill and command files.
|
||||
*/
|
||||
|
||||
import {
|
||||
getExploreSkillTemplate,
|
||||
getNewChangeSkillTemplate,
|
||||
getContinueChangeSkillTemplate,
|
||||
getApplyChangeSkillTemplate,
|
||||
getFfChangeSkillTemplate,
|
||||
getSyncSpecsSkillTemplate,
|
||||
getArchiveChangeSkillTemplate,
|
||||
getBulkArchiveChangeSkillTemplate,
|
||||
getVerifyChangeSkillTemplate,
|
||||
getOpsxExploreCommandTemplate,
|
||||
getOpsxNewCommandTemplate,
|
||||
getOpsxContinueCommandTemplate,
|
||||
getOpsxApplyCommandTemplate,
|
||||
getOpsxFfCommandTemplate,
|
||||
getOpsxSyncCommandTemplate,
|
||||
getOpsxArchiveCommandTemplate,
|
||||
getOpsxBulkArchiveCommandTemplate,
|
||||
getOpsxVerifyCommandTemplate,
|
||||
type SkillTemplate,
|
||||
} from '../templates/skill-templates.js';
|
||||
import type { CommandContent } from '../command-generation/index.js';
|
||||
|
||||
/**
|
||||
* Skill template with directory name mapping.
|
||||
*/
|
||||
export interface SkillTemplateEntry {
|
||||
template: SkillTemplate;
|
||||
dirName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Command template with ID mapping.
|
||||
*/
|
||||
export interface CommandTemplateEntry {
|
||||
template: ReturnType<typeof getOpsxExploreCommandTemplate>;
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all skill templates with their directory names.
|
||||
*/
|
||||
export function getSkillTemplates(): SkillTemplateEntry[] {
|
||||
return [
|
||||
{ template: getExploreSkillTemplate(), dirName: 'openspec-explore' },
|
||||
{ template: getNewChangeSkillTemplate(), dirName: 'openspec-new-change' },
|
||||
{ template: getContinueChangeSkillTemplate(), dirName: 'openspec-continue-change' },
|
||||
{ template: getApplyChangeSkillTemplate(), dirName: 'openspec-apply-change' },
|
||||
{ template: getFfChangeSkillTemplate(), dirName: 'openspec-ff-change' },
|
||||
{ template: getSyncSpecsSkillTemplate(), dirName: 'openspec-sync-specs' },
|
||||
{ template: getArchiveChangeSkillTemplate(), dirName: 'openspec-archive-change' },
|
||||
{ template: getBulkArchiveChangeSkillTemplate(), dirName: 'openspec-bulk-archive-change' },
|
||||
{ template: getVerifyChangeSkillTemplate(), dirName: 'openspec-verify-change' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all command templates with their IDs.
|
||||
*/
|
||||
export function getCommandTemplates(): CommandTemplateEntry[] {
|
||||
return [
|
||||
{ template: getOpsxExploreCommandTemplate(), id: 'explore' },
|
||||
{ template: getOpsxNewCommandTemplate(), id: 'new' },
|
||||
{ template: getOpsxContinueCommandTemplate(), id: 'continue' },
|
||||
{ template: getOpsxApplyCommandTemplate(), id: 'apply' },
|
||||
{ template: getOpsxFfCommandTemplate(), id: 'ff' },
|
||||
{ template: getOpsxSyncCommandTemplate(), id: 'sync' },
|
||||
{ template: getOpsxArchiveCommandTemplate(), id: 'archive' },
|
||||
{ template: getOpsxBulkArchiveCommandTemplate(), id: 'bulk-archive' },
|
||||
{ template: getOpsxVerifyCommandTemplate(), id: 'verify' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts command templates to CommandContent array.
|
||||
*/
|
||||
export function getCommandContents(): CommandContent[] {
|
||||
const commandTemplates = getCommandTemplates();
|
||||
return commandTemplates.map(({ template, id }) => ({
|
||||
id,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
category: template.category,
|
||||
tags: template.tags,
|
||||
body: template.content,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates skill file content with YAML frontmatter.
|
||||
*
|
||||
* @param template - The skill template
|
||||
* @param generatedByVersion - The OpenSpec version to embed in the file
|
||||
*/
|
||||
export function generateSkillContent(
|
||||
template: SkillTemplate,
|
||||
generatedByVersion: string
|
||||
): string {
|
||||
return `---
|
||||
name: ${template.name}
|
||||
description: ${template.description}
|
||||
license: ${template.license || 'MIT'}
|
||||
compatibility: ${template.compatibility || 'Requires openspec CLI.'}
|
||||
metadata:
|
||||
author: ${template.metadata?.author || 'openspec'}
|
||||
version: "${template.metadata?.version || '1.0'}"
|
||||
generatedBy: "${generatedByVersion}"
|
||||
---
|
||||
|
||||
${template.instructions}
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Tool Detection Utilities
|
||||
*
|
||||
* Shared utilities for detecting tool configurations and version status.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { AI_TOOLS } from '../config.js';
|
||||
|
||||
/**
|
||||
* Names of skill directories created by openspec init.
|
||||
*/
|
||||
export const SKILL_NAMES = [
|
||||
'openspec-explore',
|
||||
'openspec-new-change',
|
||||
'openspec-continue-change',
|
||||
'openspec-apply-change',
|
||||
'openspec-ff-change',
|
||||
'openspec-sync-specs',
|
||||
'openspec-archive-change',
|
||||
'openspec-bulk-archive-change',
|
||||
'openspec-verify-change',
|
||||
] as const;
|
||||
|
||||
export type SkillName = (typeof SKILL_NAMES)[number];
|
||||
|
||||
/**
|
||||
* Status of skill configuration for a tool.
|
||||
*/
|
||||
export interface ToolSkillStatus {
|
||||
/** Whether the tool has any skills configured */
|
||||
configured: boolean;
|
||||
/** Whether all 9 skills are configured */
|
||||
fullyConfigured: boolean;
|
||||
/** Number of skills currently configured (0-9) */
|
||||
skillCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Version information for a tool's skills.
|
||||
*/
|
||||
export interface ToolVersionStatus {
|
||||
/** The tool ID */
|
||||
toolId: string;
|
||||
/** The tool's display name */
|
||||
toolName: string;
|
||||
/** Whether the tool has any skills configured */
|
||||
configured: boolean;
|
||||
/** The generatedBy version found in the skill files, or null if not found */
|
||||
generatedByVersion: string | null;
|
||||
/** Whether the tool needs updating (version mismatch or missing) */
|
||||
needsUpdate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the list of tools with skillsDir configured.
|
||||
*/
|
||||
export function getToolsWithSkillsDir(): string[] {
|
||||
return AI_TOOLS.filter((t) => t.skillsDir).map((t) => t.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks which skill files exist for a tool.
|
||||
*/
|
||||
export function getToolSkillStatus(projectRoot: string, toolId: string): ToolSkillStatus {
|
||||
const tool = AI_TOOLS.find((t) => t.value === toolId);
|
||||
if (!tool?.skillsDir) {
|
||||
return { configured: false, fullyConfigured: false, skillCount: 0 };
|
||||
}
|
||||
|
||||
const skillsDir = path.join(projectRoot, tool.skillsDir, 'skills');
|
||||
let skillCount = 0;
|
||||
|
||||
for (const skillName of SKILL_NAMES) {
|
||||
const skillFile = path.join(skillsDir, skillName, 'SKILL.md');
|
||||
if (fs.existsSync(skillFile)) {
|
||||
skillCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
configured: skillCount > 0,
|
||||
fullyConfigured: skillCount === SKILL_NAMES.length,
|
||||
skillCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the skill status for all tools with skillsDir configured.
|
||||
*/
|
||||
export function getToolStates(projectRoot: string): Map<string, ToolSkillStatus> {
|
||||
const states = new Map<string, ToolSkillStatus>();
|
||||
const toolIds = AI_TOOLS.filter((t) => t.skillsDir).map((t) => t.value);
|
||||
|
||||
for (const toolId of toolIds) {
|
||||
states.set(toolId, getToolSkillStatus(projectRoot, toolId));
|
||||
}
|
||||
|
||||
return states;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the generatedBy version from a skill file's YAML frontmatter.
|
||||
* Returns null if the field is not found or the file doesn't exist.
|
||||
*/
|
||||
export function extractGeneratedByVersion(skillFilePath: string): string | null {
|
||||
try {
|
||||
if (!fs.existsSync(skillFilePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(skillFilePath, 'utf-8');
|
||||
|
||||
// Look for generatedBy in the YAML frontmatter
|
||||
// The file format is:
|
||||
// ---
|
||||
// ...
|
||||
// metadata:
|
||||
// author: openspec
|
||||
// version: "1.0"
|
||||
// generatedBy: "0.23.0"
|
||||
// ---
|
||||
const generatedByMatch = content.match(/^\s*generatedBy:\s*["']?([^"'\n]+)["']?\s*$/m);
|
||||
|
||||
if (generatedByMatch && generatedByMatch[1]) {
|
||||
return generatedByMatch[1].trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets version status for a tool by reading the first available skill file.
|
||||
*/
|
||||
export function getToolVersionStatus(
|
||||
projectRoot: string,
|
||||
toolId: string,
|
||||
currentVersion: string
|
||||
): ToolVersionStatus {
|
||||
const tool = AI_TOOLS.find((t) => t.value === toolId);
|
||||
if (!tool?.skillsDir) {
|
||||
return {
|
||||
toolId,
|
||||
toolName: toolId,
|
||||
configured: false,
|
||||
generatedByVersion: null,
|
||||
needsUpdate: false,
|
||||
};
|
||||
}
|
||||
|
||||
const skillsDir = path.join(projectRoot, tool.skillsDir, 'skills');
|
||||
let generatedByVersion: string | null = null;
|
||||
|
||||
// Find the first skill file that exists and read its version
|
||||
for (const skillName of SKILL_NAMES) {
|
||||
const skillFile = path.join(skillsDir, skillName, 'SKILL.md');
|
||||
if (fs.existsSync(skillFile)) {
|
||||
generatedByVersion = extractGeneratedByVersion(skillFile);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const configured = getToolSkillStatus(projectRoot, toolId).configured;
|
||||
const needsUpdate = configured && (generatedByVersion === null || generatedByVersion !== currentVersion);
|
||||
|
||||
return {
|
||||
toolId,
|
||||
toolName: tool.name,
|
||||
configured,
|
||||
generatedByVersion,
|
||||
needsUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all configured tools in the project.
|
||||
*/
|
||||
export function getConfiguredTools(projectRoot: string): string[] {
|
||||
return AI_TOOLS
|
||||
.filter((t) => t.skillsDir && getToolSkillStatus(projectRoot, t.value).configured)
|
||||
.map((t) => t.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets version status for all configured tools.
|
||||
*/
|
||||
export function getAllToolVersionStatus(
|
||||
projectRoot: string,
|
||||
currentVersion: string
|
||||
): ToolVersionStatus[] {
|
||||
const configuredTools = getConfiguredTools(projectRoot);
|
||||
return configuredTools.map((toolId) =>
|
||||
getToolVersionStatus(projectRoot, toolId, currentVersion)
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export const agentsRootStubTemplate = `# OpenSpec Instructions
|
||||
|
||||
These instructions are for AI assistants working in this project.
|
||||
|
||||
Always open \`@/openspec/AGENTS.md\` when the request:
|
||||
- Mentions planning or proposals (words like proposal, spec, change, plan)
|
||||
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
|
||||
- Sounds ambiguous and you need the authoritative spec before coding
|
||||
|
||||
Use \`@/openspec/AGENTS.md\` to learn:
|
||||
- How to create and apply change proposals
|
||||
- Spec format and conventions
|
||||
- Project structure and guidelines
|
||||
|
||||
Keep this managed block so 'openspec update' can refresh the instructions.
|
||||
`;
|
||||
@@ -1,457 +0,0 @@
|
||||
export const agentsTemplate = `# OpenSpec Instructions
|
||||
|
||||
Instructions for AI coding assistants using OpenSpec for spec-driven development.
|
||||
|
||||
## TL;DR Quick Checklist
|
||||
|
||||
- Search existing work: \`openspec spec list --long\`, \`openspec list\` (use \`rg\` only for full-text search)
|
||||
- Decide scope: new capability vs modify existing capability
|
||||
- Pick a unique \`change-id\`: kebab-case, verb-led (\`add-\`, \`update-\`, \`remove-\`, \`refactor-\`)
|
||||
- Scaffold: \`proposal.md\`, \`tasks.md\`, \`design.md\` (only if needed), and delta specs per affected capability
|
||||
- Write deltas: use \`## ADDED|MODIFIED|REMOVED|RENAMED Requirements\`; include at least one \`#### Scenario:\` per requirement
|
||||
- Validate: \`openspec validate [change-id] --strict --no-interactive\` and fix issues
|
||||
- Request approval: Do not start implementation until proposal is approved
|
||||
|
||||
## Three-Stage Workflow
|
||||
|
||||
### Stage 1: Creating Changes
|
||||
Create proposal when you need to:
|
||||
- Add features or functionality
|
||||
- Make breaking changes (API, schema)
|
||||
- Change architecture or patterns
|
||||
- Optimize performance (changes behavior)
|
||||
- Update security patterns
|
||||
|
||||
Triggers (examples):
|
||||
- "Help me create a change proposal"
|
||||
- "Help me plan a change"
|
||||
- "Help me create a proposal"
|
||||
- "I want to create a spec proposal"
|
||||
- "I want to create a spec"
|
||||
|
||||
Loose matching guidance:
|
||||
- Contains one of: \`proposal\`, \`change\`, \`spec\`
|
||||
- With one of: \`create\`, \`plan\`, \`make\`, \`start\`, \`help\`
|
||||
|
||||
Skip proposal for:
|
||||
- Bug fixes (restore intended behavior)
|
||||
- Typos, formatting, comments
|
||||
- Dependency updates (non-breaking)
|
||||
- Configuration changes
|
||||
- Tests for existing behavior
|
||||
|
||||
**Workflow**
|
||||
1. Review \`openspec/project.md\`, \`openspec list\`, and \`openspec list --specs\` to understand current context.
|
||||
2. Choose a unique verb-led \`change-id\` and scaffold \`proposal.md\`, \`tasks.md\`, optional \`design.md\`, and spec deltas under \`openspec/changes/<id>/\`.
|
||||
3. Draft spec deltas using \`## ADDED|MODIFIED|REMOVED Requirements\` with at least one \`#### Scenario:\` per requirement.
|
||||
4. Run \`openspec validate <id> --strict --no-interactive\` and resolve any issues before sharing the proposal.
|
||||
|
||||
### Stage 2: Implementing Changes
|
||||
Track these steps as TODOs and complete them one by one.
|
||||
1. **Read proposal.md** - Understand what's being built
|
||||
2. **Read design.md** (if exists) - Review technical decisions
|
||||
3. **Read tasks.md** - Get implementation checklist
|
||||
4. **Implement tasks sequentially** - Complete in order
|
||||
5. **Confirm completion** - Ensure every item in \`tasks.md\` is finished before updating statuses
|
||||
6. **Update checklist** - After all work is done, set every task to \`- [x]\` so the list reflects reality
|
||||
7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
|
||||
|
||||
### Stage 3: Archiving Changes
|
||||
After deployment, create separate PR to:
|
||||
- Move \`changes/[name]/\` → \`changes/archive/YYYY-MM-DD-[name]/\`
|
||||
- Update \`specs/\` if capabilities changed
|
||||
- Use \`openspec archive <change-id> --skip-specs --yes\` for tooling-only changes (always pass the change ID explicitly)
|
||||
- Run \`openspec validate --strict --no-interactive\` to confirm the archived change passes checks
|
||||
|
||||
## Before Any Task
|
||||
|
||||
**Context Checklist:**
|
||||
- [ ] Read relevant specs in \`specs/[capability]/spec.md\`
|
||||
- [ ] Check pending changes in \`changes/\` for conflicts
|
||||
- [ ] Read \`openspec/project.md\` for conventions
|
||||
- [ ] Run \`openspec list\` to see active changes
|
||||
- [ ] Run \`openspec list --specs\` to see existing capabilities
|
||||
|
||||
**Before Creating Specs:**
|
||||
- Always check if capability already exists
|
||||
- Prefer modifying existing specs over creating duplicates
|
||||
- Use \`openspec show [spec]\` to review current state
|
||||
- If request is ambiguous, ask 1–2 clarifying questions before scaffolding
|
||||
|
||||
### Search Guidance
|
||||
- Enumerate specs: \`openspec spec list --long\` (or \`--json\` for scripts)
|
||||
- Enumerate changes: \`openspec list\` (or \`openspec change list --json\` - deprecated but available)
|
||||
- Show details:
|
||||
- Spec: \`openspec show <spec-id> --type spec\` (use \`--json\` for filters)
|
||||
- Change: \`openspec show <change-id> --json --deltas-only\`
|
||||
- Full-text search (use ripgrep): \`rg -n "Requirement:|Scenario:" openspec/specs\`
|
||||
|
||||
## Quick Start
|
||||
|
||||
### CLI Commands
|
||||
|
||||
\`\`\`bash
|
||||
# Essential commands
|
||||
openspec list # List active changes
|
||||
openspec list --specs # List specifications
|
||||
openspec show [item] # Display change or spec
|
||||
openspec validate [item] # Validate changes or specs
|
||||
openspec archive <change-id> [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
|
||||
|
||||
# Project management
|
||||
openspec init [path] # Initialize OpenSpec
|
||||
openspec update [path] # Update instruction files
|
||||
|
||||
# Interactive mode
|
||||
openspec show # Prompts for selection
|
||||
openspec validate # Bulk validation mode
|
||||
|
||||
# Debugging
|
||||
openspec show [change] --json --deltas-only
|
||||
openspec validate [change] --strict --no-interactive
|
||||
\`\`\`
|
||||
|
||||
### Command Flags
|
||||
|
||||
- \`--json\` - Machine-readable output
|
||||
- \`--type change|spec\` - Disambiguate items
|
||||
- \`--strict\` - Comprehensive validation
|
||||
- \`--no-interactive\` - Disable prompts
|
||||
- \`--skip-specs\` - Archive without spec updates
|
||||
- \`--yes\`/\`-y\` - Skip confirmation prompts (non-interactive archive)
|
||||
|
||||
## Directory Structure
|
||||
|
||||
\`\`\`
|
||||
openspec/
|
||||
├── project.md # Project conventions
|
||||
├── specs/ # Current truth - what IS built
|
||||
│ └── [capability]/ # Single focused capability
|
||||
│ ├── spec.md # Requirements and scenarios
|
||||
│ └── design.md # Technical patterns
|
||||
├── changes/ # Proposals - what SHOULD change
|
||||
│ ├── [change-name]/
|
||||
│ │ ├── proposal.md # Why, what, impact
|
||||
│ │ ├── tasks.md # Implementation checklist
|
||||
│ │ ├── design.md # Technical decisions (optional; see criteria)
|
||||
│ │ └── specs/ # Delta changes
|
||||
│ │ └── [capability]/
|
||||
│ │ └── spec.md # ADDED/MODIFIED/REMOVED
|
||||
│ └── archive/ # Completed changes
|
||||
\`\`\`
|
||||
|
||||
## Creating Change Proposals
|
||||
|
||||
### Decision Tree
|
||||
|
||||
\`\`\`
|
||||
New request?
|
||||
├─ Bug fix restoring spec behavior? → Fix directly
|
||||
├─ Typo/format/comment? → Fix directly
|
||||
├─ New feature/capability? → Create proposal
|
||||
├─ Breaking change? → Create proposal
|
||||
├─ Architecture change? → Create proposal
|
||||
└─ Unclear? → Create proposal (safer)
|
||||
\`\`\`
|
||||
|
||||
### Proposal Structure
|
||||
|
||||
1. **Create directory:** \`changes/[change-id]/\` (kebab-case, verb-led, unique)
|
||||
|
||||
2. **Write proposal.md:**
|
||||
\`\`\`markdown
|
||||
# Change: [Brief description of change]
|
||||
|
||||
## Why
|
||||
[1-2 sentences on problem/opportunity]
|
||||
|
||||
## What Changes
|
||||
- [Bullet list of changes]
|
||||
- [Mark breaking changes with **BREAKING**]
|
||||
|
||||
## Impact
|
||||
- Affected specs: [list capabilities]
|
||||
- Affected code: [key files/systems]
|
||||
\`\`\`
|
||||
|
||||
3. **Create spec deltas:** \`specs/[capability]/spec.md\`
|
||||
\`\`\`markdown
|
||||
## ADDED Requirements
|
||||
### Requirement: New Feature
|
||||
The system SHALL provide...
|
||||
|
||||
#### Scenario: Success case
|
||||
- **WHEN** user performs action
|
||||
- **THEN** expected result
|
||||
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Existing Feature
|
||||
[Complete modified requirement]
|
||||
|
||||
## REMOVED Requirements
|
||||
### Requirement: Old Feature
|
||||
**Reason**: [Why removing]
|
||||
**Migration**: [How to handle]
|
||||
\`\`\`
|
||||
If multiple capabilities are affected, create multiple delta files under \`changes/[change-id]/specs/<capability>/spec.md\`—one per capability.
|
||||
|
||||
4. **Create tasks.md:**
|
||||
\`\`\`markdown
|
||||
## 1. Implementation
|
||||
- [ ] 1.1 Create database schema
|
||||
- [ ] 1.2 Implement API endpoint
|
||||
- [ ] 1.3 Add frontend component
|
||||
- [ ] 1.4 Write tests
|
||||
\`\`\`
|
||||
|
||||
5. **Create design.md when needed:**
|
||||
Create \`design.md\` if any of the following apply; otherwise omit it:
|
||||
- Cross-cutting change (multiple services/modules) or a new architectural pattern
|
||||
- New external dependency or significant data model changes
|
||||
- Security, performance, or migration complexity
|
||||
- Ambiguity that benefits from technical decisions before coding
|
||||
|
||||
Minimal \`design.md\` skeleton:
|
||||
\`\`\`markdown
|
||||
## Context
|
||||
[Background, constraints, stakeholders]
|
||||
|
||||
## Goals / Non-Goals
|
||||
- Goals: [...]
|
||||
- Non-Goals: [...]
|
||||
|
||||
## Decisions
|
||||
- Decision: [What and why]
|
||||
- Alternatives considered: [Options + rationale]
|
||||
|
||||
## Risks / Trade-offs
|
||||
- [Risk] → Mitigation
|
||||
|
||||
## Migration Plan
|
||||
[Steps, rollback]
|
||||
|
||||
## Open Questions
|
||||
- [...]
|
||||
\`\`\`
|
||||
|
||||
## Spec File Format
|
||||
|
||||
### Critical: Scenario Formatting
|
||||
|
||||
**CORRECT** (use #### headers):
|
||||
\`\`\`markdown
|
||||
#### Scenario: User login success
|
||||
- **WHEN** valid credentials provided
|
||||
- **THEN** return JWT token
|
||||
\`\`\`
|
||||
|
||||
**WRONG** (don't use bullets or bold):
|
||||
\`\`\`markdown
|
||||
- **Scenario: User login** ❌
|
||||
**Scenario**: User login ❌
|
||||
### Scenario: User login ❌
|
||||
\`\`\`
|
||||
|
||||
Every requirement MUST have at least one scenario.
|
||||
|
||||
### Requirement Wording
|
||||
- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative)
|
||||
|
||||
### Delta Operations
|
||||
|
||||
- \`## ADDED Requirements\` - New capabilities
|
||||
- \`## MODIFIED Requirements\` - Changed behavior
|
||||
- \`## REMOVED Requirements\` - Deprecated features
|
||||
- \`## RENAMED Requirements\` - Name changes
|
||||
|
||||
Headers matched with \`trim(header)\` - whitespace ignored.
|
||||
|
||||
#### When to use ADDED vs MODIFIED
|
||||
- ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement.
|
||||
- MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details.
|
||||
- RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name.
|
||||
|
||||
Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you aren’t explicitly changing the existing requirement, add a new requirement under ADDED instead.
|
||||
|
||||
Authoring a MODIFIED requirement correctly:
|
||||
1) Locate the existing requirement in \`openspec/specs/<capability>/spec.md\`.
|
||||
2) Copy the entire requirement block (from \`### Requirement: ...\` through its scenarios).
|
||||
3) Paste it under \`## MODIFIED Requirements\` and edit to reflect the new behavior.
|
||||
4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one \`#### Scenario:\`.
|
||||
|
||||
Example for RENAMED:
|
||||
\`\`\`markdown
|
||||
## RENAMED Requirements
|
||||
- FROM: \`### Requirement: Login\`
|
||||
- TO: \`### Requirement: User Authentication\`
|
||||
\`\`\`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Errors
|
||||
|
||||
**"Change must have at least one delta"**
|
||||
- Check \`changes/[name]/specs/\` exists with .md files
|
||||
- Verify files have operation prefixes (## ADDED Requirements)
|
||||
|
||||
**"Requirement must have at least one scenario"**
|
||||
- Check scenarios use \`#### Scenario:\` format (4 hashtags)
|
||||
- Don't use bullet points or bold for scenario headers
|
||||
|
||||
**Silent scenario parsing failures**
|
||||
- Exact format required: \`#### Scenario: Name\`
|
||||
- Debug with: \`openspec show [change] --json --deltas-only\`
|
||||
|
||||
### Validation Tips
|
||||
|
||||
\`\`\`bash
|
||||
# Always use strict mode for comprehensive checks
|
||||
openspec validate [change] --strict --no-interactive
|
||||
|
||||
# Debug delta parsing
|
||||
openspec show [change] --json | jq '.deltas'
|
||||
|
||||
# Check specific requirement
|
||||
openspec show [spec] --json -r 1
|
||||
\`\`\`
|
||||
|
||||
## Happy Path Script
|
||||
|
||||
\`\`\`bash
|
||||
# 1) Explore current state
|
||||
openspec spec list --long
|
||||
openspec list
|
||||
# Optional full-text search:
|
||||
# rg -n "Requirement:|Scenario:" openspec/specs
|
||||
# rg -n "^#|Requirement:" openspec/changes
|
||||
|
||||
# 2) Choose change id and scaffold
|
||||
CHANGE=add-two-factor-auth
|
||||
mkdir -p openspec/changes/$CHANGE/{specs/auth}
|
||||
printf "## Why\\n...\\n\\n## What Changes\\n- ...\\n\\n## Impact\\n- ...\\n" > openspec/changes/$CHANGE/proposal.md
|
||||
printf "## 1. Implementation\\n- [ ] 1.1 ...\\n" > openspec/changes/$CHANGE/tasks.md
|
||||
|
||||
# 3) Add deltas (example)
|
||||
cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF'
|
||||
## ADDED Requirements
|
||||
### Requirement: Two-Factor Authentication
|
||||
Users MUST provide a second factor during login.
|
||||
|
||||
#### Scenario: OTP required
|
||||
- **WHEN** valid credentials are provided
|
||||
- **THEN** an OTP challenge is required
|
||||
EOF
|
||||
|
||||
# 4) Validate
|
||||
openspec validate $CHANGE --strict --no-interactive
|
||||
\`\`\`
|
||||
|
||||
## Multi-Capability Example
|
||||
|
||||
\`\`\`
|
||||
openspec/changes/add-2fa-notify/
|
||||
├── proposal.md
|
||||
├── tasks.md
|
||||
└── specs/
|
||||
├── auth/
|
||||
│ └── spec.md # ADDED: Two-Factor Authentication
|
||||
└── notifications/
|
||||
└── spec.md # ADDED: OTP email notification
|
||||
\`\`\`
|
||||
|
||||
auth/spec.md
|
||||
\`\`\`markdown
|
||||
## ADDED Requirements
|
||||
### Requirement: Two-Factor Authentication
|
||||
...
|
||||
\`\`\`
|
||||
|
||||
notifications/spec.md
|
||||
\`\`\`markdown
|
||||
## ADDED Requirements
|
||||
### Requirement: OTP Email Notification
|
||||
...
|
||||
\`\`\`
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Simplicity First
|
||||
- Default to <100 lines of new code
|
||||
- Single-file implementations until proven insufficient
|
||||
- Avoid frameworks without clear justification
|
||||
- Choose boring, proven patterns
|
||||
|
||||
### Complexity Triggers
|
||||
Only add complexity with:
|
||||
- Performance data showing current solution too slow
|
||||
- Concrete scale requirements (>1000 users, >100MB data)
|
||||
- Multiple proven use cases requiring abstraction
|
||||
|
||||
### Clear References
|
||||
- Use \`file.ts:42\` format for code locations
|
||||
- Reference specs as \`specs/auth/spec.md\`
|
||||
- Link related changes and PRs
|
||||
|
||||
### Capability Naming
|
||||
- Use verb-noun: \`user-auth\`, \`payment-capture\`
|
||||
- Single purpose per capability
|
||||
- 10-minute understandability rule
|
||||
- Split if description needs "AND"
|
||||
|
||||
### Change ID Naming
|
||||
- Use kebab-case, short and descriptive: \`add-two-factor-auth\`
|
||||
- Prefer verb-led prefixes: \`add-\`, \`update-\`, \`remove-\`, \`refactor-\`
|
||||
- Ensure uniqueness; if taken, append \`-2\`, \`-3\`, etc.
|
||||
|
||||
## Tool Selection Guide
|
||||
|
||||
| Task | Tool | Why |
|
||||
|------|------|-----|
|
||||
| Find files by pattern | Glob | Fast pattern matching |
|
||||
| Search code content | Grep | Optimized regex search |
|
||||
| Read specific files | Read | Direct file access |
|
||||
| Explore unknown scope | Task | Multi-step investigation |
|
||||
|
||||
## Error Recovery
|
||||
|
||||
### Change Conflicts
|
||||
1. Run \`openspec list\` to see active changes
|
||||
2. Check for overlapping specs
|
||||
3. Coordinate with change owners
|
||||
4. Consider combining proposals
|
||||
|
||||
### Validation Failures
|
||||
1. Run with \`--strict\` flag
|
||||
2. Check JSON output for details
|
||||
3. Verify spec file format
|
||||
4. Ensure scenarios properly formatted
|
||||
|
||||
### Missing Context
|
||||
1. Read project.md first
|
||||
2. Check related specs
|
||||
3. Review recent archives
|
||||
4. Ask for clarification
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Stage Indicators
|
||||
- \`changes/\` - Proposed, not yet built
|
||||
- \`specs/\` - Built and deployed
|
||||
- \`archive/\` - Completed changes
|
||||
|
||||
### File Purposes
|
||||
- \`proposal.md\` - Why and what
|
||||
- \`tasks.md\` - Implementation steps
|
||||
- \`design.md\` - Technical decisions
|
||||
- \`spec.md\` - Requirements and behavior
|
||||
|
||||
### CLI Essentials
|
||||
\`\`\`bash
|
||||
openspec list # What's in progress?
|
||||
openspec show [item] # View details
|
||||
openspec validate --strict --no-interactive # Is it correct?
|
||||
openspec archive <change-id> [--yes|-y] # Mark complete (add --yes for automation)
|
||||
\`\`\`
|
||||
|
||||
Remember: Specs are truth. Changes are proposals. Keep them in sync.
|
||||
`;
|
||||
@@ -1 +0,0 @@
|
||||
export { agentsRootStubTemplate as claudeTemplate } from './agents-root-stub.js';
|
||||
@@ -1 +0,0 @@
|
||||
export { agentsRootStubTemplate as clineTemplate } from './agents-root-stub.js';
|
||||
@@ -1 +0,0 @@
|
||||
export { agentsRootStubTemplate as costrictTemplate } from './agents-root-stub.js';
|
||||
+27
-49
@@ -1,50 +1,28 @@
|
||||
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';
|
||||
/**
|
||||
* Template exports for OpenSpec.
|
||||
*
|
||||
* The old config file templates (AGENTS.md, project.md, claude-template, etc.)
|
||||
* have been removed. The skill-based workflow uses skill-templates.ts directly.
|
||||
*/
|
||||
|
||||
export interface Template {
|
||||
path: string;
|
||||
content: string | ((context: ProjectContext) => string);
|
||||
}
|
||||
|
||||
export class TemplateManager {
|
||||
static getTemplates(context: ProjectContext = {}): Template[] {
|
||||
return [
|
||||
{
|
||||
path: 'AGENTS.md',
|
||||
content: agentsTemplate
|
||||
},
|
||||
{
|
||||
path: 'project.md',
|
||||
content: projectTemplate(context)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
static getClaudeTemplate(): string {
|
||||
return claudeTemplate;
|
||||
}
|
||||
|
||||
static getClineTemplate(): string {
|
||||
return clineTemplate;
|
||||
}
|
||||
|
||||
static getCostrictTemplate(): string {
|
||||
return costrictTemplate;
|
||||
}
|
||||
|
||||
static getAgentsStandardTemplate(): string {
|
||||
return agentsRootStubTemplate;
|
||||
}
|
||||
|
||||
static getSlashCommandBody(id: SlashCommandId): string {
|
||||
return getSlashCommandBody(id);
|
||||
}
|
||||
}
|
||||
|
||||
export { ProjectContext } from './project-template.js';
|
||||
export type { SlashCommandId } from './slash-command-templates.js';
|
||||
// Re-export skill templates for convenience
|
||||
export {
|
||||
getExploreSkillTemplate,
|
||||
getNewChangeSkillTemplate,
|
||||
getContinueChangeSkillTemplate,
|
||||
getApplyChangeSkillTemplate,
|
||||
getFfChangeSkillTemplate,
|
||||
getSyncSpecsSkillTemplate,
|
||||
getArchiveChangeSkillTemplate,
|
||||
getBulkArchiveChangeSkillTemplate,
|
||||
getVerifyChangeSkillTemplate,
|
||||
getOpsxExploreCommandTemplate,
|
||||
getOpsxNewCommandTemplate,
|
||||
getOpsxContinueCommandTemplate,
|
||||
getOpsxApplyCommandTemplate,
|
||||
getOpsxFfCommandTemplate,
|
||||
getOpsxSyncCommandTemplate,
|
||||
getOpsxArchiveCommandTemplate,
|
||||
getOpsxBulkArchiveCommandTemplate,
|
||||
getOpsxVerifyCommandTemplate,
|
||||
} from './skill-templates.js';
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
export interface ProjectContext {
|
||||
projectName?: string;
|
||||
description?: string;
|
||||
techStack?: string[];
|
||||
conventions?: string;
|
||||
}
|
||||
|
||||
export const projectTemplate = (context: ProjectContext = {}) => `# ${context.projectName || 'Project'} Context
|
||||
|
||||
## Purpose
|
||||
${context.description || '[Describe your project\'s purpose and goals]'}
|
||||
|
||||
## Tech Stack
|
||||
${context.techStack?.length ? context.techStack.map(tech => `- ${tech}`).join('\n') : '- [List your primary technologies]\n- [e.g., TypeScript, React, Node.js]'}
|
||||
|
||||
## Project Conventions
|
||||
|
||||
### Code Style
|
||||
[Describe your code style preferences, formatting rules, and naming conventions]
|
||||
|
||||
### Architecture Patterns
|
||||
[Document your architectural decisions and patterns]
|
||||
|
||||
### Testing Strategy
|
||||
[Explain your testing approach and requirements]
|
||||
|
||||
### Git Workflow
|
||||
[Describe your branching strategy and commit conventions]
|
||||
|
||||
## Domain Context
|
||||
[Add domain-specific knowledge that AI assistants need to understand]
|
||||
|
||||
## Important Constraints
|
||||
[List any technical, business, or regulatory constraints]
|
||||
|
||||
## External Dependencies
|
||||
[Document key external services, APIs, or systems]
|
||||
`;
|
||||
@@ -1,60 +0,0 @@
|
||||
export type SlashCommandId = 'proposal' | 'apply' | 'archive';
|
||||
|
||||
const baseGuardrails = `**Guardrails**
|
||||
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
|
||||
- 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.
|
||||
- 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.
|
||||
2. Choose a unique verb-led \`change-id\` and scaffold \`proposal.md\`, \`tasks.md\`, and \`design.md\` (when needed) under \`openspec/changes/<id>/\`.
|
||||
3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing.
|
||||
4. Capture architectural reasoning in \`design.md\` when the solution spans multiple systems, introduces new patterns, or demands trade-off discussion before committing to specs.
|
||||
5. Draft spec deltas in \`changes/<id>/specs/<capability>/spec.md\` (one folder per capability) using \`## ADDED|MODIFIED|REMOVED Requirements\` with at least one \`#### Scenario:\` per requirement and cross-reference related capabilities when relevant.
|
||||
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 --no-interactive\` 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.
|
||||
- Explore the codebase with \`rg <keyword>\`, \`ls\`, or direct file reads so proposals align with current implementation realities.`;
|
||||
|
||||
const applySteps = `**Steps**
|
||||
Track these steps as TODOs and complete them one by one.
|
||||
1. Read \`changes/<id>/proposal.md\`, \`design.md\` (if present), and \`tasks.md\` to confirm scope and acceptance criteria.
|
||||
2. Work through tasks sequentially, keeping edits minimal and focused on the requested change.
|
||||
3. Confirm completion before updating statuses—make sure every item in \`tasks.md\` is finished.
|
||||
4. Update the checklist after all work is done so each task is marked \`- [x]\` and reflects reality.
|
||||
5. Reference \`openspec list\` or \`openspec show <item>\` when additional context is required.`;
|
||||
|
||||
const applyReferences = `**Reference**
|
||||
- Use \`openspec show <id> --json --deltas-only\` if you need additional context from the proposal while implementing.`;
|
||||
|
||||
const archiveSteps = `**Steps**
|
||||
1. Determine the change ID to archive:
|
||||
- If this prompt already includes a specific change ID (for example inside a \`<ChangeId>\` block populated by slash-command arguments), use that value after trimming whitespace.
|
||||
- If the conversation references a change loosely (for example by title or summary), run \`openspec list\` to surface likely IDs, share the relevant candidates, and confirm which one the user intends.
|
||||
- Otherwise, review the conversation, run \`openspec list\`, and ask the user which change to archive; wait for a confirmed change ID before proceeding.
|
||||
- If you still cannot identify a single change ID, stop and tell the user you cannot archive anything yet.
|
||||
2. Validate the change ID by running \`openspec list\` (or \`openspec show <id>\`) and stop if the change is missing, already archived, or otherwise not ready to archive.
|
||||
3. Run \`openspec archive <id> --yes\` so the CLI moves the change and applies spec updates without prompts (use \`--skip-specs\` only for tooling-only work).
|
||||
4. Review the command output to confirm the target specs were updated and the change landed in \`changes/archive/\`.
|
||||
5. Validate with \`openspec validate --strict --no-interactive\` and inspect with \`openspec show <id>\` if anything looks off.`;
|
||||
|
||||
const archiveReferences = `**Reference**
|
||||
- Use \`openspec list\` to confirm change IDs before archiving.
|
||||
- Inspect refreshed specs with \`openspec list --specs\` and address any validation issues before handing off.`;
|
||||
|
||||
export const slashCommandBodies: Record<SlashCommandId, string> = {
|
||||
proposal: [proposalGuardrails, proposalSteps, proposalReferences].join('\n\n'),
|
||||
apply: [baseGuardrails, applySteps, applyReferences].join('\n\n'),
|
||||
archive: [baseGuardrails, archiveSteps, archiveReferences].join('\n\n')
|
||||
};
|
||||
|
||||
export function getSlashCommandBody(id: SlashCommandId): string {
|
||||
return slashCommandBodies[id];
|
||||
}
|
||||
+361
-92
@@ -1,129 +1,398 @@
|
||||
/**
|
||||
* Update Command
|
||||
*
|
||||
* Refreshes OpenSpec skills and commands for configured tools.
|
||||
* Supports smart update detection to skip updates when already current.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { createRequire } from 'module';
|
||||
import { FileSystemUtils } from '../utils/file-system.js';
|
||||
import { OPENSPEC_DIR_NAME } from './config.js';
|
||||
import { ToolRegistry } from './configurators/registry.js';
|
||||
import { SlashCommandRegistry } from './configurators/slash/registry.js';
|
||||
import { agentsTemplate } from './templates/agents-template.js';
|
||||
import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js';
|
||||
import {
|
||||
generateCommands,
|
||||
CommandAdapterRegistry,
|
||||
} from './command-generation/index.js';
|
||||
import {
|
||||
getConfiguredTools,
|
||||
getAllToolVersionStatus,
|
||||
getSkillTemplates,
|
||||
getCommandContents,
|
||||
generateSkillContent,
|
||||
getToolsWithSkillsDir,
|
||||
type ToolVersionStatus,
|
||||
} from './shared/index.js';
|
||||
import {
|
||||
detectLegacyArtifacts,
|
||||
cleanupLegacyArtifacts,
|
||||
formatCleanupSummary,
|
||||
formatDetectionSummary,
|
||||
getToolsFromLegacyArtifacts,
|
||||
type LegacyDetectionResult,
|
||||
} from './legacy-cleanup.js';
|
||||
import { isInteractive } from '../utils/interactive.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { version: OPENSPEC_VERSION } = require('../../package.json');
|
||||
|
||||
/**
|
||||
* Options for the update command.
|
||||
*/
|
||||
export interface UpdateCommandOptions {
|
||||
/** Force update even when tools are up to date */
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateCommand {
|
||||
private readonly force: boolean;
|
||||
|
||||
constructor(options: UpdateCommandOptions = {}) {
|
||||
this.force = options.force ?? false;
|
||||
}
|
||||
|
||||
async execute(projectPath: string): Promise<void> {
|
||||
const resolvedProjectPath = path.resolve(projectPath);
|
||||
const openspecDirName = OPENSPEC_DIR_NAME;
|
||||
const openspecPath = path.join(resolvedProjectPath, openspecDirName);
|
||||
const openspecPath = path.join(resolvedProjectPath, OPENSPEC_DIR_NAME);
|
||||
|
||||
// 1. Check openspec directory exists
|
||||
if (!await FileSystemUtils.directoryExists(openspecPath)) {
|
||||
throw new Error(`No OpenSpec directory found. Run 'openspec init' first.`);
|
||||
}
|
||||
|
||||
// 2. Update AGENTS.md (full replacement)
|
||||
const agentsPath = path.join(openspecPath, 'AGENTS.md');
|
||||
// 2. Detect and handle legacy artifacts + upgrade legacy tools to new skills
|
||||
const newlyConfiguredTools = await this.handleLegacyCleanup(resolvedProjectPath);
|
||||
|
||||
await FileSystemUtils.writeFile(agentsPath, agentsTemplate);
|
||||
// 3. Find configured tools
|
||||
const configuredTools = getConfiguredTools(resolvedProjectPath);
|
||||
|
||||
// 3. Update existing AI tool configuration files only
|
||||
const configurators = ToolRegistry.getAll();
|
||||
const slashConfigurators = SlashCommandRegistry.getAll();
|
||||
const updatedFiles: string[] = [];
|
||||
const createdFiles: string[] = [];
|
||||
const failedFiles: string[] = [];
|
||||
const updatedSlashFiles: string[] = [];
|
||||
const failedSlashTools: string[] = [];
|
||||
if (configuredTools.length === 0 && newlyConfiguredTools.length === 0) {
|
||||
console.log(chalk.yellow('No configured tools found.'));
|
||||
console.log(chalk.dim('Run "openspec init" to set up tools.'));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const configurator of configurators) {
|
||||
const configFilePath = path.join(
|
||||
resolvedProjectPath,
|
||||
configurator.configFileName
|
||||
);
|
||||
const fileExists = await FileSystemUtils.fileExists(configFilePath);
|
||||
const shouldConfigure =
|
||||
fileExists || configurator.configFileName === 'AGENTS.md';
|
||||
// 4. Check version status for all configured tools
|
||||
const toolStatuses = getAllToolVersionStatus(resolvedProjectPath, OPENSPEC_VERSION);
|
||||
|
||||
if (!shouldConfigure) {
|
||||
continue;
|
||||
}
|
||||
// 5. Smart update detection
|
||||
const toolsNeedingUpdate = toolStatuses.filter((s) => s.needsUpdate);
|
||||
const toolsUpToDate = toolStatuses.filter((s) => !s.needsUpdate);
|
||||
|
||||
if (!this.force && toolsNeedingUpdate.length === 0) {
|
||||
// All tools are up to date
|
||||
this.displayUpToDateMessage(toolStatuses);
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. Display update plan
|
||||
if (this.force) {
|
||||
console.log(`Force updating ${configuredTools.length} tool(s): ${configuredTools.join(', ')}`);
|
||||
} else {
|
||||
this.displayUpdatePlan(toolsNeedingUpdate, toolsUpToDate);
|
||||
}
|
||||
console.log();
|
||||
|
||||
// 7. Prepare templates
|
||||
const skillTemplates = getSkillTemplates();
|
||||
const commandContents = getCommandContents();
|
||||
|
||||
// 8. Update tools (all if force, otherwise only those needing update)
|
||||
const toolsToUpdate = this.force ? configuredTools : toolsNeedingUpdate.map((s) => s.toolId);
|
||||
const updatedTools: string[] = [];
|
||||
const failedTools: Array<{ name: string; error: string }> = [];
|
||||
|
||||
for (const toolId of toolsToUpdate) {
|
||||
const tool = AI_TOOLS.find((t) => t.value === toolId);
|
||||
if (!tool?.skillsDir) continue;
|
||||
|
||||
const spinner = ora(`Updating ${tool.name}...`).start();
|
||||
|
||||
try {
|
||||
if (fileExists && !await FileSystemUtils.canWriteFile(configFilePath)) {
|
||||
throw new Error(
|
||||
`Insufficient permissions to modify ${configurator.configFileName}`
|
||||
);
|
||||
const skillsDir = path.join(resolvedProjectPath, tool.skillsDir, 'skills');
|
||||
|
||||
// Update skill files
|
||||
for (const { template, dirName } of skillTemplates) {
|
||||
const skillDir = path.join(skillsDir, dirName);
|
||||
const skillFile = path.join(skillDir, 'SKILL.md');
|
||||
|
||||
const skillContent = generateSkillContent(template, OPENSPEC_VERSION);
|
||||
await FileSystemUtils.writeFile(skillFile, skillContent);
|
||||
}
|
||||
|
||||
await configurator.configure(resolvedProjectPath, openspecPath);
|
||||
updatedFiles.push(configurator.configFileName);
|
||||
// Update commands
|
||||
const adapter = CommandAdapterRegistry.get(tool.value);
|
||||
if (adapter) {
|
||||
const generatedCommands = generateCommands(commandContents, adapter);
|
||||
|
||||
if (!fileExists) {
|
||||
createdFiles.push(configurator.configFileName);
|
||||
for (const cmd of generatedCommands) {
|
||||
const commandFile = path.join(resolvedProjectPath, cmd.path);
|
||||
await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
|
||||
}
|
||||
}
|
||||
|
||||
spinner.succeed(`Updated ${tool.name}`);
|
||||
updatedTools.push(tool.name);
|
||||
} catch (error) {
|
||||
failedFiles.push(configurator.configFileName);
|
||||
console.error(
|
||||
`Failed to update ${configurator.configFileName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
spinner.fail(`Failed to update ${tool.name}`);
|
||||
failedTools.push({
|
||||
name: tool.name,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const slashConfigurator of slashConfigurators) {
|
||||
if (!slashConfigurator.isAvailable) {
|
||||
continue;
|
||||
// 9. Summary
|
||||
console.log();
|
||||
if (updatedTools.length > 0) {
|
||||
console.log(chalk.green(`✓ Updated: ${updatedTools.join(', ')} (v${OPENSPEC_VERSION})`));
|
||||
}
|
||||
if (failedTools.length > 0) {
|
||||
console.log(chalk.red(`✗ Failed: ${failedTools.map(f => `${f.name} (${f.error})`).join(', ')}`));
|
||||
}
|
||||
|
||||
// 10. Show onboarding message for newly configured tools from legacy upgrade
|
||||
if (newlyConfiguredTools.length > 0) {
|
||||
console.log();
|
||||
console.log(chalk.bold('Getting started:'));
|
||||
console.log(' /opsx:new Start a new change');
|
||||
console.log(' /opsx:continue Create the next artifact');
|
||||
console.log(' /opsx:apply Implement tasks');
|
||||
console.log();
|
||||
console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`);
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(chalk.dim('Restart your IDE for changes to take effect.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display message when all tools are up to date.
|
||||
*/
|
||||
private displayUpToDateMessage(toolStatuses: ToolVersionStatus[]): void {
|
||||
const toolNames = toolStatuses.map((s) => s.toolId);
|
||||
console.log(chalk.green(`✓ All ${toolStatuses.length} tool(s) up to date (v${OPENSPEC_VERSION})`));
|
||||
console.log(chalk.dim(` Tools: ${toolNames.join(', ')}`));
|
||||
console.log();
|
||||
console.log(chalk.dim('Use --force to refresh skills anyway.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the update plan showing which tools need updating.
|
||||
*/
|
||||
private displayUpdatePlan(
|
||||
needingUpdate: ToolVersionStatus[],
|
||||
upToDate: ToolVersionStatus[]
|
||||
): void {
|
||||
const updates = needingUpdate.map((s) => {
|
||||
const fromVersion = s.generatedByVersion ?? 'unknown';
|
||||
return `${s.toolId} (${fromVersion} → ${OPENSPEC_VERSION})`;
|
||||
});
|
||||
|
||||
console.log(`Updating ${needingUpdate.length} tool(s): ${updates.join(', ')}`);
|
||||
|
||||
if (upToDate.length > 0) {
|
||||
const upToDateNames = upToDate.map((s) => s.toolId);
|
||||
console.log(chalk.dim(`Already up to date: ${upToDateNames.join(', ')}`));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect and handle legacy OpenSpec artifacts.
|
||||
* Unlike init, update warns but continues if legacy files found in non-interactive mode.
|
||||
* Returns array of tool IDs that were newly configured during legacy upgrade.
|
||||
*/
|
||||
private async handleLegacyCleanup(projectPath: string): Promise<string[]> {
|
||||
// Detect legacy artifacts
|
||||
const detection = await detectLegacyArtifacts(projectPath);
|
||||
|
||||
if (!detection.hasLegacyArtifacts) {
|
||||
return []; // No legacy artifacts found
|
||||
}
|
||||
|
||||
// Show what was detected
|
||||
console.log();
|
||||
console.log(formatDetectionSummary(detection));
|
||||
console.log();
|
||||
|
||||
const canPrompt = isInteractive();
|
||||
|
||||
if (this.force) {
|
||||
// --force flag: proceed with cleanup automatically
|
||||
await this.performLegacyCleanup(projectPath, detection);
|
||||
// Then upgrade legacy tools to new skills
|
||||
return this.upgradeLegacyTools(projectPath, detection, canPrompt);
|
||||
}
|
||||
|
||||
if (!canPrompt) {
|
||||
// Non-interactive mode without --force: warn and continue
|
||||
// (Unlike init, update doesn't abort - user may just want to update skills)
|
||||
console.log(chalk.yellow('⚠ Run with --force to auto-cleanup legacy files, or run interactively.'));
|
||||
console.log();
|
||||
return [];
|
||||
}
|
||||
|
||||
// Interactive mode: prompt for confirmation
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
const shouldCleanup = await confirm({
|
||||
message: 'Upgrade and clean up legacy files?',
|
||||
default: true,
|
||||
});
|
||||
|
||||
if (shouldCleanup) {
|
||||
await this.performLegacyCleanup(projectPath, detection);
|
||||
// Then upgrade legacy tools to new skills
|
||||
return this.upgradeLegacyTools(projectPath, detection, canPrompt);
|
||||
} else {
|
||||
console.log(chalk.dim('Skipping legacy cleanup. Continuing with skill update...'));
|
||||
console.log();
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform cleanup of legacy artifacts.
|
||||
*/
|
||||
private async performLegacyCleanup(projectPath: string, detection: LegacyDetectionResult): Promise<void> {
|
||||
const spinner = ora('Cleaning up legacy files...').start();
|
||||
|
||||
const result = await cleanupLegacyArtifacts(projectPath, detection);
|
||||
|
||||
spinner.succeed('Legacy files cleaned up');
|
||||
|
||||
const summary = formatCleanupSummary(result);
|
||||
if (summary) {
|
||||
console.log();
|
||||
console.log(summary);
|
||||
}
|
||||
|
||||
console.log();
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade legacy tools to new skills system.
|
||||
* Returns array of tool IDs that were newly configured.
|
||||
*/
|
||||
private async upgradeLegacyTools(
|
||||
projectPath: string,
|
||||
detection: LegacyDetectionResult,
|
||||
canPrompt: boolean
|
||||
): Promise<string[]> {
|
||||
// Get tools that had legacy artifacts
|
||||
const legacyTools = getToolsFromLegacyArtifacts(detection);
|
||||
|
||||
if (legacyTools.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get currently configured tools
|
||||
const configuredTools = getConfiguredTools(projectPath);
|
||||
const configuredSet = new Set(configuredTools);
|
||||
|
||||
// Filter to tools that aren't already configured
|
||||
const unconfiguredLegacyTools = legacyTools.filter((t) => !configuredSet.has(t));
|
||||
|
||||
if (unconfiguredLegacyTools.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get valid tools (those with skillsDir)
|
||||
const validToolIds = new Set(getToolsWithSkillsDir());
|
||||
const validUnconfiguredTools = unconfiguredLegacyTools.filter((t) => validToolIds.has(t));
|
||||
|
||||
if (validUnconfiguredTools.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Show what tools were detected from legacy artifacts
|
||||
console.log(chalk.bold('Tools detected from legacy artifacts:'));
|
||||
for (const toolId of validUnconfiguredTools) {
|
||||
const tool = AI_TOOLS.find((t) => t.value === toolId);
|
||||
console.log(` • ${tool?.name || toolId}`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
let selectedTools: string[];
|
||||
|
||||
if (this.force || !canPrompt) {
|
||||
// Non-interactive with --force: auto-select detected tools
|
||||
selectedTools = validUnconfiguredTools;
|
||||
console.log(`Setting up skills for: ${selectedTools.join(', ')}`);
|
||||
} else {
|
||||
// Interactive mode: prompt for tool selection with detected tools pre-selected
|
||||
const { searchableMultiSelect } = await import('../prompts/searchable-multi-select.js');
|
||||
|
||||
const sortedChoices = validUnconfiguredTools.map((toolId) => {
|
||||
const tool = AI_TOOLS.find((t) => t.value === toolId);
|
||||
return {
|
||||
name: tool?.name || toolId,
|
||||
value: toolId,
|
||||
configured: false,
|
||||
preSelected: true, // Pre-select all detected legacy tools
|
||||
};
|
||||
});
|
||||
|
||||
selectedTools = await searchableMultiSelect({
|
||||
message: 'Select tools to set up with the new skill system:',
|
||||
pageSize: 15,
|
||||
choices: sortedChoices,
|
||||
validate: (_selected: string[]) => true, // Allow empty selection (user can skip)
|
||||
});
|
||||
|
||||
if (selectedTools.length === 0) {
|
||||
console.log(chalk.dim('Skipping tool setup.'));
|
||||
console.log();
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Create skills for selected tools
|
||||
const newlyConfigured: string[] = [];
|
||||
const skillTemplates = getSkillTemplates();
|
||||
const commandContents = getCommandContents();
|
||||
|
||||
for (const toolId of selectedTools) {
|
||||
const tool = AI_TOOLS.find((t) => t.value === toolId);
|
||||
if (!tool?.skillsDir) continue;
|
||||
|
||||
const spinner = ora(`Setting up ${tool.name}...`).start();
|
||||
|
||||
try {
|
||||
const updated = await slashConfigurator.updateExisting(
|
||||
resolvedProjectPath,
|
||||
openspecPath
|
||||
);
|
||||
updatedSlashFiles.push(...updated);
|
||||
const skillsDir = path.join(projectPath, tool.skillsDir, 'skills');
|
||||
|
||||
// Create skill files
|
||||
for (const { template, dirName } of skillTemplates) {
|
||||
const skillDir = path.join(skillsDir, dirName);
|
||||
const skillFile = path.join(skillDir, 'SKILL.md');
|
||||
|
||||
const skillContent = generateSkillContent(template, OPENSPEC_VERSION);
|
||||
await FileSystemUtils.writeFile(skillFile, skillContent);
|
||||
}
|
||||
|
||||
// Create commands
|
||||
const adapter = CommandAdapterRegistry.get(tool.value);
|
||||
if (adapter) {
|
||||
const generatedCommands = generateCommands(commandContents, adapter);
|
||||
|
||||
for (const cmd of generatedCommands) {
|
||||
const commandFile = path.join(projectPath, cmd.path);
|
||||
await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
|
||||
}
|
||||
}
|
||||
|
||||
spinner.succeed(`Setup complete for ${tool.name}`);
|
||||
newlyConfigured.push(toolId);
|
||||
} catch (error) {
|
||||
failedSlashTools.push(slashConfigurator.toolId);
|
||||
console.error(
|
||||
`Failed to update slash commands for ${slashConfigurator.toolId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
spinner.fail(`Failed to set up ${tool.name}`);
|
||||
console.log(chalk.red(` ${error instanceof Error ? error.message : String(error)}`));
|
||||
}
|
||||
}
|
||||
|
||||
const summaryParts: string[] = [];
|
||||
const instructionFiles: string[] = ['openspec/AGENTS.md'];
|
||||
|
||||
if (updatedFiles.includes('AGENTS.md')) {
|
||||
instructionFiles.push(
|
||||
createdFiles.includes('AGENTS.md') ? 'AGENTS.md (created)' : 'AGENTS.md'
|
||||
);
|
||||
if (newlyConfigured.length > 0) {
|
||||
console.log();
|
||||
}
|
||||
|
||||
summaryParts.push(
|
||||
`Updated OpenSpec instructions (${instructionFiles.join(', ')})`
|
||||
);
|
||||
|
||||
const aiToolFiles = updatedFiles.filter((file) => file !== 'AGENTS.md');
|
||||
if (aiToolFiles.length > 0) {
|
||||
summaryParts.push(`Updated AI tool files: ${aiToolFiles.join(', ')}`);
|
||||
}
|
||||
|
||||
if (updatedSlashFiles.length > 0) {
|
||||
// Normalize to forward slashes for cross-platform log consistency
|
||||
const normalized = updatedSlashFiles.map((p) => FileSystemUtils.toPosixPath(p));
|
||||
summaryParts.push(`Updated slash commands: ${normalized.join(', ')}`);
|
||||
}
|
||||
|
||||
const failedItems = [
|
||||
...failedFiles,
|
||||
...failedSlashTools.map(
|
||||
(toolId) => `slash command refresh (${toolId})`
|
||||
),
|
||||
];
|
||||
|
||||
if (failedItems.length > 0) {
|
||||
summaryParts.push(`Failed to update: ${failedItems.join(', ')}`);
|
||||
}
|
||||
|
||||
console.log(summaryParts.join(' | '));
|
||||
|
||||
// No additional notes
|
||||
return newlyConfigured;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ const ART_COLUMN_WIDTH = 24;
|
||||
function getWelcomeText(): string[] {
|
||||
return [
|
||||
chalk.white.bold('Welcome to OpenSpec'),
|
||||
chalk.dim('Experimental Artifact Workflow'),
|
||||
chalk.dim('A lightweight spec-driven framework'),
|
||||
'',
|
||||
chalk.white('This setup will configure:'),
|
||||
chalk.dim(' • Agent Skills for AI tools'),
|
||||
|
||||
@@ -239,10 +239,28 @@ export class FileSystemUtils {
|
||||
}
|
||||
return await this.ensureWritePermissions(parentDir);
|
||||
}
|
||||
|
||||
const testFile = path.join(dirPath, '.openspec-test-' + Date.now());
|
||||
|
||||
const testFile = path.join(dirPath, '.openspec-test-' + Date.now() + '-' + Math.random().toString(36).slice(2));
|
||||
await fs.writeFile(testFile, '');
|
||||
await fs.unlink(testFile);
|
||||
|
||||
// On Windows, file may be temporarily locked by antivirus or indexing services.
|
||||
// Retry unlink with a small delay if it fails.
|
||||
const maxRetries = 3;
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
await fs.unlink(testFile);
|
||||
break;
|
||||
} catch (unlinkError: any) {
|
||||
if (attempt === maxRetries - 1) {
|
||||
// Last attempt failed, but we successfully wrote the file, so permissions are OK
|
||||
// Just log and continue - the temp file will be cleaned up eventually
|
||||
console.debug(`Could not clean up test file ${testFile}: ${unlinkError.message}`);
|
||||
} else {
|
||||
// Wait briefly before retrying (Windows file lock release)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.debug(`Insufficient permissions to write to ${dirPath}: ${error.message}`);
|
||||
@@ -250,3 +268,58 @@ export class FileSystemUtils {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a marker block from file content.
|
||||
* Only removes markers that are on their own lines (ignores inline mentions).
|
||||
* Cleans up double blank lines that may result from removal.
|
||||
*
|
||||
* @param content - File content with markers
|
||||
* @param startMarker - The start marker string
|
||||
* @param endMarker - The end marker string
|
||||
* @returns Content with marker block removed, or original content if markers not found/invalid
|
||||
*/
|
||||
export function removeMarkerBlock(
|
||||
content: string,
|
||||
startMarker: string,
|
||||
endMarker: string
|
||||
): string {
|
||||
const startIndex = findMarkerIndex(content, startMarker);
|
||||
const endIndex = startIndex !== -1
|
||||
? findMarkerIndex(content, endMarker, startIndex + startMarker.length)
|
||||
: findMarkerIndex(content, endMarker);
|
||||
|
||||
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
|
||||
return content;
|
||||
}
|
||||
|
||||
// Find the start of the line containing the start marker
|
||||
let lineStart = startIndex;
|
||||
while (lineStart > 0 && content[lineStart - 1] !== '\n') {
|
||||
lineStart--;
|
||||
}
|
||||
|
||||
// Find the end of the line containing the end marker
|
||||
let lineEnd = endIndex + endMarker.length;
|
||||
while (lineEnd < content.length && content[lineEnd] !== '\n') {
|
||||
lineEnd++;
|
||||
}
|
||||
// Include the trailing newline if present
|
||||
if (lineEnd < content.length && content[lineEnd] === '\n') {
|
||||
lineEnd++;
|
||||
}
|
||||
|
||||
const before = content.substring(0, lineStart);
|
||||
const after = content.substring(lineEnd);
|
||||
|
||||
// Clean up double blank lines (handle both Unix \n and Windows \r\n)
|
||||
let result = before + after;
|
||||
result = result.replace(/(\r?\n){3,}/g, '\n\n');
|
||||
|
||||
// Trim trailing whitespace but preserve leading whitespace and original newline style
|
||||
if (result.trimEnd() === '') {
|
||||
return '';
|
||||
}
|
||||
const newline = content.includes('\r\n') ? '\r\n' : '\n';
|
||||
return result.trimEnd() + newline;
|
||||
}
|
||||
|
||||
+4
-1
@@ -9,4 +9,7 @@ export {
|
||||
resolveSchemaForChange,
|
||||
validateSchemaName,
|
||||
ChangeMetadataError,
|
||||
} from './change-metadata.js';
|
||||
} from './change-metadata.js';
|
||||
|
||||
// File system utilities
|
||||
export { FileSystemUtils, removeMarkerBlock } from './file-system.js';
|
||||
+19
-18
@@ -90,13 +90,13 @@ describe('openspec CLI e2e basics', () => {
|
||||
env: { CODEX_HOME: codexHome },
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Tool summary:');
|
||||
expect(result.stdout).toContain('OpenSpec Setup Complete');
|
||||
|
||||
// Check that tool configurations were created
|
||||
const claudePath = path.join(emptyProjectDir, 'CLAUDE.md');
|
||||
const cursorProposal = path.join(emptyProjectDir, '.cursor/commands/openspec-proposal.md');
|
||||
expect(await fileExists(claudePath)).toBe(true);
|
||||
expect(await fileExists(cursorProposal)).toBe(true);
|
||||
// Check that skills were created for multiple tools
|
||||
const claudeSkillPath = path.join(emptyProjectDir, '.claude/skills/openspec-explore/SKILL.md');
|
||||
const cursorSkillPath = path.join(emptyProjectDir, '.cursor/skills/openspec-explore/SKILL.md');
|
||||
expect(await fileExists(claudeSkillPath)).toBe(true);
|
||||
expect(await fileExists(cursorSkillPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('initializes with --tools list option', async () => {
|
||||
@@ -106,12 +106,14 @@ describe('openspec CLI e2e basics', () => {
|
||||
|
||||
const result = await runCLI(['init', '--tools', 'claude'], { cwd: emptyProjectDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Tool summary:');
|
||||
expect(result.stdout).toContain('OpenSpec Setup Complete');
|
||||
expect(result.stdout).toContain('Claude Code');
|
||||
|
||||
const claudePath = path.join(emptyProjectDir, 'CLAUDE.md');
|
||||
const cursorProposal = path.join(emptyProjectDir, '.cursor/commands/openspec-proposal.md');
|
||||
expect(await fileExists(claudePath)).toBe(true);
|
||||
expect(await fileExists(cursorProposal)).toBe(false); // Not selected
|
||||
// New init creates skills, not CLAUDE.md
|
||||
const claudeSkillPath = path.join(emptyProjectDir, '.claude/skills/openspec-explore/SKILL.md');
|
||||
const cursorSkillPath = path.join(emptyProjectDir, '.cursor/skills/openspec-explore/SKILL.md');
|
||||
expect(await fileExists(claudeSkillPath)).toBe(true);
|
||||
expect(await fileExists(cursorSkillPath)).toBe(false); // Not selected
|
||||
});
|
||||
|
||||
it('initializes with --tools none option', async () => {
|
||||
@@ -121,15 +123,14 @@ describe('openspec CLI e2e basics', () => {
|
||||
|
||||
const result = await runCLI(['init', '--tools', 'none'], { cwd: emptyProjectDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Tool summary:');
|
||||
expect(result.stdout).toContain('OpenSpec Setup Complete');
|
||||
|
||||
const claudePath = path.join(emptyProjectDir, 'CLAUDE.md');
|
||||
const cursorProposal = path.join(emptyProjectDir, '.cursor/commands/openspec-proposal.md');
|
||||
const rootAgentsPath = path.join(emptyProjectDir, 'AGENTS.md');
|
||||
// With --tools none, no tool skills should be created
|
||||
const claudeSkillPath = path.join(emptyProjectDir, '.claude/skills/openspec-explore/SKILL.md');
|
||||
const cursorSkillPath = path.join(emptyProjectDir, '.cursor/skills/openspec-explore/SKILL.md');
|
||||
|
||||
expect(await fileExists(rootAgentsPath)).toBe(true);
|
||||
expect(await fileExists(claudePath)).toBe(false);
|
||||
expect(await fileExists(cursorProposal)).toBe(false);
|
||||
expect(await fileExists(claudeSkillPath)).toBe(false);
|
||||
expect(await fileExists(cursorSkillPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns error for invalid tool names', async () => {
|
||||
|
||||
@@ -559,37 +559,37 @@ artifacts:
|
||||
});
|
||||
|
||||
describe('help text', () => {
|
||||
it('marks status command as experimental in help', async () => {
|
||||
it('status command help shows description', async () => {
|
||||
const result = await runCLI(['status', '--help']);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('[Experimental]');
|
||||
expect(result.stdout).toContain('Display artifact completion status');
|
||||
});
|
||||
|
||||
it('marks instructions command as experimental in help', async () => {
|
||||
it('instructions command help shows description', async () => {
|
||||
const result = await runCLI(['instructions', '--help']);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('[Experimental]');
|
||||
expect(result.stdout).toContain('Output enriched instructions');
|
||||
});
|
||||
|
||||
it('marks templates command as experimental in help', async () => {
|
||||
it('templates command help shows description', async () => {
|
||||
const result = await runCLI(['templates', '--help']);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('[Experimental]');
|
||||
expect(result.stdout).toContain('Show resolved template paths');
|
||||
});
|
||||
|
||||
it('marks new command as experimental in help', async () => {
|
||||
it('new command help shows description', async () => {
|
||||
const result = await runCLI(['new', '--help']);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('[Experimental]');
|
||||
expect(result.stdout).toContain('Create new items');
|
||||
});
|
||||
});
|
||||
|
||||
describe('experimental command', () => {
|
||||
it('requires --tool flag', async () => {
|
||||
const result = await runCLI(['experimental'], { cwd: tempDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
describe('experimental command (deprecated alias for init)', () => {
|
||||
it('shows deprecation notice', async () => {
|
||||
const result = await runCLI(['experimental', '--tool', 'claude'], { cwd: tempDir });
|
||||
// May succeed or fail depending on setup, but should show deprecation notice
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain('--tool');
|
||||
expect(output).toContain('deprecated');
|
||||
});
|
||||
|
||||
it('errors for unknown tool', async () => {
|
||||
@@ -598,7 +598,7 @@ artifacts:
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain("Unknown tool 'unknown-tool'");
|
||||
expect(output).toContain('Invalid tool(s): unknown-tool');
|
||||
});
|
||||
|
||||
it('errors for tool without skillsDir', async () => {
|
||||
@@ -608,7 +608,7 @@ artifacts:
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
const output = getOutput(result);
|
||||
expect(output).toContain('does not support skill generation');
|
||||
expect(output).toContain('Invalid tool(s): agents');
|
||||
});
|
||||
|
||||
it('creates skills for Claude tool', async () => {
|
||||
|
||||
+290
-1627
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,179 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getSkillTemplates,
|
||||
getCommandTemplates,
|
||||
getCommandContents,
|
||||
generateSkillContent,
|
||||
} from '../../../src/core/shared/skill-generation.js';
|
||||
|
||||
describe('skill-generation', () => {
|
||||
describe('getSkillTemplates', () => {
|
||||
it('should return all 9 skill templates', () => {
|
||||
const templates = getSkillTemplates();
|
||||
expect(templates).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('should have unique directory names', () => {
|
||||
const templates = getSkillTemplates();
|
||||
const dirNames = templates.map(t => t.dirName);
|
||||
const uniqueDirNames = new Set(dirNames);
|
||||
expect(uniqueDirNames.size).toBe(templates.length);
|
||||
});
|
||||
|
||||
it('should include all expected skills', () => {
|
||||
const templates = getSkillTemplates();
|
||||
const dirNames = templates.map(t => t.dirName);
|
||||
|
||||
expect(dirNames).toContain('openspec-explore');
|
||||
expect(dirNames).toContain('openspec-new-change');
|
||||
expect(dirNames).toContain('openspec-continue-change');
|
||||
expect(dirNames).toContain('openspec-apply-change');
|
||||
expect(dirNames).toContain('openspec-ff-change');
|
||||
expect(dirNames).toContain('openspec-sync-specs');
|
||||
expect(dirNames).toContain('openspec-archive-change');
|
||||
expect(dirNames).toContain('openspec-bulk-archive-change');
|
||||
expect(dirNames).toContain('openspec-verify-change');
|
||||
});
|
||||
|
||||
it('should have valid template structure', () => {
|
||||
const templates = getSkillTemplates();
|
||||
|
||||
for (const { template, dirName } of templates) {
|
||||
expect(template.name).toBeTruthy();
|
||||
expect(template.description).toBeTruthy();
|
||||
expect(template.instructions).toBeTruthy();
|
||||
expect(dirName).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCommandTemplates', () => {
|
||||
it('should return all 9 command templates', () => {
|
||||
const templates = getCommandTemplates();
|
||||
expect(templates).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('should have unique IDs', () => {
|
||||
const templates = getCommandTemplates();
|
||||
const ids = templates.map(t => t.id);
|
||||
const uniqueIds = new Set(ids);
|
||||
expect(uniqueIds.size).toBe(templates.length);
|
||||
});
|
||||
|
||||
it('should include all expected commands', () => {
|
||||
const templates = getCommandTemplates();
|
||||
const ids = templates.map(t => t.id);
|
||||
|
||||
expect(ids).toContain('explore');
|
||||
expect(ids).toContain('new');
|
||||
expect(ids).toContain('continue');
|
||||
expect(ids).toContain('apply');
|
||||
expect(ids).toContain('ff');
|
||||
expect(ids).toContain('sync');
|
||||
expect(ids).toContain('archive');
|
||||
expect(ids).toContain('bulk-archive');
|
||||
expect(ids).toContain('verify');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCommandContents', () => {
|
||||
it('should return all 9 command contents', () => {
|
||||
const contents = getCommandContents();
|
||||
expect(contents).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('should have valid content structure', () => {
|
||||
const contents = getCommandContents();
|
||||
|
||||
for (const content of contents) {
|
||||
expect(content.id).toBeTruthy();
|
||||
expect(content.name).toBeTruthy();
|
||||
expect(content.description).toBeTruthy();
|
||||
expect(content.body).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('should have matching IDs with command templates', () => {
|
||||
const templates = getCommandTemplates();
|
||||
const contents = getCommandContents();
|
||||
|
||||
const templateIds = templates.map(t => t.id).sort();
|
||||
const contentIds = contents.map(c => c.id).sort();
|
||||
|
||||
expect(contentIds).toEqual(templateIds);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSkillContent', () => {
|
||||
it('should generate valid YAML frontmatter', () => {
|
||||
const template = {
|
||||
name: 'test-skill',
|
||||
description: 'Test description',
|
||||
instructions: 'Test instructions',
|
||||
license: 'MIT',
|
||||
compatibility: 'Test compatibility',
|
||||
metadata: {
|
||||
author: 'test-author',
|
||||
version: '2.0',
|
||||
},
|
||||
};
|
||||
|
||||
const content = generateSkillContent(template, '0.23.0');
|
||||
|
||||
expect(content).toMatch(/^---\n/);
|
||||
expect(content).toContain('name: test-skill');
|
||||
expect(content).toContain('description: Test description');
|
||||
expect(content).toContain('license: MIT');
|
||||
expect(content).toContain('compatibility: Test compatibility');
|
||||
expect(content).toContain('author: test-author');
|
||||
expect(content).toContain('version: "2.0"');
|
||||
expect(content).toContain('generatedBy: "0.23.0"');
|
||||
expect(content).toContain('Test instructions');
|
||||
});
|
||||
|
||||
it('should use default values for optional fields', () => {
|
||||
const template = {
|
||||
name: 'minimal-skill',
|
||||
description: 'Minimal description',
|
||||
instructions: 'Minimal instructions',
|
||||
};
|
||||
|
||||
const content = generateSkillContent(template, '0.24.0');
|
||||
|
||||
expect(content).toContain('license: MIT');
|
||||
expect(content).toContain('compatibility: Requires openspec CLI.');
|
||||
expect(content).toContain('author: openspec');
|
||||
expect(content).toContain('version: "1.0"');
|
||||
expect(content).toContain('generatedBy: "0.24.0"');
|
||||
});
|
||||
|
||||
it('should embed the provided version in generatedBy field', () => {
|
||||
const template = {
|
||||
name: 'version-test',
|
||||
description: 'Test version embedding',
|
||||
instructions: 'Instructions',
|
||||
};
|
||||
|
||||
const content1 = generateSkillContent(template, '0.23.0');
|
||||
expect(content1).toContain('generatedBy: "0.23.0"');
|
||||
|
||||
const content2 = generateSkillContent(template, '1.0.0');
|
||||
expect(content2).toContain('generatedBy: "1.0.0"');
|
||||
|
||||
const content3 = generateSkillContent(template, '0.24.0-beta.1');
|
||||
expect(content3).toContain('generatedBy: "0.24.0-beta.1"');
|
||||
});
|
||||
|
||||
it('should end frontmatter with separator and blank line', () => {
|
||||
const template = {
|
||||
name: 'test',
|
||||
description: 'Test',
|
||||
instructions: 'Body content',
|
||||
};
|
||||
|
||||
const content = generateSkillContent(template, '0.23.0');
|
||||
|
||||
expect(content).toMatch(/---\n\nBody content\n$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
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 {
|
||||
SKILL_NAMES,
|
||||
getToolsWithSkillsDir,
|
||||
getToolSkillStatus,
|
||||
getToolStates,
|
||||
extractGeneratedByVersion,
|
||||
getToolVersionStatus,
|
||||
getConfiguredTools,
|
||||
getAllToolVersionStatus,
|
||||
} from '../../../src/core/shared/tool-detection.js';
|
||||
|
||||
describe('tool-detection', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('SKILL_NAMES', () => {
|
||||
it('should contain all 9 skill names', () => {
|
||||
expect(SKILL_NAMES).toHaveLength(9);
|
||||
expect(SKILL_NAMES).toContain('openspec-explore');
|
||||
expect(SKILL_NAMES).toContain('openspec-new-change');
|
||||
expect(SKILL_NAMES).toContain('openspec-continue-change');
|
||||
expect(SKILL_NAMES).toContain('openspec-apply-change');
|
||||
expect(SKILL_NAMES).toContain('openspec-ff-change');
|
||||
expect(SKILL_NAMES).toContain('openspec-sync-specs');
|
||||
expect(SKILL_NAMES).toContain('openspec-archive-change');
|
||||
expect(SKILL_NAMES).toContain('openspec-bulk-archive-change');
|
||||
expect(SKILL_NAMES).toContain('openspec-verify-change');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getToolsWithSkillsDir', () => {
|
||||
it('should return tools that have skillsDir configured', () => {
|
||||
const tools = getToolsWithSkillsDir();
|
||||
expect(tools).toContain('claude');
|
||||
expect(tools).toContain('cursor');
|
||||
expect(tools).toContain('windsurf');
|
||||
expect(tools.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getToolSkillStatus', () => {
|
||||
it('should return not configured for unknown tool', () => {
|
||||
const status = getToolSkillStatus(testDir, 'unknown-tool');
|
||||
expect(status.configured).toBe(false);
|
||||
expect(status.fullyConfigured).toBe(false);
|
||||
expect(status.skillCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should return not configured when no skills exist', () => {
|
||||
const status = getToolSkillStatus(testDir, 'claude');
|
||||
expect(status.configured).toBe(false);
|
||||
expect(status.fullyConfigured).toBe(false);
|
||||
expect(status.skillCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should detect when one skill exists', async () => {
|
||||
const skillDir = path.join(testDir, '.claude', 'skills', 'openspec-explore');
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, 'SKILL.md'), 'test content');
|
||||
|
||||
const status = getToolSkillStatus(testDir, 'claude');
|
||||
expect(status.configured).toBe(true);
|
||||
expect(status.fullyConfigured).toBe(false);
|
||||
expect(status.skillCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should detect when all skills exist', async () => {
|
||||
for (const skillName of SKILL_NAMES) {
|
||||
const skillDir = path.join(testDir, '.claude', 'skills', skillName);
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, 'SKILL.md'), 'test content');
|
||||
}
|
||||
|
||||
const status = getToolSkillStatus(testDir, 'claude');
|
||||
expect(status.configured).toBe(true);
|
||||
expect(status.fullyConfigured).toBe(true);
|
||||
expect(status.skillCount).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getToolStates', () => {
|
||||
it('should return status for all tools with skillsDir', () => {
|
||||
const states = getToolStates(testDir);
|
||||
expect(states.has('claude')).toBe(true);
|
||||
expect(states.has('cursor')).toBe(true);
|
||||
|
||||
const claudeStatus = states.get('claude');
|
||||
expect(claudeStatus?.configured).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect configured tools', async () => {
|
||||
const skillDir = path.join(testDir, '.claude', 'skills', 'openspec-explore');
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, 'SKILL.md'), 'test content');
|
||||
|
||||
const states = getToolStates(testDir);
|
||||
expect(states.get('claude')?.configured).toBe(true);
|
||||
expect(states.get('cursor')?.configured).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractGeneratedByVersion', () => {
|
||||
it('should return null for non-existent file', () => {
|
||||
const version = extractGeneratedByVersion(path.join(testDir, 'missing.md'));
|
||||
expect(version).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when generatedBy is not present', async () => {
|
||||
const filePath = path.join(testDir, 'skill.md');
|
||||
await fs.writeFile(filePath, `---
|
||||
name: openspec-explore
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
Content here
|
||||
`);
|
||||
|
||||
const version = extractGeneratedByVersion(filePath);
|
||||
expect(version).toBeNull();
|
||||
});
|
||||
|
||||
it('should extract generatedBy version with double quotes', async () => {
|
||||
const filePath = path.join(testDir, 'skill.md');
|
||||
await fs.writeFile(filePath, `---
|
||||
name: openspec-explore
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "0.23.0"
|
||||
---
|
||||
|
||||
Content here
|
||||
`);
|
||||
|
||||
const version = extractGeneratedByVersion(filePath);
|
||||
expect(version).toBe('0.23.0');
|
||||
});
|
||||
|
||||
it('should extract generatedBy version with single quotes', async () => {
|
||||
const filePath = path.join(testDir, 'skill.md');
|
||||
await fs.writeFile(filePath, `---
|
||||
name: openspec-explore
|
||||
metadata:
|
||||
generatedBy: '0.24.0'
|
||||
---
|
||||
|
||||
Content here
|
||||
`);
|
||||
|
||||
const version = extractGeneratedByVersion(filePath);
|
||||
expect(version).toBe('0.24.0');
|
||||
});
|
||||
|
||||
it('should extract generatedBy version without quotes', async () => {
|
||||
const filePath = path.join(testDir, 'skill.md');
|
||||
await fs.writeFile(filePath, `---
|
||||
name: openspec-explore
|
||||
metadata:
|
||||
generatedBy: 0.25.0
|
||||
---
|
||||
|
||||
Content here
|
||||
`);
|
||||
|
||||
const version = extractGeneratedByVersion(filePath);
|
||||
expect(version).toBe('0.25.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getToolVersionStatus', () => {
|
||||
it('should return not configured for unknown tool', () => {
|
||||
const status = getToolVersionStatus(testDir, 'unknown-tool', '0.23.0');
|
||||
expect(status.configured).toBe(false);
|
||||
expect(status.generatedByVersion).toBeNull();
|
||||
expect(status.needsUpdate).toBe(false);
|
||||
});
|
||||
|
||||
it('should return not configured when no skills exist', () => {
|
||||
const status = getToolVersionStatus(testDir, 'claude', '0.23.0');
|
||||
expect(status.configured).toBe(false);
|
||||
expect(status.generatedByVersion).toBeNull();
|
||||
expect(status.needsUpdate).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect needsUpdate when generatedBy is missing', async () => {
|
||||
const skillDir = path.join(testDir, '.claude', 'skills', 'openspec-explore');
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, 'SKILL.md'), `---
|
||||
name: openspec-explore
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
Content here
|
||||
`);
|
||||
|
||||
const status = getToolVersionStatus(testDir, 'claude', '0.23.0');
|
||||
expect(status.configured).toBe(true);
|
||||
expect(status.generatedByVersion).toBeNull();
|
||||
expect(status.needsUpdate).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect needsUpdate when version differs', async () => {
|
||||
const skillDir = path.join(testDir, '.claude', 'skills', 'openspec-explore');
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, 'SKILL.md'), `---
|
||||
name: openspec-explore
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "0.22.0"
|
||||
---
|
||||
|
||||
Content here
|
||||
`);
|
||||
|
||||
const status = getToolVersionStatus(testDir, 'claude', '0.23.0');
|
||||
expect(status.configured).toBe(true);
|
||||
expect(status.generatedByVersion).toBe('0.22.0');
|
||||
expect(status.needsUpdate).toBe(true);
|
||||
});
|
||||
|
||||
it('should not need update when version matches', async () => {
|
||||
const skillDir = path.join(testDir, '.claude', 'skills', 'openspec-explore');
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, 'SKILL.md'), `---
|
||||
name: openspec-explore
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "0.23.0"
|
||||
---
|
||||
|
||||
Content here
|
||||
`);
|
||||
|
||||
const status = getToolVersionStatus(testDir, 'claude', '0.23.0');
|
||||
expect(status.configured).toBe(true);
|
||||
expect(status.generatedByVersion).toBe('0.23.0');
|
||||
expect(status.needsUpdate).toBe(false);
|
||||
});
|
||||
|
||||
it('should include tool name in status', async () => {
|
||||
const skillDir = path.join(testDir, '.claude', 'skills', 'openspec-explore');
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, 'SKILL.md'), 'content');
|
||||
|
||||
const status = getToolVersionStatus(testDir, 'claude', '0.23.0');
|
||||
expect(status.toolId).toBe('claude');
|
||||
expect(status.toolName).toBe('Claude Code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConfiguredTools', () => {
|
||||
it('should return empty array when no tools are configured', () => {
|
||||
const tools = getConfiguredTools(testDir);
|
||||
expect(tools).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return configured tools', async () => {
|
||||
// Setup Claude
|
||||
const claudeSkillDir = path.join(testDir, '.claude', 'skills', 'openspec-explore');
|
||||
await fs.mkdir(claudeSkillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(claudeSkillDir, 'SKILL.md'), 'content');
|
||||
|
||||
// Setup Cursor
|
||||
const cursorSkillDir = path.join(testDir, '.cursor', 'skills', 'openspec-explore');
|
||||
await fs.mkdir(cursorSkillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(cursorSkillDir, 'SKILL.md'), 'content');
|
||||
|
||||
const tools = getConfiguredTools(testDir);
|
||||
expect(tools).toContain('claude');
|
||||
expect(tools).toContain('cursor');
|
||||
expect(tools).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllToolVersionStatus', () => {
|
||||
it('should return empty array when no tools are configured', () => {
|
||||
const statuses = getAllToolVersionStatus(testDir, '0.23.0');
|
||||
expect(statuses).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return version status for all configured tools', async () => {
|
||||
// Setup Claude with old version
|
||||
const claudeSkillDir = path.join(testDir, '.claude', 'skills', 'openspec-explore');
|
||||
await fs.mkdir(claudeSkillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(claudeSkillDir, 'SKILL.md'), `---
|
||||
metadata:
|
||||
generatedBy: "0.22.0"
|
||||
---
|
||||
`);
|
||||
|
||||
// Setup Cursor with current version
|
||||
const cursorSkillDir = path.join(testDir, '.cursor', 'skills', 'openspec-explore');
|
||||
await fs.mkdir(cursorSkillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(cursorSkillDir, 'SKILL.md'), `---
|
||||
metadata:
|
||||
generatedBy: "0.23.0"
|
||||
---
|
||||
`);
|
||||
|
||||
const statuses = getAllToolVersionStatus(testDir, '0.23.0');
|
||||
expect(statuses).toHaveLength(2);
|
||||
|
||||
const claudeStatus = statuses.find(s => s.toolId === 'claude');
|
||||
expect(claudeStatus?.generatedByVersion).toBe('0.22.0');
|
||||
expect(claudeStatus?.needsUpdate).toBe(true);
|
||||
|
||||
const cursorStatus = statuses.find(s => s.toolId === 'cursor');
|
||||
expect(cursorStatus?.generatedByVersion).toBe('0.23.0');
|
||||
expect(cursorStatus?.needsUpdate).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
+1305
-1672
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { FileSystemUtils } from '../../src/utils/file-system.js';
|
||||
import { FileSystemUtils, removeMarkerBlock } from '../../src/utils/file-system.js';
|
||||
|
||||
describe('FileSystemUtils.updateFileWithMarkers', () => {
|
||||
let testDir: string;
|
||||
@@ -285,3 +285,164 @@ ${END_MARKER}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMarkerBlock', () => {
|
||||
const START_MARKER = '<!-- OPENSPEC:START -->';
|
||||
const END_MARKER = '<!-- OPENSPEC:END -->';
|
||||
|
||||
describe('basic removal', () => {
|
||||
it('should remove marker block and preserve content before', () => {
|
||||
const content = `User content before
|
||||
${START_MARKER}
|
||||
OpenSpec content
|
||||
${END_MARKER}`;
|
||||
const result = removeMarkerBlock(content, START_MARKER, END_MARKER);
|
||||
expect(result).toBe('User content before\n');
|
||||
expect(result).not.toContain(START_MARKER);
|
||||
expect(result).not.toContain(END_MARKER);
|
||||
});
|
||||
|
||||
it('should remove marker block and preserve content after', () => {
|
||||
const content = `${START_MARKER}
|
||||
OpenSpec content
|
||||
${END_MARKER}
|
||||
User content after`;
|
||||
const result = removeMarkerBlock(content, START_MARKER, END_MARKER);
|
||||
expect(result).toBe('User content after\n');
|
||||
});
|
||||
|
||||
it('should remove marker block and preserve content before and after', () => {
|
||||
const content = `User content before
|
||||
${START_MARKER}
|
||||
OpenSpec content
|
||||
${END_MARKER}
|
||||
User content after`;
|
||||
const result = removeMarkerBlock(content, START_MARKER, END_MARKER);
|
||||
expect(result).toContain('User content before');
|
||||
expect(result).toContain('User content after');
|
||||
expect(result).not.toContain(START_MARKER);
|
||||
});
|
||||
|
||||
it('should return empty string when only markers remain', () => {
|
||||
const content = `${START_MARKER}
|
||||
OpenSpec content
|
||||
${END_MARKER}`;
|
||||
const result = removeMarkerBlock(content, START_MARKER, END_MARKER);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid states', () => {
|
||||
it('should return original content when markers are missing', () => {
|
||||
const content = 'Plain content without markers';
|
||||
const result = removeMarkerBlock(content, START_MARKER, END_MARKER);
|
||||
expect(result).toBe('Plain content without markers');
|
||||
});
|
||||
|
||||
it('should return original content when only start marker exists', () => {
|
||||
const content = `${START_MARKER}
|
||||
Content without end marker`;
|
||||
const result = removeMarkerBlock(content, START_MARKER, END_MARKER);
|
||||
expect(result).toContain(START_MARKER);
|
||||
});
|
||||
|
||||
it('should return original content when only end marker exists', () => {
|
||||
const content = `Content without start marker
|
||||
${END_MARKER}`;
|
||||
const result = removeMarkerBlock(content, START_MARKER, END_MARKER);
|
||||
expect(result).toContain(END_MARKER);
|
||||
});
|
||||
|
||||
it('should return original content when markers are in wrong order', () => {
|
||||
const content = `${END_MARKER}
|
||||
Content
|
||||
${START_MARKER}`;
|
||||
const result = removeMarkerBlock(content, START_MARKER, END_MARKER);
|
||||
expect(result).toContain(END_MARKER);
|
||||
expect(result).toContain(START_MARKER);
|
||||
});
|
||||
});
|
||||
|
||||
describe('whitespace handling', () => {
|
||||
it('should clean up double blank lines', () => {
|
||||
const content = `Line 1
|
||||
|
||||
|
||||
${START_MARKER}
|
||||
OpenSpec content
|
||||
${END_MARKER}
|
||||
|
||||
|
||||
Line 2`;
|
||||
const result = removeMarkerBlock(content, START_MARKER, END_MARKER);
|
||||
expect(result).not.toMatch(/\n{3,}/);
|
||||
});
|
||||
|
||||
it('should handle markers with whitespace on same line', () => {
|
||||
const content = `User content
|
||||
${START_MARKER}
|
||||
OpenSpec content
|
||||
${END_MARKER}
|
||||
More content`;
|
||||
const result = removeMarkerBlock(content, START_MARKER, END_MARKER);
|
||||
expect(result).toContain('User content');
|
||||
expect(result).toContain('More content');
|
||||
expect(result).not.toContain(START_MARKER);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inline marker mentions', () => {
|
||||
it('should ignore inline mentions and only remove actual marker block', () => {
|
||||
const content = `Intro referencing markers like ${START_MARKER} and ${END_MARKER} inside text.
|
||||
|
||||
${START_MARKER}
|
||||
Original content
|
||||
${END_MARKER}
|
||||
`;
|
||||
const result = removeMarkerBlock(content, START_MARKER, END_MARKER);
|
||||
// Inline mentions should be preserved
|
||||
expect(result).toContain('Intro referencing markers like');
|
||||
expect(result).toContain(`${START_MARKER} and ${END_MARKER} inside text`);
|
||||
// Original content between markers should be removed
|
||||
expect(result).not.toContain('Original content');
|
||||
});
|
||||
|
||||
it('should handle multiple inline mentions before actual block', () => {
|
||||
const content = `The ${START_MARKER} marker starts a block.
|
||||
The ${END_MARKER} marker ends it.
|
||||
Here is the actual block:
|
||||
${START_MARKER}
|
||||
Managed content
|
||||
${END_MARKER}
|
||||
After block content`;
|
||||
const result = removeMarkerBlock(content, START_MARKER, END_MARKER);
|
||||
expect(result).toContain(`The ${START_MARKER} marker starts a block`);
|
||||
expect(result).toContain(`The ${END_MARKER} marker ends it`);
|
||||
expect(result).toContain('After block content');
|
||||
expect(result).not.toContain('Managed content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shell markers', () => {
|
||||
const SHELL_START = '# OPENSPEC:START';
|
||||
const SHELL_END = '# OPENSPEC:END';
|
||||
|
||||
it('should work with shell-style markers', () => {
|
||||
const content = `# User config
|
||||
export PATH="/usr/local/bin:$PATH"
|
||||
|
||||
${SHELL_START}
|
||||
# OpenSpec managed
|
||||
alias openspec="npx openspec"
|
||||
${SHELL_END}
|
||||
|
||||
# More user config
|
||||
export EDITOR="vim"`;
|
||||
const result = removeMarkerBlock(content, SHELL_START, SHELL_END);
|
||||
expect(result).toContain('export PATH');
|
||||
expect(result).toContain('export EDITOR');
|
||||
expect(result).not.toContain('alias openspec');
|
||||
expect(result).not.toContain(SHELL_START);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user