Compare commits

..
Author SHA1 Message Date
Tabish Bidiwale 31c78a5e38 docs: add multi-language output guide
Document how to configure OpenSpec to generate artifacts in languages
other than English using the existing context field in config.yaml.

Includes examples for Portuguese, Spanish, Chinese, Japanese, French,
and German, plus tips for handling technical terminology.
2026-01-20 00:55:26 -08:00
133 changed files with 8669 additions and 10650 deletions
+1 -4
View File
@@ -3,8 +3,6 @@ name: Polish Release Notes
# Uses Claude to transform raw changelog into polished release notes.
# Triggered automatically by release-prepare after publishing, or manually.
on:
repository_dispatch:
types: [polish-release-notes]
workflow_dispatch:
inputs:
tag_name:
@@ -13,8 +11,7 @@ on:
type: string
env:
# repository_dispatch passes tag via client_payload, workflow_dispatch via inputs
TAG_NAME: ${{ github.event.client_payload.tag_name || inputs.tag_name }}
TAG_NAME: ${{ inputs.tag_name }}
permissions:
contents: write
+1 -6
View File
@@ -60,9 +60,6 @@ jobs:
# npm authentication handled via OIDC trusted publishing (no token needed)
# Trigger release notes polishing after a release is published
# Uses repository_dispatch instead of workflow_dispatch because:
# - workflow_dispatch requires actions:write permission (GitHub App doesn't have it)
# - repository_dispatch works with contents:write (which we already have)
- name: Polish release notes
if: steps.changesets.outputs.published == 'true'
env:
@@ -71,6 +68,4 @@ jobs:
# Get version from package.json (just bumped by changesets)
TAG="v$(jq -r .version package.json)"
echo "Triggering polish workflow for $TAG"
gh api repos/${{ github.repository }}/dispatches \
--method POST \
--input - <<< "{\"event_type\":\"polish-release-notes\",\"client_payload\":{\"tag_name\":\"$TAG\"}}"
gh workflow run polish-release-notes.yml -f tag_name="$TAG"
+18
View File
@@ -0,0 +1,18 @@
<!-- 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 -->
-12
View File
@@ -1,17 +1,5 @@
# @fission-ai/openspec
## 0.23.0
### Minor Changes
- [#540](https://github.com/Fission-AI/OpenSpec/pull/540) [`c4cfdc7`](https://github.com/Fission-AI/OpenSpec/commit/c4cfdc7c499daef30d8a218f5f59b8d9e5adb754) Thanks [@TabishB](https://github.com/TabishB)! - ### New Features
- **Bulk archive skill** — Archive multiple completed changes in a single operation with `/opsx:bulk-archive`. Includes batch validation, spec conflict detection, and consolidated confirmation
### Other
- **Simplified setup** — Config creation now uses sensible defaults with helpful comments instead of interactive prompts
## 0.22.0
### Minor Changes
+109 -125
View File
@@ -27,7 +27,7 @@
</p>
<p align="center">
<sub>🧪 <strong>OPSX Workflow</strong> — schema-driven, hackable, fluid. See <a href="docs/experimental-workflow.md">workflow docs</a> for details.</sub>
<sub>🧪 <strong>New:</strong> <a href="docs/experimental-workflow.md">Experimental Workflow (OPSX)</a> — schema-driven, hackable, fluid. Iterate on workflows without code changes.</sub>
</p>
# OpenSpec
@@ -89,26 +89,43 @@ 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>Tools with Agent Skills + Slash Commands</strong> (click to expand)</summary>
<summary><strong>Native Slash Commands</strong> (click to expand)</summary>
These tools support the full OpenSpec workflow with skills and commands:
These tools have built-in OpenSpec commands. Select the OpenSpec integration when prompted.
| Tool | Skills Location | Commands |
|------|-----------------|----------|
| **Claude Code** | `.claude/skills/` | `/opsx:new`, `/opsx:apply`, `/opsx:archive`, etc. |
| **Cursor** | `.cursor/skills/` | `/opsx:*` commands via prompts |
| 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 (CLI)** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.qoder/commands/openspec/`) — see [docs](https://qoder.com/cli) |
| **Qwen Code** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.qwen/commands/`) |
| **RooCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.roo/commands/`) |
| **Windsurf** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.windsurf/workflows/`) |
Run `openspec init` and select the tools you use. Skills and commands are generated automatically.
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`.
</details>
<details>
<summary><strong>AGENTS.md Compatible</strong> (click to expand)</summary>
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/).
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 |
|-------|
@@ -180,139 +197,102 @@ openspec init
```
**What happens during initialization:**
- 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)
**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.
**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
```
- 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
**After setup:**
- Run `/opsx:new` to start your first change
- Primary AI tools can trigger `/openspec` workflows without additional configuration
- Run `openspec list` to verify the setup and view any active changes
- Restart your IDE for slash commands to take effect
- 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
### Optional: Configure Project Context
### Optional: Populate Project Context
After `openspec init`, you can customize `openspec/config.yaml` to inject project-specific context into all artifacts:
After `openspec init` completes, you'll receive a suggested prompt to help populate your project context:
```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
```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"
```
This context is automatically included in artifact instructions, helping the AI understand your project's conventions.
Use `openspec/project.md` to define project-level conventions, standards, architectural patterns, and other guidelines that should be followed across all changes.
### Create Your First Change
Here's a real example showing the complete OpenSpec workflow using `/opsx:*` commands.
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. Start a New Change
```text
You: /opsx:new
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. Build Out Artifacts
Use `/opsx:continue` to create artifacts one at a time, or `/opsx:ff` to fast-forward through all planning artifacts:
#### 1. Draft the Proposal
Start by asking your AI to create a change proposal:
```text
You: /opsx:continue
You: Create an OpenSpec change proposal for adding profile search filters by role and team
(Shortcut for tools with slash commands: /openspec:proposal Add profile search filters)
AI: Proposal is done. Ready to create: specs, design
Which artifact should I create?
You: specs
AI: *Creates specs with requirements and scenarios*
AI: I'll create an OpenSpec change proposal for profile filters.
*Scaffolds openspec/changes/add-profile-filters/ with proposal.md, tasks.md, spec deltas.*
```
Or fast-forward through everything:
```text
You: /opsx:ff add-profile-filters
#### 2. Verify & Review
Check that the change was created correctly and review the proposal:
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
$ 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.*
```
#### 4. Implement the Change
Once specs look good, start implementation:
```text
You: /opsx:apply
You: The specs look good. Let's implement this change.
(Shortcut for tools with slash commands: /openspec:apply add-profile-filters)
AI: I'll work through the tasks in the add-profile-filters change.
*Implements tasks, marking them complete as it goes*
*Implements tasks from openspec/changes/add-profile-filters/tasks.md*
*Marks tasks complete: Task 1.1 ✓, Task 1.2 ✓, Task 2.1 ✓...*
```
**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
You: /opsx:archive
AI: All tasks are complete. The implementation is ready.
You: Please archive the change
(Shortcut for tools with slash commands: /openspec:archive add-profile-filters)
AI: I'll archive the add-profile-filters change.
*Runs: openspec archive add-profile-filters --yes*
✓ Change archived. Specs updated. Ready for the next feature!
*Runs: openspec archive add-profile-filters --yes*
✓ Change archived successfully. Specs updated. Ready for the next feature!
```
Or run directly in terminal:
Or run the command yourself in terminal:
```bash
openspec archive add-profile-filters --yes
$ openspec archive add-profile-filters --yes # Archive the completed change without prompts
```
**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/
openspec update # Refresh skills and commands for configured tools
openspec archive <change> [--yes|-y] # Move a completed change into archive/ (non-interactive with --yes)
```
## Example: How AI Creates OpenSpec Files
@@ -412,12 +392,12 @@ Without specs, AI coding assistants generate code from vague prompts, often miss
## Team Adoption
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.
1. **Initialize OpenSpec** – Run `openspec init` in your repo.
2. **Start with new features** – Ask your AI to capture upcoming work as change proposals.
3. **Grow incrementally** – Each change archives into living specs that document your system.
4. **Stay flexible** – Different teammates can use Claude Code, Cursor, or any AGENTS.md-compatible tool while sharing the same specs.
4. **Stay flexible** – Different teammates can use Claude Code, CodeBuddy, Cursor, or any AGENTS.md-compatible tool while sharing the same specs.
Run `openspec update` to refresh skills and commands when upgrading OpenSpec or adding new tools.
Run `openspec update` whenever someone switches tools so your agents pick up the latest instructions and slash-command bindings.
## Updating OpenSpec
@@ -425,38 +405,42 @@ Run `openspec update` to refresh skills and commands when upgrading OpenSpec or
```bash
npm install -g @fission-ai/openspec@latest
```
2. **Refresh skills and commands**
```bash
openspec update
```
This regenerates skills and slash commands for all configured tools.
2. **Refresh agent instructions**
- Run `openspec update` inside each project to regenerate AI guidance and ensure the latest slash commands are active.
3. **Restart your IDE** for slash commands to take effect.
## Workflow Customization
## Experimental Features
<details>
<summary><strong>Custom Schemas & Templates</strong></summary>
<summary><strong>🧪 OPSX: Fluid, Iterative Workflow</strong> (Claude Code only)</summary>
OpenSpec uses a **schema-driven workflow** that you can customize:
**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
**Why customize:**
**What's different:**
- **Hackable** — edit templates and schemas yourself, test immediately, no rebuild
- **Granular** — each artifact has its own instructions, test and tweak individually
- **Customizable** — define your own workflows, artifacts, and dependencies
- **Fluid** — no phase gates, update any artifact anytime
**Built-in schemas:**
- `spec-driven` (default): proposal → specs → design → tasks
- `tdd`: tests → implementation → docs
```
You can always go back:
**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
proposal ──→ specs ──→ design ──→ tasks ──→ implement
▲ ▲ ▲ │
└───────────┴──────────┴────────────────────┘
```
Schemas are stored in `openspec/schemas/` (project) or `~/.local/share/openspec/schemas/` (global).
| Command | What it does |
|---------|--------------|
| `/opsx:new` | Start a new change |
| `/opsx:continue` | Create the next artifact (based on what's ready) |
| `/opsx:ff` | Fast-forward (all planning artifacts at once) |
| `/opsx:apply` | Implement tasks, updating artifacts as needed |
| `/opsx:archive` | Archive when done |
**Setup:** `openspec artifact-experimental-setup`
[Full documentation →](docs/experimental-workflow.md)
+4 -4
View File
@@ -1,6 +1,6 @@
# Experimental Workflow (OPSX)
> **Status:** Experimental. Things might break. Feedback welcome on [Discord](https://discord.gg/YctCnvvshC).
> **Status:** Experimental. Things might break. Feedback welcome on [Discord](https://discord.gg/BYjPaKbqMt).
>
> **Compatibility:** Claude Code only (for now)
@@ -73,7 +73,7 @@ You can always go back:
openspec init
# 2. Generate the experimental skills
openspec experimental
openspec artifact-experimental-setup
```
This creates skills in `.claude/skills/` that Claude Code auto-detects.
@@ -86,7 +86,7 @@ Project config lets you set defaults and inject project-specific context into al
### Creating Config
Config is created during `experimental`, or manually:
Config is created during `artifact-experimental-setup`, or manually:
```yaml
# openspec/config.yaml
@@ -662,4 +662,4 @@ openspec schema validate my-workflow
This is rough. That's intentional — we're learning what works.
Found a bug? Have ideas? Join us on [Discord](https://discord.gg/YctCnvvshC) or open an issue on [GitHub](https://github.com/Fission-AI/openspec/issues).
Found a bug? Have ideas? Join us on [Discord](https://discord.gg/BYjPaKbqMt) or open an issue on [GitHub](https://github.com/Fission-AI/openspec/issues).
+217
View File
@@ -0,0 +1,217 @@
# Multi-Language Output Guide
Configure OpenSpec to generate artifacts in languages other than English.
## Overview
OpenSpec can output proposals, specs, designs, and tasks in any language by adding language instructions to your project's `context` configuration. This approach uses the existing config system without requiring any code changes.
## Quick Setup
### If you already have a config file
Add a language instruction to your existing `openspec/config.yaml`:
```yaml
schema: spec-driven
context: |
Language: Portuguese (pt-BR)
All artifacts must be written in Brazilian Portuguese.
Use Portuguese technical terminology where appropriate.
# Your other project context below...
Tech stack: TypeScript, React, Node.js
```
That's it. All generated artifacts will now be in Portuguese.
### If you don't have a config file yet
You can create one interactively or manually:
**Option 1: Interactive setup**
```bash
openspec artifact-experimental-setup
```
This will guide you through creating `openspec/config.yaml` with schema selection, context, and rules.
**Option 2: Manual creation**
Create the file `openspec/config.yaml` in your project root:
```bash
mkdir -p openspec
cat > openspec/config.yaml << 'EOF'
schema: spec-driven
context: |
Language: Portuguese (pt-BR)
All artifacts must be written in Brazilian Portuguese.
EOF
```
## Language Examples
### Portuguese (Brazil)
```yaml
context: |
Language: Portuguese (pt-BR)
All artifacts (proposals, specs, designs, tasks) must be written in Brazilian Portuguese.
Use Brazilian Portuguese conventions for technical terminology.
```
### Spanish
```yaml
context: |
Idioma: Español
Todos los artefactos deben escribirse en español.
Utilizar terminología técnica en español cuando sea posible.
```
### Chinese (Simplified)
```yaml
context: |
语言:中文(简体)
所有产出物(提案、规格、设计、任务)必须用简体中文撰写。
技术术语可以保留英文原文,但说明应使用中文。
```
### Japanese
```yaml
context: |
言語:日本語
すべての成果物は日本語で作成してください。
技術用語は必要に応じて英語を併記可能です。
```
### French
```yaml
context: |
Langue : Français
Tous les artefacts doivent être rédigés en français.
Utiliser la terminologie technique française lorsque possible.
```
### German
```yaml
context: |
Sprache: Deutsch
Alle Artefakte müssen auf Deutsch verfasst werden.
Technische Fachbegriffe können auf Englisch beibehalten werden.
```
## How It Works
The `context` field in `openspec/config.yaml` is injected into every artifact's instructions as an XML block:
```xml
<context>
Language: Portuguese (pt-BR)
All artifacts must be written in Brazilian Portuguese.
...
</context>
<template>
[Schema's template content]
</template>
```
The AI assistant sees this context when generating any artifact and follows the language instruction.
## Tips
### Be Explicit About Scope
Specify which parts should be in the target language:
```yaml
context: |
Language: Spanish
Write all prose, descriptions, and explanations in Spanish.
Code comments may remain in English for consistency with the codebase.
Variable names and code identifiers should stay in English.
```
### Handle Technical Terms
Decide how to handle technical terminology:
```yaml
context: |
Language: Japanese
Write in Japanese, but:
- Keep technical terms like "API", "REST", "GraphQL" in English
- Provide Japanese explanations in parentheses for complex terms on first use
- Code examples and file paths remain in English
```
### Combine with Other Context
Language settings work alongside your other project context:
```yaml
schema: spec-driven
context: |
Language: Portuguese (pt-BR)
All artifacts must be written in Brazilian Portuguese.
Tech stack: TypeScript, React 18, Node.js 20
Database: PostgreSQL with Prisma ORM
Testing: Vitest + React Testing Library
Conventions:
- Use functional components with hooks
- Follow existing patterns in src/components/
```
## Verification
To verify your language config is working:
```bash
# Create a test change
openspec new change test-language
# Check the instructions - should show your language context
openspec instructions proposal --change test-language
# Output will include:
# <context>
# Language: Portuguese (pt-BR)
# All artifacts must be written in Brazilian Portuguese.
# ...
# </context>
```
## Limitations
- Language instructions apply to **all** artifacts. You cannot set different languages for different artifact types.
- The effectiveness depends on the AI model's proficiency in the target language.
- Technical diagrams, code samples, and file paths typically remain in English regardless of language setting.
## Future Considerations
We're considering adding a dedicated `language` field to the config schema in a future release:
```yaml
# Potential future syntax (not yet implemented)
schema: spec-driven
language: pt-BR
```
For now, the `context` approach described above is the recommended method. It provides full flexibility and works with all current versions of OpenSpec.
## Related Documentation
- [Project Config Demo](./project-config-demo.md) - Overview of project configuration
- [Experimental Workflow Guide](./experimental-workflow.md) - Full workflow documentation
+2 -2
View File
@@ -19,7 +19,7 @@
{
default = pkgs.stdenv.mkDerivation (finalAttrs: {
pname = "openspec";
version = "0.23.0";
version = "0.20.0";
src = ./.;
@@ -27,7 +27,7 @@
inherit (finalAttrs) pname version src;
pnpm = pkgs.pnpm_9;
fetcherVersion = 3;
hash = "sha256-9s2kdvd7svK4hofnD66HkDc86WTQeayfF5y7L2dmjNg=";
hash = "sha256-m/7IdY1ou9ljjYAcx3W8AyEJvIZfCBWIWxproQ/INPA=";
};
nativeBuildInputs = with pkgs; [
+456
View File
@@ -0,0 +1,456 @@
# 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,2 +0,0 @@
schema: spec-driven
created: 2026-01-23
@@ -1,193 +0,0 @@
## Context
Currently `openspec init` and `openspec experimental` are separate commands with distinct purposes:
- **init**: Creates `openspec/` directory, generates `AGENTS.md`/`project.md`, configures tool config files (`CLAUDE.md`, etc.), generates old slash commands (`/openspec:proposal`, etc.)
- **experimental**: Generates skills (9 per tool), generates opsx slash commands (`/opsx:new`, etc.), creates `config.yaml`
The skill-based workflow (experimental) is the direction we're going, so we're making it the default by merging into `init`.
## Goals / Non-Goals
**Goals:**
- Single `openspec init` command that sets up the complete skill-based workflow
- Clean migration path for existing users with legacy artifacts
- Remove all code related to config files and old slash commands
- Keep the polished UX from experimental (animated welcome, searchable multi-select)
**Non-Goals:**
- Supporting both workflows simultaneously
- Providing options to use the old workflow
- Backward compatibility for `/openspec:*` commands (breaking change)
## Decisions
### Decision 1: Merge into init, not into experimental
**Choice**: Rewrite `init` to do what `experimental` does, then delete `experimental`.
**Rationale**: `init` is the canonical setup command. Users expect `init` to set up their project. `experimental` was always meant to be temporary.
**Alternatives considered**:
- Keep `experimental` as the main command → confusing name for default behavior
- Create new command → unnecessary, `init` already exists
### Decision 2: Legacy cleanup with Y/N prompt
**Choice**: Detect legacy artifacts, show what was found, prompt `"Legacy files detected. Upgrade and clean up? [Y/n]"`, then remove if confirmed.
**Rationale**: Users should know what's being removed. A single Y/N is simple and decisive. No need for multiple options.
**Alternatives considered**:
- Multiple options (keep/remove/cancel) → overcomplicated
- Silent removal → users might be surprised
- Just warn without removing → leaves cruft
### Decision 3: Surgical removal of legacy content
**Choice**: For files with mixed content (OpenSpec markers + user content), only remove the OpenSpec marker block. For files that are 100% OpenSpec content, delete the entire file.
**Rationale**: Respects user customizations. CLAUDE.md might have other instructions beyond OpenSpec.
**Edge cases**:
- **Config files with mixed content**: Remove only `<!-- OPENSPEC:START -->` to `<!-- OPENSPEC:END -->` block
- **Config files that are 100% OpenSpec**: Delete file entirely (check if content outside markers is empty/whitespace)
- **Old slash command directories** (`.claude/commands/openspec/`): Delete entire directory (ours)
- **`openspec/AGENTS.md`**: Delete (ours)
- **Root `AGENTS.md`**: Only remove OpenSpec marker block, preserve rest
### Decision 6: Preserve project.md with migration hint
**Choice**: Do NOT auto-delete `openspec/project.md`. Preserve it and show a message directing users to manually migrate content to `config.yaml`'s `context:` field.
**Rationale**:
- `project.md` may contain valuable user-written project documentation
- The new workflow uses `config.yaml.context` for the same purpose (auto-injected into artifacts)
- Auto-deleting would lose user content; auto-migrating is complex (needs LLM to compress)
- Users can migrate manually or use `/opsx:explore` to get AI assistance
**Migration path**:
1. During legacy cleanup, detect `openspec/project.md` but do not delete
2. Show in output: "openspec/project.md still exists - migrate content to config.yaml's context: field, then delete"
3. User migrates manually or asks Claude in explore mode: "help me migrate project.md to config.yaml"
4. User deletes project.md when ready
**Why not auto-migrate?**
- `project.md` is verbose (sections, headers, placeholders)
- `config.yaml.context` should be concise and dense
- LLM compression would be ideal but adds complexity and non-determinism to init
- Manual migration lets users decide what's actually important
### Decision 4: Hidden alias for experimental
**Choice**: Keep `openspec experimental` as a hidden command that delegates to `init`.
**Rationale**: Users who learned `experimental` can still use it during transition. Hidden means it won't show in help.
### Decision 5: Reuse existing infrastructure
**Choice**: Reuse skill templates, command adapters, welcome screen, and multi-select from experimental.
**Rationale**: Already built and working. Just needs to be called from init instead of experimental.
## Risks / Trade-offs
| Risk | Mitigation |
|------|------------|
| Users with custom `/openspec:*` commands lose them | Document in release notes; old commands are in git history |
| Mixed-content detection might be imperfect | Conservative approach: if unsure, preserve the file and warn |
| Users confused by missing config files | Clear messaging in init output about what changed |
| `openspec update` might break | Review and update `update` command to work with new structure |
## Architecture
### What init creates (after merge)
```
openspec/
├── config.yaml # Schema settings (from experimental)
├── specs/ # Empty, for user's specs
└── changes/ # Empty, for user's changes
└── archive/
.<tool>/skills/ # 9 skills per selected tool
├── openspec-explore/SKILL.md
├── openspec-new-change/SKILL.md
├── openspec-continue-change/SKILL.md
├── openspec-apply-change/SKILL.md
├── openspec-ff-change/SKILL.md
├── openspec-verify-change/SKILL.md
├── openspec-sync-specs/SKILL.md
├── openspec-archive-change/SKILL.md
└── openspec-bulk-archive-change/SKILL.md
.<tool>/commands/opsx/ # 9 slash commands per selected tool
├── explore.md
├── new.md
├── continue.md
├── apply.md
├── ff.md
├── verify.md
├── sync.md
├── archive.md
└── bulk-archive.md
```
### What init no longer creates
- `CLAUDE.md`, `.cursorrules`, `.windsurfrules`, etc. (config files)
- `openspec/AGENTS.md`
- `openspec/project.md`
- Root `AGENTS.md` stub
- `.claude/commands/openspec/` (old slash commands)
### Legacy detection targets
| Artifact Type | Detection Method | Removal Method |
|--------------|------------------|----------------|
| Config files (CLAUDE.md, etc.) | File exists AND contains OpenSpec markers | Remove marker block; delete file if empty after |
| Old slash command dirs | Directory exists at `.<tool>/commands/openspec/` | Delete entire directory |
| openspec/AGENTS.md | File exists at `openspec/AGENTS.md` | Delete file |
| openspec/project.md | File exists at `openspec/project.md` | **Preserve** - show migration hint only |
| Root AGENTS.md | File exists at `AGENTS.md` AND contains OpenSpec markers | Remove marker block; delete file if empty after |
### Code to remove
- `src/core/configurators/` - entire directory (ToolRegistry, all config generators)
- `src/core/configurators/slash/` - entire directory (SlashCommandRegistry, old command generators)
- `src/core/templates/slash-command-templates.ts` - old `/openspec:*` content
- `src/core/templates/claude-template.ts`
- `src/core/templates/cline-template.ts`
- `src/core/templates/costrict-template.ts`
- `src/core/templates/agents-template.ts`
- `src/core/templates/agents-root-stub.ts`
- `src/core/templates/project-template.ts`
- `src/commands/experimental/` - entire directory (merged into init)
- Related test files
### Code to migrate into init
- Animated welcome screen (`src/ui/welcome-screen.ts`) - keep, call from init
- Searchable multi-select (`src/prompts/searchable-multi-select.ts`) - keep, call from init
- Skill templates (`src/core/templates/skill-templates.ts`) - keep
- Command generation (`src/core/command-generation/`) - keep
- Tool states detection (from `experimental/setup.ts`) - move to init
## Open Questions
1. **What happens to `openspec update`?** - RESOLVED
**Current behavior**: Updates `openspec/AGENTS.md`, config files (`CLAUDE.md`, etc.) via `ToolRegistry`, and old slash commands (`/openspec:*`) via `SlashCommandRegistry`.
**New behavior**: Rewrite to refresh skills and opsx commands instead:
- Detect which tools have skills installed (check for `.claude/skills/openspec-*/`, etc.)
- Refresh all 9 skill files per installed tool using `skill-templates.ts`
- Refresh all 9 opsx command files per installed tool using `command-generation/` adapters
- Remove imports of `ToolRegistry`, `SlashCommandRegistry`, `agentsTemplate`
- Update output messaging to reflect skills/commands instead of config files
**Key principle**: Same as current update - only refresh existing tools, don't add new ones.
2. **Should we keep `openspec schemas` and other experimental subcommands?** - RESOLVED
**Decision**: Yes, keep them. Remove "[Experimental]" label from all subcommands (status, instructions, schemas, etc.). See task 4.3.
@@ -1,32 +0,0 @@
## Why
The current setup has two separate commands (`openspec init` and `openspec experimental`) that configure different parts of the OpenSpec workflow. This creates confusion about which command to run, results in partial setups, and maintains two parallel systems (config files + old slash commands vs skills + opsx commands). Making the skill-based workflow the default simplifies onboarding and establishes a single, consistent way to use OpenSpec.
## What Changes
- **BREAKING**: `openspec init` now generates skills and `/opsx:*` commands instead of config files and `/openspec:*` commands
- **BREAKING**: Config files (`CLAUDE.md`, `.cursorrules`, etc.) are no longer generated
- **BREAKING**: Old slash commands (`/openspec:proposal`, `/openspec:apply`, `/openspec:archive`) are no longer generated
- **BREAKING**: `openspec/AGENTS.md` and `openspec/project.md` are no longer generated
- Merge `experimental` command functionality into `init`
- Add legacy detection and auto-cleanup with Y/N confirmation
- Keep `openspec experimental` as hidden alias for backward compatibility
- Use the animated welcome screen from experimental for the unified init
## Capabilities
### New Capabilities
- `legacy-cleanup`: Detect and remove legacy OpenSpec artifacts (config files, old slash commands, AGENTS.md) during init
### Modified Capabilities
- `cli-init`: Complete rewrite - generates skills and opsx commands instead of config files and old slash commands; removes AGENTS.md/project.md generation; adds legacy cleanup; uses experimental's animated welcome screen
## Impact
- **Code removal**: `ToolRegistry`, `SlashCommandRegistry`, config file generators, old slash command templates, AGENTS.md/project.md templates
- **Code migration**: Move skill generation and command adapter logic from `experimental/setup.ts` into `init.ts`
- **Commands affected**: `init` (rewritten), `experimental` (becomes hidden alias), `update` (may need adjustment)
- **User migration**: Existing users running `init` will be prompted to clean up legacy files
- **Breaking for**: Users relying on config files for passive triggering, users using `/openspec:*` commands
@@ -1,176 +0,0 @@
## MODIFIED Requirements
### Requirement: Directory Creation
The command SHALL create the OpenSpec directory structure with config file.
#### Scenario: Creating OpenSpec structure
- **WHEN** `openspec init` is executed
- **THEN** create the following directory structure:
```
openspec/
├── config.yaml
├── specs/
└── changes/
└── archive/
```
### Requirement: AI Tool Configuration
The command SHALL configure AI coding assistants with skills and slash commands using a searchable multi-select experience.
#### Scenario: Prompting for AI tool selection
- **WHEN** run interactively
- **THEN** display animated welcome screen with OpenSpec logo
- **AND** present a searchable multi-select that shows all available tools
- **AND** mark already configured tools with "(configured ✓)" indicator
- **AND** pre-select configured tools for easy refresh
- **AND** sort configured tools to appear first in the list
- **AND** allow filtering by typing to search
#### Scenario: Selecting tools to configure
- **WHEN** user selects tools and confirms
- **THEN** generate skills in `.<tool>/skills/` directory for each selected tool
- **AND** generate slash commands in `.<tool>/commands/opsx/` directory for each selected tool
- **AND** create `openspec/config.yaml` with default schema setting
### Requirement: Skill Generation
The command SHALL generate Agent Skills for selected AI tools.
#### Scenario: Generating skills for a tool
- **WHEN** a tool is selected during initialization
- **THEN** create 9 skill directories under `.<tool>/skills/`:
- `openspec-explore/SKILL.md`
- `openspec-new-change/SKILL.md`
- `openspec-continue-change/SKILL.md`
- `openspec-apply-change/SKILL.md`
- `openspec-ff-change/SKILL.md`
- `openspec-verify-change/SKILL.md`
- `openspec-sync-specs/SKILL.md`
- `openspec-archive-change/SKILL.md`
- `openspec-bulk-archive-change/SKILL.md`
- **AND** each SKILL.md SHALL contain YAML frontmatter with name and description
- **AND** each SKILL.md SHALL contain the skill instructions
### Requirement: Slash Command Generation
The command SHALL generate opsx slash commands for selected AI tools.
#### Scenario: Generating slash commands for a tool
- **WHEN** a tool is selected during initialization
- **THEN** create 9 slash command files using the tool's command adapter:
- `/opsx:explore`
- `/opsx:new`
- `/opsx:continue`
- `/opsx:apply`
- `/opsx:ff`
- `/opsx:verify`
- `/opsx:sync`
- `/opsx:archive`
- `/opsx:bulk-archive`
- **AND** use tool-specific path conventions (e.g., `.claude/commands/opsx/` for Claude)
- **AND** include tool-specific frontmatter format
### Requirement: Success Output
The command SHALL provide clear, actionable next steps upon successful initialization.
#### Scenario: Displaying success message
- **WHEN** initialization completes successfully
- **THEN** display categorized summary:
- "Created: <tools>" for newly configured tools
- "Refreshed: <tools>" for already-configured tools that were updated
- Count of skills and commands generated
- **AND** display getting started section with:
- `/opsx:new` - Start a new change
- `/opsx:continue` - Create the next artifact
- `/opsx:apply` - Implement tasks
- **AND** display links to documentation and feedback
#### Scenario: Displaying restart instruction
- **WHEN** initialization completes successfully and tools were created or refreshed
- **THEN** display instruction to restart IDE for slash commands to take effect
### Requirement: Config File Generation
The command SHALL create an OpenSpec config file with schema settings.
#### Scenario: Creating config.yaml
- **WHEN** initialization completes
- **AND** config.yaml does not exist
- **THEN** create `openspec/config.yaml` with default schema setting
- **AND** display config location in output
#### Scenario: Preserving existing config.yaml
- **WHEN** initialization runs in extend mode
- **AND** `openspec/config.yaml` already exists
- **THEN** preserve the existing config file
- **AND** display "(exists)" indicator in output
### Requirement: Non-Interactive Mode
The command SHALL support non-interactive operation through command-line options.
#### Scenario: Select all tools non-interactively
- **WHEN** run with `--tools all`
- **THEN** automatically select every available AI tool without prompting
- **AND** proceed with skill and command generation
#### Scenario: Select specific tools non-interactively
- **WHEN** run with `--tools claude,cursor`
- **THEN** parse the comma-separated tool IDs
- **AND** generate skills and commands for specified tools only
#### Scenario: Skip tool configuration non-interactively
- **WHEN** run with `--tools none`
- **THEN** create only the openspec directory structure and config.yaml
- **AND** skip skill and command generation
### Requirement: Experimental Command Alias
The command SHALL maintain backward compatibility with the experimental command.
#### Scenario: Running openspec experimental
- **WHEN** user runs `openspec experimental`
- **THEN** delegate to `openspec init`
- **AND** the command SHALL be hidden from help output
## REMOVED Requirements
### Requirement: File Generation
**Reason**: AGENTS.md and project.md are no longer generated. Skills contain all necessary instructions.
**Migration**: Skills in `.<tool>/skills/` provide all OpenSpec workflow instructions. No manual file needed.
### Requirement: AI Tool Configuration Details
**Reason**: Config files (CLAUDE.md, .cursorrules, etc.) are replaced by skills.
**Migration**: Use skills in `.<tool>/skills/` instead of config files. Skills provide richer, tool-specific instructions.
### Requirement: Slash Command Configuration
**Reason**: Old `/openspec:*` slash commands are replaced by `/opsx:*` commands with richer functionality.
**Migration**: Use `/opsx:new`, `/opsx:continue`, `/opsx:apply` instead of `/openspec:proposal`, `/openspec:apply`, `/openspec:archive`.
### Requirement: Root instruction stub
**Reason**: Root AGENTS.md stub is no longer needed. Skills provide tool-specific instructions.
**Migration**: Skills are loaded automatically by supporting tools. No root stub needed.
@@ -1,158 +0,0 @@
## ADDED Requirements
### Requirement: Legacy artifact detection
The system SHALL detect legacy OpenSpec artifacts from previous init versions.
#### Scenario: Detecting legacy config files
- **WHEN** running `openspec init` on an existing project
- **THEN** the system SHALL check for config files with OpenSpec markers:
- `CLAUDE.md`
- `.cursorrules`
- `.windsurfrules`
- `.clinerules`
- `.kilocode_rules`
- `.github/copilot-instructions.md`
- `.amazonq/instructions.md`
- `CODEBUDDY.md`
- `IFLOW.md`
- And all other tool config files from the legacy ToolRegistry
#### Scenario: Detecting legacy slash command directories
- **WHEN** running `openspec init` on an existing project
- **THEN** the system SHALL check for old slash command directories:
- `.claude/commands/openspec/`
- `.cursor/commands/openspec/` (note: old format used `openspec-*.md` in commands root)
- `.windsurf/workflows/openspec-*.md`
- And equivalent directories for all tools in the legacy SlashCommandRegistry
#### Scenario: Detecting legacy OpenSpec structure files
- **WHEN** running `openspec init` on an existing project
- **THEN** the system SHALL check for:
- `openspec/AGENTS.md`
- `openspec/project.md` (for migration messaging only, not deleted)
- Root `AGENTS.md` with OpenSpec markers
### Requirement: Legacy cleanup confirmation
The system SHALL prompt for confirmation before removing legacy artifacts.
#### Scenario: Prompting for cleanup when legacy detected
- **WHEN** legacy artifacts are detected
- **THEN** the system SHALL display what was found
- **AND** prompt: "Legacy files detected. Upgrade and clean up? [Y/n]"
- **AND** default to Yes if user presses Enter
#### Scenario: User confirms cleanup
- **WHEN** user responds Y or presses Enter
- **THEN** the system SHALL remove legacy artifacts
- **AND** proceed with skill-based setup
#### Scenario: User declines cleanup
- **WHEN** user responds N
- **THEN** the system SHALL abort initialization
- **AND** display message suggesting manual cleanup or using `--force` flag
#### Scenario: Non-interactive mode
- **WHEN** running with `--no-interactive` or in CI environment
- **AND** legacy artifacts are detected
- **THEN** the system SHALL abort with exit code 1
- **AND** display detected legacy artifacts
- **AND** suggest running interactively or using `--force` flag
### Requirement: Surgical removal of config file content
The system SHALL preserve user content when removing OpenSpec markers from config files.
#### Scenario: Config file with only OpenSpec content
- **WHEN** a config file contains only OpenSpec marker block (whitespace outside is acceptable)
- **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
- **WHEN** a config file contains content outside OpenSpec markers
- **THEN** the system SHALL remove only the `<!-- OPENSPEC:START -->` to `<!-- OPENSPEC:END -->` block
- **AND** preserve all content before and after the markers
- **AND** clean up any resulting double blank lines
#### Scenario: Root AGENTS.md with mixed content
- **WHEN** root `AGENTS.md` contains OpenSpec markers AND other content
- **THEN** the system SHALL remove only the OpenSpec marker block
- **AND** preserve the rest of the file
### Requirement: Legacy directory removal
The system SHALL remove legacy slash command directories entirely.
#### Scenario: Removing old slash command directory
- **WHEN** a legacy slash command directory exists (e.g., `.claude/commands/openspec/`)
- **THEN** the system SHALL delete the entire directory and its contents
- **AND** NOT delete the parent directory (e.g., `.claude/commands/` remains)
#### Scenario: Removing legacy AGENTS.md
- **WHEN** `openspec/AGENTS.md` exists
- **THEN** the system SHALL delete the file
- **AND** NOT delete the `openspec/` directory itself
### Requirement: project.md migration hint
The system SHALL preserve project.md and display a migration hint instead of deleting it.
#### Scenario: project.md exists during upgrade
- **WHEN** `openspec/project.md` exists during legacy cleanup
- **THEN** the system SHALL NOT delete the file
- **AND** the system SHALL display a migration hint in the output:
```
Manual migration needed:
→ openspec/project.md still exists
Move useful content to config.yaml's "context:" field, then delete
```
#### Scenario: project.md migration rationale
- **GIVEN** project.md may contain user-written project documentation
- **AND** config.yaml's context field serves the same purpose (auto-injected into artifacts)
- **WHEN** displaying the migration hint
- **THEN** users can migrate manually or use `/opsx:explore` to get AI assistance
### Requirement: Cleanup reporting
The system SHALL report what was cleaned up.
#### Scenario: Displaying cleanup summary
- **WHEN** legacy cleanup completes
- **THEN** the system SHALL display a summary section:
```
Cleaned up legacy files:
✓ Removed OpenSpec markers from CLAUDE.md
✓ Removed .claude/commands/openspec/ (replaced by /opsx:*)
✓ Removed openspec/AGENTS.md (no longer needed)
```
- **AND IF** `openspec/project.md` exists
- **THEN** the system SHALL display a separate migration section:
```
Manual migration needed:
→ openspec/project.md still exists
Move useful content to config.yaml's "context:" field, then delete
```
#### Scenario: No legacy detected
- **WHEN** no legacy artifacts are found
- **THEN** the system SHALL NOT display the cleanup section
- **AND** proceed directly with skill setup
@@ -1,67 +0,0 @@
## 1. Legacy Detection & Cleanup Module
- [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
- [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
- [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
- [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
- [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
- [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
- [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)
@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-01-22
@@ -1,144 +0,0 @@
## Context
The `artifact-experimental-setup` command generates skill files and opsx slash commands for AI coding assistants. Currently it hardcodes paths to `.claude/skills` and `.claude/commands/opsx`.
The existing `AI_TOOLS` array in `config.ts` lists 22 AI tools but lacks path information. There's also an existing `SlashCommandConfigurator` system for the old workflow commands, but it's tightly coupled to the old 3 commands (proposal, apply, archive) and can't be easily extended for the 9 opsx commands.
Each AI tool has:
- Different skill directory conventions (`.claude/skills/`, `.cursor/skills/`, etc.)
- Different command file paths (`.claude/commands/opsx/`, `.cursor/commands/`, etc.)
- Different frontmatter formats (YAML keys, structure varies by tool)
## Goals / Non-Goals
**Goals:**
- Support skill generation for any AI tool following the Agent Skills spec
- Support command generation with tool-specific formatting via adapters
- Require explicit tool selection (no defaults)
- Create a generic, extensible command generation system
**Non-Goals:**
- Global path installation (deferred to future work)
- Multi-tool generation in single command (future enhancement)
- Unifying with existing SlashCommandConfigurator (separate systems for now)
## Decisions
### 1. Add `skillsDir` to `AIToolOption` interface
**Decision**: Add single `skillsDir` field to existing interface. No `commandsDir` or `globalSkillsDir`.
```typescript
interface AIToolOption {
name: string;
value: string;
available: boolean;
successLabel?: string;
skillsDir?: string; // e.g., '.claude' - /skills suffix per Agent Skills spec
}
```
**Rationale**:
- Skills follow Agent Skills spec: `<toolDir>/skills/` - suffix is standard
- Commands need per-tool formatting, handled by adapters (not a simple path)
- Global paths deferred - can extend interface later
### 2. Strategy/Adapter pattern for command generation
**Decision**: Create generic command generation with tool-specific adapters.
```text
┌─────────────────────────────────────────────────────────────────┐
│ CommandContent │
│ (tool-agnostic: id, name, description, category, tags, body) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ generateCommand(content, adapter) │
└─────────────────────────────────────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Claude │ │ Cursor │ │ Windsurf │
│ Adapter │ │ Adapter │ │ Adapter │
└──────────┘ └──────────┘ └──────────┘
```
**Interfaces:**
```typescript
// Tool-agnostic command data
interface CommandContent {
id: string; // e.g., 'explore', 'new', 'apply'
name: string; // e.g., 'OpenSpec Explore'
description: string; // e.g., 'Enter explore mode...'
category: string; // e.g., 'OpenSpec'
tags: string[]; // e.g., ['openspec', 'explore']
body: string; // The command instructions
}
// Per-tool formatting strategy
interface ToolCommandAdapter {
toolId: string;
getFilePath(commandId: string): string;
formatFile(content: CommandContent): string;
}
```
**Rationale**:
- Separates "what to generate" from "how to format it"
- Each tool's frontmatter quirks encapsulated in its adapter
- Easy to add new tools by implementing adapter interface
- Body content shared across all tools
**Alternative considered**: Extend existing SlashCommandConfigurator
- Rejected: Tightly coupled to old 3 commands, significant refactor needed
### 3. Adapter registry pattern
**Decision**: Create `CommandAdapterRegistry` similar to existing `SlashCommandRegistry`.
```typescript
class CommandAdapterRegistry {
private static adapters: Map<string, ToolCommandAdapter> = new Map();
static get(toolId: string): ToolCommandAdapter | undefined;
static getAll(): ToolCommandAdapter[];
}
```
**Rationale**:
- Consistent with existing codebase patterns
- Easy lookup by tool ID
- Centralized registration
### 4. Required tool flag
**Decision**: Require `--tool` flag - error if omitted.
**Rationale**:
- Explicit tool selection avoids assumptions
- Consistent with project convention of not providing defaults
- Users must consciously choose their target tool
## Risks / Trade-offs
**[Risk] Adapter maintenance burden** → Each new tool needs an adapter. Mitigated by simple interface - most adapters are ~20 lines.
**[Risk] Frontmatter format drift** → Tools may change their formats. Mitigated by encapsulating format in adapter - single place to update.
**[Trade-off] Two command systems** → Old SlashCommandConfigurator and new CommandAdapterRegistry coexist. Acceptable for now - can unify later if needed.
**[Trade-off] skillsDir optional** → Tools without skillsDir configured will error. Acceptable - we add paths as tools are tested.
## Implementation Approach
1. Add `skillsDir` to `AIToolOption` and populate for known tools
2. Create `CommandContent` and `ToolCommandAdapter` interfaces
3. Implement adapters for Claude, Cursor, Windsurf (start with 3)
4. Create `CommandAdapterRegistry`
5. Create `generateCommand()` function
6. Update `artifact-experimental-setup` to use new system
7. Add `--tool` flag with validation
@@ -1,36 +0,0 @@
## Why
The `artifact-experimental-setup` command currently hardcodes skill output paths to `.claude/skills` and `.claude/commands/opsx`. This prevents users of other AI coding tools (Cursor, Windsurf, Codex, etc.) from using OpenSpec's skill generation. We need to support the diverse ecosystem of AI coding assistants, each with their own conventions for skill/instruction file locations and command frontmatter formats.
## What Changes
- Add `skillsDir` path configuration to the existing `AIToolOption` interface in `config.ts`
- Add required `--tool <tool-id>` flag to the `artifact-experimental-setup` command
- Create a generic command generation system using Strategy/Adapter pattern:
- `CommandContent`: tool-agnostic command data (id, name, description, body)
- `ToolCommandAdapter`: per-tool formatting (file paths, frontmatter format)
- `CommandGenerator`: orchestrates generation using content + adapter
- Require explicit tool selection (no default) for clarity
## Capabilities
### New Capabilities
- `ai-tool-paths`: Configuration mapping AI tool IDs to their project-local skill directory paths
- `command-generation`: Generic command generation system with tool adapters for formatting differences
### Modified Capabilities
- `cli-artifact-workflow`: Adding `--tool` flag to setup command for provider selection
## Impact
- **Files Modified**:
- `src/core/config.ts` - Extend `AIToolOption` interface with `skillsDir` field
- `src/commands/artifact-workflow.ts` - Add `--tool` flag, use provider paths and adapters
- **New Files**:
- `src/core/command-generation/types.ts` - CommandContent, ToolCommandAdapter interfaces
- `src/core/command-generation/generator.ts` - Generic command generator
- `src/core/command-generation/adapters/*.ts` - Per-tool adapters
- **Backward Compatibility**: Existing workflows unaffected - this is a new command setup feature
- **User-Facing**: Required `--tool` flag on `artifact-experimental-setup` command for explicit tool selection
@@ -1,63 +0,0 @@
# ai-tool-paths Specification
## Purpose
Define the path configuration for AI coding tool skill directories, enabling skill generation to target different tools following the Agent Skills spec.
## Requirements
## ADDED Requirements
### Requirement: AIToolOption skillsDir field
The `AIToolOption` interface SHALL include an optional `skillsDir` field for skill generation path configuration.
#### Scenario: Interface includes skillsDir field
- **WHEN** a tool entry is defined in `AI_TOOLS` that supports skill generation
- **THEN** it SHALL include a `skillsDir` field specifying the project-local base directory (e.g., `.claude`)
#### Scenario: Skills path follows Agent Skills spec
- **WHEN** generating skills for a tool with `skillsDir: '.claude'`
- **THEN** skills SHALL be written to `<projectRoot>/<skillsDir>/skills/`
- **AND** the `/skills` suffix is appended per Agent Skills specification
### Requirement: Path configuration for supported tools
The `AI_TOOLS` array SHALL include `skillsDir` for tools that support the Agent Skills specification.
#### Scenario: Claude Code paths defined
- **WHEN** looking up the `claude` tool
- **THEN** `skillsDir` SHALL be `.claude`
#### Scenario: Cursor paths defined
- **WHEN** looking up the `cursor` tool
- **THEN** `skillsDir` SHALL be `.cursor`
#### Scenario: Windsurf paths defined
- **WHEN** looking up the `windsurf` tool
- **THEN** `skillsDir` SHALL be `.windsurf`
#### Scenario: Tools without skillsDir
- **WHEN** a tool has no `skillsDir` defined
- **THEN** skill generation SHALL error with message indicating the tool is not supported
### Requirement: Cross-platform path handling
The system SHALL handle paths correctly across operating systems.
#### Scenario: Path construction on Windows
- **WHEN** constructing skill paths on Windows
- **THEN** the system SHALL use `path.join()` for all path construction
- **AND** SHALL NOT hardcode forward slashes
#### Scenario: Path construction on Unix
- **WHEN** constructing skill paths on macOS or Linux
- **THEN** the system SHALL use `path.join()` for consistency
@@ -1,60 +0,0 @@
# cli-artifact-workflow Delta Specification
## Purpose
Add `--tool` flag to the `artifact-experimental-setup` command for multi-provider support.
## ADDED Requirements
### Requirement: Tool selection flag
The `artifact-experimental-setup` command SHALL accept a `--tool <tool-id>` flag to specify the target AI tool.
#### Scenario: Specify tool via flag
- **WHEN** user runs `openspec artifact-experimental-setup --tool cursor`
- **THEN** skill files are generated in `.cursor/skills/`
- **AND** command files are generated using Cursor's frontmatter format
#### Scenario: Missing tool flag
- **WHEN** user runs `openspec artifact-experimental-setup` without `--tool`
- **THEN** the system displays an error requiring the `--tool` flag
- **AND** lists valid tool IDs in the error message
#### Scenario: Unknown tool ID
- **WHEN** user runs `openspec artifact-experimental-setup --tool unknown-tool`
- **AND** the tool ID is not in `AI_TOOLS`
- **THEN** the system displays an error listing valid tool IDs
#### Scenario: Tool without skillsDir
- **WHEN** user specifies a tool that has no `skillsDir` configured
- **THEN** the system displays an error indicating skill generation is not supported for that tool
#### Scenario: Tool without command adapter
- **WHEN** user specifies a tool that has `skillsDir` but no command adapter registered
- **THEN** skill files are generated successfully
- **AND** command generation is skipped with informational message
### Requirement: Output messaging
The setup command SHALL display clear output about what was generated.
#### Scenario: Show target tool in output
- **WHEN** setup command runs successfully
- **THEN** output includes the target tool name (e.g., "Setting up for Cursor...")
#### Scenario: Show generated paths
- **WHEN** setup command completes
- **THEN** output lists all generated skill file paths
- **AND** lists all generated command file paths (if applicable)
#### Scenario: Show skipped commands message
- **WHEN** command generation is skipped due to missing adapter
- **THEN** output includes message: "Command generation skipped - no adapter for <tool>"
@@ -1,98 +0,0 @@
# command-generation Specification
## Purpose
Define a generic command generation system that supports multiple AI tools through a Strategy/Adapter pattern, separating command content from tool-specific formatting.
## ADDED Requirements
### Requirement: CommandContent interface
The system SHALL define a tool-agnostic `CommandContent` interface for command data.
#### Scenario: CommandContent structure
- **WHEN** defining a command to generate
- **THEN** `CommandContent` SHALL include:
- `id`: string identifier (e.g., 'explore', 'apply')
- `name`: human-readable name (e.g., 'OpenSpec Explore')
- `description`: brief description of command purpose
- `category`: grouping category (e.g., 'OpenSpec')
- `tags`: array of tag strings
- `body`: the command instruction content
### Requirement: ToolCommandAdapter interface
The system SHALL define a `ToolCommandAdapter` interface for per-tool formatting.
#### Scenario: Adapter interface structure
- **WHEN** implementing a tool adapter
- **THEN** `ToolCommandAdapter` SHALL require:
- `toolId`: string identifier matching `AIToolOption.value`
- `getFilePath(commandId: string)`: returns relative file path for command
- `formatFile(content: CommandContent)`: returns complete file content with frontmatter
#### Scenario: Claude adapter formatting
- **WHEN** formatting a command for Claude Code
- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields
- **AND** file path SHALL follow pattern `.claude/commands/opsx/<id>.md`
#### Scenario: Cursor adapter formatting
- **WHEN** formatting a command for Cursor
- **THEN** the adapter SHALL output YAML frontmatter with `name` as `/opsx-<id>`, `id`, `category`, `description` fields
- **AND** file path SHALL follow pattern `.cursor/commands/opsx-<id>.md`
#### Scenario: Windsurf adapter formatting
- **WHEN** formatting a command for Windsurf
- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields
- **AND** file path SHALL follow pattern `.windsurf/commands/opsx/<id>.md`
### Requirement: Command generator function
The system SHALL provide a `generateCommand` function that combines content with adapter.
#### Scenario: Generate command file
- **WHEN** calling `generateCommand(content, adapter)`
- **THEN** it SHALL return an object with:
- `path`: the file path from `adapter.getFilePath(content.id)`
- `fileContent`: the formatted content from `adapter.formatFile(content)`
#### Scenario: Generate multiple commands
- **WHEN** generating all opsx commands for a tool
- **THEN** the system SHALL iterate over command contents and generate each using the tool's adapter
### Requirement: CommandAdapterRegistry
The system SHALL provide a registry for looking up tool adapters.
#### Scenario: Get adapter by tool ID
- **WHEN** calling `CommandAdapterRegistry.get('cursor')`
- **THEN** it SHALL return the Cursor adapter or undefined if not registered
#### Scenario: Get all adapters
- **WHEN** calling `CommandAdapterRegistry.getAll()`
- **THEN** it SHALL return array of all registered adapters
#### Scenario: Adapter not found
- **WHEN** looking up an adapter for unregistered tool
- **THEN** `CommandAdapterRegistry.get()` SHALL return undefined
- **AND** caller SHALL handle missing adapter appropriately
### Requirement: Shared command body content
The body content of commands SHALL be shared across all tools.
#### Scenario: Same instructions across tools
- **WHEN** generating the 'explore' command for Claude and Cursor
- **THEN** both SHALL use the same `body` content
- **AND** only the frontmatter and file path SHALL differ
@@ -1,55 +0,0 @@
## 1. Extend AIToolOption Interface
- [x] 1.1 Add `skillsDir?: string` field to `AIToolOption` interface in `src/core/config.ts`
## 2. Add skillsDir to AI_TOOLS
- [x] 2.1 Add `skillsDir: '.claude'` to Claude Code tool entry
- [x] 2.2 Add `skillsDir: '.cursor'` to Cursor tool entry
- [x] 2.3 Add `skillsDir: '.windsurf'` to Windsurf tool entry
- [x] 2.4 Add skillsDir for other tools with known Agent Skills spec support (codex, opencode, roocode, kilocode, gemini, factory, github-copilot)
## 3. Create Command Generation Types
- [x] 3.1 Create `src/core/command-generation/types.ts` with `CommandContent` interface
- [x] 3.2 Add `ToolCommandAdapter` interface to types.ts
- [x] 3.3 Export types from module index
## 4. Implement Tool Command Adapters
- [x] 4.1 Create `src/core/command-generation/adapters/claude.ts` with Claude frontmatter format
- [x] 4.2 Create `src/core/command-generation/adapters/cursor.ts` with Cursor frontmatter format
- [x] 4.3 Create `src/core/command-generation/adapters/windsurf.ts` with Windsurf frontmatter format
- [x] 4.4 Create base adapter or utility for shared YAML formatting logic (if applicable)
## 5. Create Command Adapter Registry
- [x] 5.1 Create `src/core/command-generation/registry.ts` with `CommandAdapterRegistry` class
- [x] 5.2 Register Claude, Cursor, Windsurf adapters in static initializer
- [x] 5.3 Add `get(toolId)` and `getAll()` methods
## 6. Create Command Generator
- [x] 6.1 Create `src/core/command-generation/generator.ts` with `generateCommand()` function
- [x] 6.2 Add `generateCommands()` function for batch generation
- [x] 6.3 Create module index `src/core/command-generation/index.ts` exporting public API
## 7. Update artifact-experimental-setup Command
- [x] 7.1 Add `--tool <tool-id>` option (required) to command in `src/commands/artifact-workflow.ts`
- [x] 7.2 Add validation: `--tool` flag is required (error if missing with list of valid tools)
- [x] 7.3 Add validation: tool exists in AI_TOOLS
- [x] 7.4 Add validation: tool has skillsDir configured
- [x] 7.5 Replace hardcoded `.claude` skill paths with `tool.skillsDir`
- [x] 7.6 Replace hardcoded command generation with `CommandAdapterRegistry.get()` + `generateCommands()`
- [x] 7.7 Handle missing adapter gracefully (skip commands with message)
- [x] 7.8 Update output messages to show target tool name and paths
## 8. Testing
- [x] 8.1 Add unit tests for `CommandContent` and `ToolCommandAdapter` contracts
- [x] 8.2 Add unit tests for Claude adapter (path + frontmatter format)
- [x] 8.3 Add unit tests for Cursor adapter (path + frontmatter format)
- [x] 8.4 Add unit tests for `CommandAdapterRegistry.get()` and missing adapter case
- [x] 8.5 Add integration test for `--tool` flag validation
- [x] 8.6 Verify cross-platform path handling uses `path.join()` throughout
@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-01-20
@@ -1,28 +0,0 @@
## Why
We want to rename `spec-driven` to `openspec-default` to better reflect that it's the standard/default workflow. However, renaming directly would break existing projects that have `schema: spec-driven` in their `openspec/config.yaml`. Adding alias support allows both names to work interchangeably, enabling a smooth transition with no breaking changes.
## What Changes
- Add schema alias resolution in the schema resolver
- `openspec-default` and `spec-driven` will both resolve to the same schema
- The physical directory remains `schemas/spec-driven/` (or could be renamed to `schemas/openspec-default/` with `spec-driven` as the alias)
- All CLI commands and config files accept either name
- No changes required to existing user configs
## Capabilities
### New Capabilities
- `schema-aliases`: Support for schema name aliases so multiple names can resolve to the same schema directory
### Modified Capabilities
<!-- No existing spec-level behavior is changing - this is purely additive -->
## Impact
- `src/core/artifact-graph/resolver.ts` - Add alias resolution logic
- `schemas/` directory - Potentially rename `spec-driven` to `openspec-default`
- Documentation - Update to prefer `openspec-default` while noting `spec-driven` still works
- Default schema constants - Update `DEFAULT_SCHEMA` to `openspec-default`
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@fission-ai/openspec",
"version": "0.23.0",
"version": "0.22.0",
"description": "AI-native system for spec-driven development",
"keywords": [
"openspec",
+14 -14
View File
@@ -463,8 +463,8 @@ packages:
'@types/node':
optional: true
'@inquirer/type@3.0.10':
resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==}
'@inquirer/type@3.0.8':
resolution: {integrity: sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
@@ -1946,7 +1946,7 @@ snapshots:
dependencies:
'@inquirer/core': 10.2.2(@types/node@24.2.0)
'@inquirer/figures': 1.0.13
'@inquirer/type': 3.0.10(@types/node@24.2.0)
'@inquirer/type': 3.0.8(@types/node@24.2.0)
ansi-escapes: 4.3.2
yoctocolors-cjs: 2.1.2
optionalDependencies:
@@ -1955,7 +1955,7 @@ snapshots:
'@inquirer/confirm@5.1.14(@types/node@24.2.0)':
dependencies:
'@inquirer/core': 10.2.2(@types/node@24.2.0)
'@inquirer/type': 3.0.10(@types/node@24.2.0)
'@inquirer/type': 3.0.8(@types/node@24.2.0)
optionalDependencies:
'@types/node': 24.2.0
@@ -1963,7 +1963,7 @@ snapshots:
dependencies:
'@inquirer/ansi': 1.0.0
'@inquirer/figures': 1.0.13
'@inquirer/type': 3.0.10(@types/node@24.2.0)
'@inquirer/type': 3.0.8(@types/node@24.2.0)
cli-width: 4.1.0
mute-stream: 2.0.0
signal-exit: 4.1.0
@@ -1975,7 +1975,7 @@ snapshots:
'@inquirer/editor@4.2.15(@types/node@24.2.0)':
dependencies:
'@inquirer/core': 10.2.2(@types/node@24.2.0)
'@inquirer/type': 3.0.10(@types/node@24.2.0)
'@inquirer/type': 3.0.8(@types/node@24.2.0)
external-editor: 3.1.0
optionalDependencies:
'@types/node': 24.2.0
@@ -1983,7 +1983,7 @@ snapshots:
'@inquirer/expand@4.0.17(@types/node@24.2.0)':
dependencies:
'@inquirer/core': 10.2.2(@types/node@24.2.0)
'@inquirer/type': 3.0.10(@types/node@24.2.0)
'@inquirer/type': 3.0.8(@types/node@24.2.0)
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 24.2.0
@@ -2000,21 +2000,21 @@ snapshots:
'@inquirer/input@4.2.1(@types/node@24.2.0)':
dependencies:
'@inquirer/core': 10.2.2(@types/node@24.2.0)
'@inquirer/type': 3.0.10(@types/node@24.2.0)
'@inquirer/type': 3.0.8(@types/node@24.2.0)
optionalDependencies:
'@types/node': 24.2.0
'@inquirer/number@3.0.17(@types/node@24.2.0)':
dependencies:
'@inquirer/core': 10.2.2(@types/node@24.2.0)
'@inquirer/type': 3.0.10(@types/node@24.2.0)
'@inquirer/type': 3.0.8(@types/node@24.2.0)
optionalDependencies:
'@types/node': 24.2.0
'@inquirer/password@4.0.17(@types/node@24.2.0)':
dependencies:
'@inquirer/core': 10.2.2(@types/node@24.2.0)
'@inquirer/type': 3.0.10(@types/node@24.2.0)
'@inquirer/type': 3.0.8(@types/node@24.2.0)
ansi-escapes: 4.3.2
optionalDependencies:
'@types/node': 24.2.0
@@ -2037,7 +2037,7 @@ snapshots:
'@inquirer/rawlist@4.1.5(@types/node@24.2.0)':
dependencies:
'@inquirer/core': 10.2.2(@types/node@24.2.0)
'@inquirer/type': 3.0.10(@types/node@24.2.0)
'@inquirer/type': 3.0.8(@types/node@24.2.0)
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 24.2.0
@@ -2046,7 +2046,7 @@ snapshots:
dependencies:
'@inquirer/core': 10.2.2(@types/node@24.2.0)
'@inquirer/figures': 1.0.13
'@inquirer/type': 3.0.10(@types/node@24.2.0)
'@inquirer/type': 3.0.8(@types/node@24.2.0)
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 24.2.0
@@ -2055,13 +2055,13 @@ snapshots:
dependencies:
'@inquirer/core': 10.2.2(@types/node@24.2.0)
'@inquirer/figures': 1.0.13
'@inquirer/type': 3.0.10(@types/node@24.2.0)
'@inquirer/type': 3.0.8(@types/node@24.2.0)
ansi-escapes: 4.3.2
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 24.2.0
'@inquirer/type@3.0.10(@types/node@24.2.0)':
'@inquirer/type@3.0.8(@types/node@24.2.0)':
optionalDependencies:
'@types/node': 24.2.0
+2 -2
View File
@@ -41,8 +41,8 @@ sed "${SED_INPLACE[@]}" "s|hash = \"sha256-[^\"]*\"|hash = \"$PLACEHOLDER\"|" "$
echo " Building to get correct hash (this will fail)..."
BUILD_OUTPUT=$(nix build 2>&1 || true)
# Extract the correct hash from error output (portable - works on macOS and Linux)
CORRECT_HASH=$(echo "$BUILD_OUTPUT" | grep -o 'got:[[:space:]]*sha256-[A-Za-z0-9+/=]*' | head -1 | sed 's/got:[[:space:]]*//')
# Extract the correct hash from error output
CORRECT_HASH=$(echo "$BUILD_OUTPUT" | grep -oP 'got:\s+\Ksha256-[A-Za-z0-9+/=]+' | head -1)
if [ -z "$CORRECT_HASH" ]; then
echo "❌ Error: Could not extract hash from build output"
+9 -136
View File
@@ -15,21 +15,8 @@ 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/artifact-workflow.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();
@@ -87,19 +74,18 @@ program.hook('postAction', async () => {
await shutdown();
});
const availableToolIds = AI_TOOLS.filter((tool) => tool.skillsDir).map((tool) => tool.value);
const availableToolIds = AI_TOOLS.filter((tool) => tool.available).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)
.option('--force', 'Auto-cleanup legacy files without prompting')
.action(async (targetPath = '.', options?: { tools?: string; force?: boolean }) => {
.action(async (targetPath = '.', options?: { tools?: string }) => {
try {
// Validate that the path is a valid directory
const resolvedPath = path.resolve(targetPath);
try {
const stats = await fs.stat(resolvedPath);
if (!stats.isDirectory()) {
@@ -115,11 +101,10 @@ 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) {
@@ -129,36 +114,13 @@ 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')
.option('--force', 'Force update even when tools are up to date')
.action(async (targetPath = '.', options?: { force?: boolean }) => {
.action(async (targetPath = '.') => {
try {
const resolvedPath = path.resolve(targetPath);
const updateCommand = new UpdateCommand({ force: options?.force });
const updateCommand = new UpdateCommand();
await updateCommand.execute(resolvedPath);
} catch (error) {
console.log(); // Empty line for spacing
@@ -413,96 +375,7 @@ 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);
}
});
// Register artifact workflow commands (experimental)
registerArtifactWorkflowCommands(program);
program.parse();
File diff suppressed because it is too large Load Diff
-22
View File
@@ -1,22 +0,0 @@
/**
* 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';
-481
View File
@@ -1,481 +0,0 @@
/**
* Instructions Command
*
* Generates enriched instructions for creating artifacts or applying tasks.
* Includes both artifact instructions and apply instructions.
*/
import ora from 'ora';
import path from 'path';
import * as fs from 'fs';
import {
loadChangeContext,
generateInstructions,
resolveSchema,
type ArtifactInstructions,
} from '../../core/artifact-graph/index.js';
import {
validateChangeExists,
validateSchemaExists,
type TaskItem,
type ApplyInstructions,
} from './shared.js';
// -----------------------------------------------------------------------------
// Types
// -----------------------------------------------------------------------------
export interface InstructionsOptions {
change?: string;
schema?: string;
json?: boolean;
}
export interface ApplyInstructionsOptions {
change?: string;
schema?: string;
json?: boolean;
}
// -----------------------------------------------------------------------------
// Artifact Instructions Command
// -----------------------------------------------------------------------------
export async function instructionsCommand(
artifactId: string | undefined,
options: InstructionsOptions
): Promise<void> {
const spinner = ora('Generating instructions...').start();
try {
const projectRoot = process.cwd();
const changeName = await validateChangeExists(options.change, projectRoot);
// Validate schema if explicitly provided
if (options.schema) {
validateSchemaExists(options.schema, projectRoot);
}
// loadChangeContext will auto-detect schema from metadata if not provided
const context = loadChangeContext(projectRoot, changeName, options.schema);
if (!artifactId) {
spinner.stop();
const validIds = context.graph.getAllArtifacts().map((a) => a.id);
throw new Error(
`Missing required argument <artifact>. Valid artifacts:\n ${validIds.join('\n ')}`
);
}
const artifact = context.graph.getArtifact(artifactId);
if (!artifact) {
spinner.stop();
const validIds = context.graph.getAllArtifacts().map((a) => a.id);
throw new Error(
`Artifact '${artifactId}' not found in schema '${context.schemaName}'. Valid artifacts:\n ${validIds.join('\n ')}`
);
}
const instructions = generateInstructions(context, artifactId, projectRoot);
const isBlocked = instructions.dependencies.some((d) => !d.done);
spinner.stop();
if (options.json) {
console.log(JSON.stringify(instructions, null, 2));
return;
}
printInstructionsText(instructions, isBlocked);
} catch (error) {
spinner.stop();
throw error;
}
}
export function printInstructionsText(instructions: ArtifactInstructions, isBlocked: boolean): void {
const {
artifactId,
changeName,
schemaName,
changeDir,
outputPath,
description,
instruction,
context,
rules,
template,
dependencies,
unlocks,
} = instructions;
// Opening tag
console.log(`<artifact id="${artifactId}" change="${changeName}" schema="${schemaName}">`);
console.log();
// Warning for blocked artifacts
if (isBlocked) {
const missing = dependencies.filter((d) => !d.done).map((d) => d.id);
console.log('<warning>');
console.log('This artifact has unmet dependencies. Complete them first or proceed with caution.');
console.log(`Missing: ${missing.join(', ')}`);
console.log('</warning>');
console.log();
}
// Task directive
console.log('<task>');
console.log(`Create the ${artifactId} artifact for change "${changeName}".`);
console.log(description);
console.log('</task>');
console.log();
// Project context (AI constraint - do not include in output)
if (context) {
console.log('<project_context>');
console.log('<!-- This is background information for you. Do NOT include this in your output. -->');
console.log(context);
console.log('</project_context>');
console.log();
}
// Rules (AI constraint - do not include in output)
if (rules && rules.length > 0) {
console.log('<rules>');
console.log('<!-- These are constraints for you to follow. Do NOT include this in your output. -->');
for (const rule of rules) {
console.log(`- ${rule}`);
}
console.log('</rules>');
console.log();
}
// Dependencies (files to read for context)
if (dependencies.length > 0) {
console.log('<dependencies>');
console.log('Read these files for context before creating this artifact:');
console.log();
for (const dep of dependencies) {
const status = dep.done ? 'done' : 'missing';
const fullPath = path.join(changeDir, dep.path);
console.log(`<dependency id="${dep.id}" status="${status}">`);
console.log(` <path>${fullPath}</path>`);
console.log(` <description>${dep.description}</description>`);
console.log('</dependency>');
}
console.log('</dependencies>');
console.log();
}
// Output location
console.log('<output>');
console.log(`Write to: ${path.join(changeDir, outputPath)}`);
console.log('</output>');
console.log();
// Instruction (guidance)
if (instruction) {
console.log('<instruction>');
console.log(instruction.trim());
console.log('</instruction>');
console.log();
}
// Template
console.log('<template>');
console.log('<!-- Use this as the structure for your output file. Fill in the sections. -->');
console.log(template.trim());
console.log('</template>');
console.log();
// Success criteria placeholder
console.log('<success_criteria>');
console.log('<!-- To be defined in schema validation rules -->');
console.log('</success_criteria>');
console.log();
// Unlocks
if (unlocks.length > 0) {
console.log('<unlocks>');
console.log(`Completing this artifact enables: ${unlocks.join(', ')}`);
console.log('</unlocks>');
console.log();
}
// Closing tag
console.log('</artifact>');
}
// -----------------------------------------------------------------------------
// Apply Instructions Command
// -----------------------------------------------------------------------------
/**
* Parses tasks.md content and extracts task items with their completion status.
*/
function parseTasksFile(content: string): TaskItem[] {
const tasks: TaskItem[] = [];
const lines = content.split('\n');
let taskIndex = 0;
for (const line of lines) {
// Match checkbox patterns: - [ ] or - [x] or - [X]
const checkboxMatch = line.match(/^[-*]\s*\[([ xX])\]\s*(.+)$/);
if (checkboxMatch) {
taskIndex++;
const done = checkboxMatch[1].toLowerCase() === 'x';
const description = checkboxMatch[2].trim();
tasks.push({
id: `${taskIndex}`,
description,
done,
});
}
}
return tasks;
}
/**
* Checks if an artifact output exists in the change directory.
* Supports glob patterns (e.g., "specs/*.md") by verifying at least one matching file exists.
*/
function artifactOutputExists(changeDir: string, generates: string): boolean {
// Normalize the generates path to use platform-specific separators
const normalizedGenerates = generates.split('/').join(path.sep);
const fullPath = path.join(changeDir, normalizedGenerates);
// If it's a glob pattern (contains ** or *), check for matching files
if (generates.includes('*')) {
// Extract the directory part before the glob pattern
const parts = normalizedGenerates.split(path.sep);
const dirParts: string[] = [];
let patternPart = '';
for (const part of parts) {
if (part.includes('*')) {
patternPart = part;
break;
}
dirParts.push(part);
}
const dirPath = path.join(changeDir, ...dirParts);
// Check if directory exists
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
return false;
}
// Extract expected extension from pattern (e.g., "*.md" -> ".md")
const extMatch = patternPart.match(/\*(\.[a-zA-Z0-9]+)$/);
const expectedExt = extMatch ? extMatch[1] : null;
// Recursively check for matching files
const hasMatchingFiles = (dir: string): boolean => {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
// For ** patterns, recurse into subdirectories
if (generates.includes('**') && hasMatchingFiles(path.join(dir, entry.name))) {
return true;
}
} else if (entry.isFile()) {
// Check if file matches expected extension (or any file if no extension specified)
if (!expectedExt || entry.name.endsWith(expectedExt)) {
return true;
}
}
}
} catch {
return false;
}
return false;
};
return hasMatchingFiles(dirPath);
}
return fs.existsSync(fullPath);
}
/**
* Generates apply instructions for implementing tasks from a change.
* Schema-aware: reads apply phase configuration from schema to determine
* required artifacts, tracking file, and instruction.
*/
export async function generateApplyInstructions(
projectRoot: string,
changeName: string,
schemaName?: string
): Promise<ApplyInstructions> {
// loadChangeContext will auto-detect schema from metadata if not provided
const context = loadChangeContext(projectRoot, changeName, schemaName);
const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);
// Get the full schema to access the apply phase configuration
const schema = resolveSchema(context.schemaName);
const applyConfig = schema.apply;
// Determine required artifacts and tracking file from schema
// Fallback: if no apply block, require all artifacts
const requiredArtifactIds = applyConfig?.requires ?? schema.artifacts.map((a) => a.id);
const tracksFile = applyConfig?.tracks ?? null;
const schemaInstruction = applyConfig?.instruction ?? null;
// Check which required artifacts are missing
const missingArtifacts: string[] = [];
for (const artifactId of requiredArtifactIds) {
const artifact = schema.artifacts.find((a) => a.id === artifactId);
if (artifact && !artifactOutputExists(changeDir, artifact.generates)) {
missingArtifacts.push(artifactId);
}
}
// Build context files from all existing artifacts in schema
const contextFiles: Record<string, string> = {};
for (const artifact of schema.artifacts) {
if (artifactOutputExists(changeDir, artifact.generates)) {
contextFiles[artifact.id] = path.join(changeDir, artifact.generates);
}
}
// Parse tasks if tracking file exists
let tasks: TaskItem[] = [];
let tracksFileExists = false;
if (tracksFile) {
const tracksPath = path.join(changeDir, tracksFile);
tracksFileExists = fs.existsSync(tracksPath);
if (tracksFileExists) {
const tasksContent = await fs.promises.readFile(tracksPath, 'utf-8');
tasks = parseTasksFile(tasksContent);
}
}
// Calculate progress
const total = tasks.length;
const complete = tasks.filter((t) => t.done).length;
const remaining = total - complete;
// Determine state and instruction
let state: ApplyInstructions['state'];
let instruction: string;
if (missingArtifacts.length > 0) {
state = 'blocked';
instruction = `Cannot apply this change yet. Missing artifacts: ${missingArtifacts.join(', ')}.\nUse the openspec-continue-change skill to create the missing artifacts first.`;
} else if (tracksFile && !tracksFileExists) {
// Tracking file configured but doesn't exist yet
const tracksFilename = path.basename(tracksFile);
state = 'blocked';
instruction = `The ${tracksFilename} file is missing and must be created.\nUse openspec-continue-change to generate the tracking file.`;
} else if (tracksFile && tracksFileExists && total === 0) {
// Tracking file exists but contains no tasks
const tracksFilename = path.basename(tracksFile);
state = 'blocked';
instruction = `The ${tracksFilename} file exists but contains no tasks.\nAdd tasks to ${tracksFilename} or regenerate it with openspec-continue-change.`;
} else if (tracksFile && remaining === 0 && total > 0) {
state = 'all_done';
instruction = 'All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving.';
} else if (!tracksFile) {
// No tracking file (e.g., TDD schema) - ready to apply
state = 'ready';
instruction = schemaInstruction?.trim() ?? 'All required artifacts complete. Proceed with implementation.';
} else {
state = 'ready';
instruction = schemaInstruction?.trim() ?? 'Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification.';
}
return {
changeName,
changeDir,
schemaName: context.schemaName,
contextFiles,
progress: { total, complete, remaining },
tasks,
state,
missingArtifacts: missingArtifacts.length > 0 ? missingArtifacts : undefined,
instruction,
};
}
export async function applyInstructionsCommand(options: ApplyInstructionsOptions): Promise<void> {
const spinner = ora('Generating apply instructions...').start();
try {
const projectRoot = process.cwd();
const changeName = await validateChangeExists(options.change, projectRoot);
// Validate schema if explicitly provided
if (options.schema) {
validateSchemaExists(options.schema, projectRoot);
}
// generateApplyInstructions uses loadChangeContext which auto-detects schema
const instructions = await generateApplyInstructions(projectRoot, changeName, options.schema);
spinner.stop();
if (options.json) {
console.log(JSON.stringify(instructions, null, 2));
return;
}
printApplyInstructionsText(instructions);
} catch (error) {
spinner.stop();
throw error;
}
}
export function printApplyInstructionsText(instructions: ApplyInstructions): void {
const { changeName, schemaName, contextFiles, progress, tasks, state, missingArtifacts, instruction } = instructions;
console.log(`## Apply: ${changeName}`);
console.log(`Schema: ${schemaName}`);
console.log();
// Warning for blocked state
if (state === 'blocked' && missingArtifacts) {
console.log('### ⚠️ Blocked');
console.log();
console.log(`Missing artifacts: ${missingArtifacts.join(', ')}`);
console.log('Use the openspec-continue-change skill to create these first.');
console.log();
}
// Context files (dynamically from schema)
const contextFileEntries = Object.entries(contextFiles);
if (contextFileEntries.length > 0) {
console.log('### Context Files');
for (const [artifactId, filePath] of contextFileEntries) {
console.log(`- ${artifactId}: ${filePath}`);
}
console.log();
}
// Progress (only show if we have tracking)
if (progress.total > 0 || tasks.length > 0) {
console.log('### Progress');
if (state === 'all_done') {
console.log(`${progress.complete}/${progress.total} complete ✓`);
} else {
console.log(`${progress.complete}/${progress.total} complete`);
}
console.log();
}
// Tasks
if (tasks.length > 0) {
console.log('### Tasks');
for (const task of tasks) {
const checkbox = task.done ? '[x]' : '[ ]';
console.log(`- ${checkbox} ${task.description}`);
}
console.log();
}
// Instruction
console.log('### Instruction');
console.log(instruction);
}
-61
View File
@@ -1,61 +0,0 @@
/**
* New Change Command
*
* Creates a new change directory with optional description and schema.
*/
import ora from 'ora';
import path from 'path';
import { createChange, validateChangeName } from '../../utils/change-utils.js';
import { validateSchemaExists } from './shared.js';
// -----------------------------------------------------------------------------
// Types
// -----------------------------------------------------------------------------
export interface NewChangeOptions {
description?: string;
schema?: string;
}
// -----------------------------------------------------------------------------
// Command Implementation
// -----------------------------------------------------------------------------
export async function newChangeCommand(name: string | undefined, options: NewChangeOptions): Promise<void> {
if (!name) {
throw new Error('Missing required argument <name>');
}
const validation = validateChangeName(name);
if (!validation.valid) {
throw new Error(validation.error);
}
const projectRoot = process.cwd();
// Validate schema if provided
if (options.schema) {
validateSchemaExists(options.schema, projectRoot);
}
const schemaDisplay = options.schema ? ` with schema '${options.schema}'` : '';
const spinner = ora(`Creating change '${name}'${schemaDisplay}...`).start();
try {
const result = await createChange(projectRoot, name, { schema: options.schema });
// If description provided, create README.md with description
if (options.description) {
const { promises: fs } = await import('fs');
const changeDir = path.join(projectRoot, 'openspec', 'changes', name);
const readmePath = path.join(changeDir, 'README.md');
await fs.writeFile(readmePath, `# ${name}\n\n${options.description}\n`, 'utf-8');
}
spinner.succeed(`Created change '${name}' at openspec/changes/${name}/ (schema: ${result.schema})`);
} catch (error) {
spinner.fail(`Failed to create change '${name}'`);
throw error;
}
}
-46
View File
@@ -1,46 +0,0 @@
/**
* Schemas Command
*
* Lists available workflow schemas with descriptions.
*/
import chalk from 'chalk';
import { listSchemasWithInfo } from '../../core/artifact-graph/index.js';
// -----------------------------------------------------------------------------
// Types
// -----------------------------------------------------------------------------
export interface SchemasOptions {
json?: boolean;
}
// -----------------------------------------------------------------------------
// Command Implementation
// -----------------------------------------------------------------------------
export async function schemasCommand(options: SchemasOptions): Promise<void> {
const projectRoot = process.cwd();
const schemas = listSchemasWithInfo(projectRoot);
if (options.json) {
console.log(JSON.stringify(schemas, null, 2));
return;
}
console.log('Available schemas:');
console.log();
for (const schema of schemas) {
let sourceLabel = '';
if (schema.source === 'project') {
sourceLabel = chalk.cyan(' (project)');
} else if (schema.source === 'user') {
sourceLabel = chalk.dim(' (user override)');
}
console.log(` ${chalk.bold(schema.name)}${sourceLabel}`);
console.log(` ${schema.description}`);
console.log(` Artifacts: ${schema.artifacts.join(' → ')}`);
console.log();
}
}
-161
View File
@@ -1,161 +0,0 @@
/**
* Shared Types and Utilities for Artifact Workflow Commands
*
* This module contains types, constants, and validation helpers used across
* multiple artifact workflow commands.
*/
import chalk from 'chalk';
import path from 'path';
import * as fs from 'fs';
import { getSchemaDir, listSchemas } from '../../core/artifact-graph/index.js';
import { validateChangeName } from '../../utils/change-utils.js';
// -----------------------------------------------------------------------------
// Types
// -----------------------------------------------------------------------------
export interface TaskItem {
id: string;
description: string;
done: boolean;
}
export interface ApplyInstructions {
changeName: string;
changeDir: string;
schemaName: string;
contextFiles: Record<string, string>;
progress: {
total: number;
complete: number;
remaining: number;
};
tasks: TaskItem[];
state: 'blocked' | 'all_done' | 'ready';
missingArtifacts?: string[];
instruction: string;
}
// -----------------------------------------------------------------------------
// Constants
// -----------------------------------------------------------------------------
export const DEFAULT_SCHEMA = 'spec-driven';
// -----------------------------------------------------------------------------
// Utility Functions
// -----------------------------------------------------------------------------
/**
* Checks if color output is disabled via NO_COLOR env or --no-color flag.
*/
export function isColorDisabled(): boolean {
return process.env.NO_COLOR === '1' || process.env.NO_COLOR === 'true';
}
/**
* Gets the color function based on status.
*/
export function getStatusColor(status: 'done' | 'ready' | 'blocked'): (text: string) => string {
if (isColorDisabled()) {
return (text: string) => text;
}
switch (status) {
case 'done':
return chalk.green;
case 'ready':
return chalk.yellow;
case 'blocked':
return chalk.red;
}
}
/**
* Gets the status indicator for an artifact.
*/
export function getStatusIndicator(status: 'done' | 'ready' | 'blocked'): string {
const color = getStatusColor(status);
switch (status) {
case 'done':
return color('[x]');
case 'ready':
return color('[ ]');
case 'blocked':
return color('[-]');
}
}
/**
* Validates that a change exists and returns available changes if not.
* Checks directory existence directly to support scaffolded changes (without proposal.md).
*/
export async function validateChangeExists(
changeName: string | undefined,
projectRoot: string
): Promise<string> {
const changesPath = path.join(projectRoot, 'openspec', 'changes');
// Get all change directories (not just those with proposal.md)
const getAvailableChanges = async (): Promise<string[]> => {
try {
const entries = await fs.promises.readdir(changesPath, { withFileTypes: true });
return entries
.filter((e) => e.isDirectory() && e.name !== 'archive' && !e.name.startsWith('.'))
.map((e) => e.name);
} catch {
return [];
}
};
if (!changeName) {
const available = await getAvailableChanges();
if (available.length === 0) {
throw new Error('No changes found. Create one with: openspec new change <name>');
}
throw new Error(
`Missing required option --change. Available changes:\n ${available.join('\n ')}`
);
}
// Validate change name format to prevent path traversal
const nameValidation = validateChangeName(changeName);
if (!nameValidation.valid) {
throw new Error(`Invalid change name '${changeName}': ${nameValidation.error}`);
}
// Check directory existence directly
const changePath = path.join(changesPath, changeName);
const exists = fs.existsSync(changePath) && fs.statSync(changePath).isDirectory();
if (!exists) {
const available = await getAvailableChanges();
if (available.length === 0) {
throw new Error(
`Change '${changeName}' not found. No changes exist. Create one with: openspec new change <name>`
);
}
throw new Error(
`Change '${changeName}' not found. Available changes:\n ${available.join('\n ')}`
);
}
return changeName;
}
/**
* Validates that a schema exists and returns available schemas if not.
*
* @param schemaName - The schema name to validate
* @param projectRoot - Optional project root for project-local schema resolution
*/
export function validateSchemaExists(schemaName: string, projectRoot?: string): string {
const schemaDir = getSchemaDir(schemaName, projectRoot);
if (!schemaDir) {
const availableSchemas = listSchemas(projectRoot);
throw new Error(
`Schema '${schemaName}' not found. Available schemas:\n ${availableSchemas.join('\n ')}`
);
}
return schemaName;
}
-90
View File
@@ -1,90 +0,0 @@
/**
* Status Command
*
* Displays artifact completion status for a change.
*/
import ora from 'ora';
import chalk from 'chalk';
import {
loadChangeContext,
formatChangeStatus,
type ChangeStatus,
} from '../../core/artifact-graph/index.js';
import {
validateChangeExists,
validateSchemaExists,
getStatusIndicator,
getStatusColor,
} from './shared.js';
// -----------------------------------------------------------------------------
// Types
// -----------------------------------------------------------------------------
export interface StatusOptions {
change?: string;
schema?: string;
json?: boolean;
}
// -----------------------------------------------------------------------------
// Command Implementation
// -----------------------------------------------------------------------------
export async function statusCommand(options: StatusOptions): Promise<void> {
const spinner = ora('Loading change status...').start();
try {
const projectRoot = process.cwd();
const changeName = await validateChangeExists(options.change, projectRoot);
// Validate schema if explicitly provided
if (options.schema) {
validateSchemaExists(options.schema, projectRoot);
}
// loadChangeContext will auto-detect schema from metadata if not provided
const context = loadChangeContext(projectRoot, changeName, options.schema);
const status = formatChangeStatus(context);
spinner.stop();
if (options.json) {
console.log(JSON.stringify(status, null, 2));
return;
}
printStatusText(status);
} catch (error) {
spinner.stop();
throw error;
}
}
export function printStatusText(status: ChangeStatus): void {
const doneCount = status.artifacts.filter((a) => a.status === 'done').length;
const total = status.artifacts.length;
console.log(`Change: ${status.changeName}`);
console.log(`Schema: ${status.schemaName}`);
console.log(`Progress: ${doneCount}/${total} artifacts complete`);
console.log();
for (const artifact of status.artifacts) {
const indicator = getStatusIndicator(artifact.status);
const color = getStatusColor(artifact.status);
let line = `${indicator} ${artifact.id}`;
if (artifact.status === 'blocked' && artifact.missingDeps && artifact.missingDeps.length > 0) {
line += color(` (blocked by: ${artifact.missingDeps.join(', ')})`);
}
console.log(line);
}
if (status.isComplete) {
console.log();
console.log(chalk.green('All artifacts complete!'));
}
}
-98
View File
@@ -1,98 +0,0 @@
/**
* Templates Command
*
* Shows resolved template paths for all artifacts in a schema.
*/
import ora from 'ora';
import path from 'path';
import {
resolveSchema,
getSchemaDir,
ArtifactGraph,
} from '../../core/artifact-graph/index.js';
import { validateSchemaExists, DEFAULT_SCHEMA } from './shared.js';
// -----------------------------------------------------------------------------
// Types
// -----------------------------------------------------------------------------
export interface TemplatesOptions {
schema?: string;
json?: boolean;
}
export interface TemplateInfo {
artifactId: string;
templatePath: string;
source: 'project' | 'user' | 'package';
}
// -----------------------------------------------------------------------------
// Command Implementation
// -----------------------------------------------------------------------------
export async function templatesCommand(options: TemplatesOptions): Promise<void> {
const spinner = ora('Loading templates...').start();
try {
const projectRoot = process.cwd();
const schemaName = validateSchemaExists(options.schema ?? DEFAULT_SCHEMA, projectRoot);
const schema = resolveSchema(schemaName, projectRoot);
const graph = ArtifactGraph.fromSchema(schema);
const schemaDir = getSchemaDir(schemaName, projectRoot)!;
// Determine the source (project, user, or package)
const {
getUserSchemasDir,
getProjectSchemasDir,
} = await import('../../core/artifact-graph/resolver.js');
const projectSchemasDir = getProjectSchemasDir(projectRoot);
const userSchemasDir = getUserSchemasDir();
// Determine source by checking if schemaDir is inside each base directory
// Using path.relative is more robust than startsWith for path comparisons
const isInsideDir = (child: string, parent: string): boolean => {
const relative = path.relative(parent, child);
return !relative.startsWith('..') && !path.isAbsolute(relative);
};
let source: 'project' | 'user' | 'package';
if (isInsideDir(schemaDir, projectSchemasDir)) {
source = 'project';
} else if (isInsideDir(schemaDir, userSchemasDir)) {
source = 'user';
} else {
source = 'package';
}
const templates: TemplateInfo[] = graph.getAllArtifacts().map((artifact) => ({
artifactId: artifact.id,
templatePath: path.join(schemaDir, 'templates', artifact.template),
source,
}));
spinner.stop();
if (options.json) {
const output: Record<string, { path: string; source: string }> = {};
for (const t of templates) {
output[t.artifactId] = { path: t.templatePath, source: t.source };
}
console.log(JSON.stringify(output, null, 2));
return;
}
console.log(`Schema: ${schemaName}`);
console.log(`Source: ${source}`);
console.log();
for (const t of templates) {
console.log(`${t.artifactId}:`);
console.log(` ${t.templatePath}`);
}
} catch (error) {
spinner.stop();
throw error;
}
}
+23 -13
View File
@@ -59,11 +59,7 @@ export interface ArtifactInstructions {
description: string;
/** Guidance on how to create this artifact (from schema instruction field) */
instruction: string | undefined;
/** Project context from config (constraints/background for AI, not to be included in output) */
context: string | undefined;
/** Artifact-specific rules from config (constraints for AI, not to be included in output) */
rules: string[] | undefined;
/** Template content (structure to follow - this IS the output format) */
/** Template content (structure to follow) */
template: string;
/** Dependencies with completion status and paths */
dependencies: DependencyInfo[];
@@ -222,11 +218,14 @@ export function generateInstructions(
const dependencies = getDependencyInfo(artifact, context.graph, context.completed);
const unlocks = getUnlockedArtifacts(context.graph, artifactId);
// Build enriched template with project config injections
let enrichedTemplate = '';
let projectConfig = null;
// Use projectRoot from context if not explicitly provided
const effectiveProjectRoot = projectRoot ?? context.projectRoot;
// Try to read project config for context and rules
let projectConfig = null;
// Try to read project config
if (effectiveProjectRoot) {
try {
projectConfig = readProjectConfig(effectiveProjectRoot);
@@ -253,10 +252,23 @@ export function generateInstructions(
}
}
// Extract context and rules as separate fields (not prepended to template)
const configContext = projectConfig?.context?.trim() || undefined;
// 1. Add context (all artifacts)
if (projectConfig?.context) {
enrichedTemplate += `<context>\n${projectConfig.context}\n</context>\n\n`;
}
// 2. Add rules (only for matching artifact)
const rulesForArtifact = projectConfig?.rules?.[artifactId];
const configRules = rulesForArtifact && rulesForArtifact.length > 0 ? rulesForArtifact : undefined;
if (rulesForArtifact && rulesForArtifact.length > 0) {
enrichedTemplate += `<rules>\n`;
for (const rule of rulesForArtifact) {
enrichedTemplate += `- ${rule}\n`;
}
enrichedTemplate += `</rules>\n\n`;
}
// 3. Add original template (without wrapper - CLI handles XML structure)
enrichedTemplate += templateContent;
return {
changeName: context.changeName,
@@ -266,9 +278,7 @@ export function generateInstructions(
outputPath: artifact.generates,
description: artifact.description,
instruction: artifact.instruction,
context: configContext,
rules: configRules,
template: templateContent,
template: enrichedTemplate,
dependencies,
unlocks,
};
@@ -1,30 +0,0 @@
/**
* Amazon Q Developer Command Adapter
*
* Formats commands for Amazon Q Developer following its frontmatter specification.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* Amazon Q adapter for command generation.
* File path: .amazonq/prompts/opsx-<id>.md
* Frontmatter: description
*/
export const amazonQAdapter: ToolCommandAdapter = {
toolId: 'amazon-q',
getFilePath(commandId: string): string {
return path.join('.amazonq', 'prompts', `opsx-${commandId}.md`);
},
formatFile(content: CommandContent): string {
return `---
description: ${content.description}
---
${content.body}
`;
},
};
@@ -1,30 +0,0 @@
/**
* Antigravity Command Adapter
*
* Formats commands for Antigravity following its frontmatter specification.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* Antigravity adapter for command generation.
* File path: .agent/workflows/opsx-<id>.md
* Frontmatter: description
*/
export const antigravityAdapter: ToolCommandAdapter = {
toolId: 'antigravity',
getFilePath(commandId: string): string {
return path.join('.agent', 'workflows', `opsx-${commandId}.md`);
},
formatFile(content: CommandContent): string {
return `---
description: ${content.description}
---
${content.body}
`;
},
};
@@ -1,31 +0,0 @@
/**
* Auggie (Augment CLI) Command Adapter
*
* Formats commands for Auggie following its frontmatter specification.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* Auggie adapter for command generation.
* File path: .augment/commands/opsx-<id>.md
* Frontmatter: description, argument-hint
*/
export const auggieAdapter: ToolCommandAdapter = {
toolId: 'auggie',
getFilePath(commandId: string): string {
return path.join('.augment', 'commands', `opsx-${commandId}.md`);
},
formatFile(content: CommandContent): string {
return `---
description: ${content.description}
argument-hint: command arguments
---
${content.body}
`;
},
};
@@ -1,56 +0,0 @@
/**
* Claude Code Command Adapter
*
* Formats commands for Claude Code following its frontmatter specification.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* Escapes a string value for safe YAML output.
* Quotes the string if it contains special YAML characters.
*/
function escapeYamlValue(value: string): string {
// Check if value needs quoting (contains special YAML characters or starts/ends with whitespace)
const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value);
if (needsQuoting) {
// Use double quotes and escape internal double quotes and backslashes
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
return `"${escaped}"`;
}
return value;
}
/**
* Formats a tags array as a YAML array with proper escaping.
*/
function formatTagsArray(tags: string[]): string {
const escapedTags = tags.map((tag) => escapeYamlValue(tag));
return `[${escapedTags.join(', ')}]`;
}
/**
* Claude Code adapter for command generation.
* File path: .claude/commands/opsx/<id>.md
* Frontmatter: name, description, category, tags
*/
export const claudeAdapter: ToolCommandAdapter = {
toolId: 'claude',
getFilePath(commandId: string): string {
return path.join('.claude', 'commands', 'opsx', `${commandId}.md`);
},
formatFile(content: CommandContent): string {
return `---
name: ${escapeYamlValue(content.name)}
description: ${escapeYamlValue(content.description)}
category: ${escapeYamlValue(content.category)}
tags: ${formatTagsArray(content.tags)}
---
${content.body}
`;
},
};
@@ -1,31 +0,0 @@
/**
* Cline Command Adapter
*
* Formats commands for Cline following its workflow specification.
* Cline uses markdown headers instead of YAML frontmatter.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* Cline adapter for command generation.
* File path: .clinerules/workflows/opsx-<id>.md
* Format: Markdown header with description
*/
export const clineAdapter: ToolCommandAdapter = {
toolId: 'cline',
getFilePath(commandId: string): string {
return path.join('.clinerules', 'workflows', `opsx-${commandId}.md`);
},
formatFile(content: CommandContent): string {
return `# ${content.name}
${content.description}
${content.body}
`;
},
};
@@ -1,32 +0,0 @@
/**
* CodeBuddy Command Adapter
*
* Formats commands for CodeBuddy following its frontmatter specification.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* CodeBuddy adapter for command generation.
* File path: .codebuddy/commands/opsx/<id>.md
* Frontmatter: name, description, argument-hint
*/
export const codebuddyAdapter: ToolCommandAdapter = {
toolId: 'codebuddy',
getFilePath(commandId: string): string {
return path.join('.codebuddy', 'commands', 'opsx', `${commandId}.md`);
},
formatFile(content: CommandContent): string {
return `---
name: ${content.name}
description: "${content.description}"
argument-hint: "[command arguments]"
---
${content.body}
`;
},
};
@@ -1,31 +0,0 @@
/**
* Codex Command Adapter
*
* Formats commands for Codex following its frontmatter specification.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* Codex adapter for command generation.
* File path: .codex/prompts/opsx-<id>.md
* Frontmatter: description, argument-hint
*/
export const codexAdapter: ToolCommandAdapter = {
toolId: 'codex',
getFilePath(commandId: string): string {
return path.join('.codex', 'prompts', `opsx-${commandId}.md`);
},
formatFile(content: CommandContent): string {
return `---
description: ${content.description}
argument-hint: command arguments
---
${content.body}
`;
},
};
@@ -1,32 +0,0 @@
/**
* Continue Command Adapter
*
* Formats commands for Continue following its .prompt specification.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* Continue adapter for command generation.
* File path: .continue/prompts/opsx-<id>.prompt
* Frontmatter: name, description, invokable
*/
export const continueAdapter: ToolCommandAdapter = {
toolId: 'continue',
getFilePath(commandId: string): string {
return path.join('.continue', 'prompts', `opsx-${commandId}.prompt`);
},
formatFile(content: CommandContent): string {
return `---
name: opsx-${content.id}
description: ${content.description}
invokable: true
---
${content.body}
`;
},
};
@@ -1,31 +0,0 @@
/**
* CoStrict Command Adapter
*
* Formats commands for CoStrict following its frontmatter specification.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* CoStrict adapter for command generation.
* File path: .cospec/openspec/commands/opsx-<id>.md
* Frontmatter: description, argument-hint
*/
export const costrictAdapter: ToolCommandAdapter = {
toolId: 'costrict',
getFilePath(commandId: string): string {
return path.join('.cospec', 'openspec', 'commands', `opsx-${commandId}.md`);
},
formatFile(content: CommandContent): string {
return `---
description: "${content.description}"
argument-hint: command arguments
---
${content.body}
`;
},
};
@@ -1,34 +0,0 @@
/**
* Crush Command Adapter
*
* Formats commands for Crush following its frontmatter specification.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* Crush adapter for command generation.
* File path: .crush/commands/opsx/<id>.md
* Frontmatter: name, description, category, tags
*/
export const crushAdapter: ToolCommandAdapter = {
toolId: 'crush',
getFilePath(commandId: string): string {
return path.join('.crush', 'commands', 'opsx', `${commandId}.md`);
},
formatFile(content: CommandContent): string {
const tagsStr = content.tags.join(', ');
return `---
name: ${content.name}
description: ${content.description}
category: ${content.category}
tags: [${tagsStr}]
---
${content.body}
`;
},
};
@@ -1,49 +0,0 @@
/**
* Cursor Command Adapter
*
* Formats commands for Cursor following its frontmatter specification.
* Cursor uses a different frontmatter format and file naming convention.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* Escapes a string value for safe YAML output.
* Quotes the string if it contains special YAML characters.
*/
function escapeYamlValue(value: string): string {
// Check if value needs quoting (contains special YAML characters or starts/ends with whitespace)
const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value);
if (needsQuoting) {
// Use double quotes and escape internal double quotes and backslashes
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
return `"${escaped}"`;
}
return value;
}
/**
* Cursor adapter for command generation.
* File path: .cursor/commands/opsx-<id>.md
* Frontmatter: name (as /opsx-<id>), id, category, description
*/
export const cursorAdapter: ToolCommandAdapter = {
toolId: 'cursor',
getFilePath(commandId: string): string {
return path.join('.cursor', 'commands', `opsx-${commandId}.md`);
},
formatFile(content: CommandContent): string {
return `---
name: /opsx-${content.id}
id: opsx-${content.id}
category: ${escapeYamlValue(content.category)}
description: ${escapeYamlValue(content.description)}
---
${content.body}
`;
},
};
@@ -1,31 +0,0 @@
/**
* Factory Droid Command Adapter
*
* Formats commands for Factory Droid following its frontmatter specification.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* Factory adapter for command generation.
* File path: .factory/commands/opsx-<id>.md
* Frontmatter: description, argument-hint
*/
export const factoryAdapter: ToolCommandAdapter = {
toolId: 'factory',
getFilePath(commandId: string): string {
return path.join('.factory', 'commands', `opsx-${commandId}.md`);
},
formatFile(content: CommandContent): string {
return `---
description: ${content.description}
argument-hint: command arguments
---
${content.body}
`;
},
};
@@ -1,30 +0,0 @@
/**
* Gemini CLI Command Adapter
*
* Formats commands for Gemini CLI following its TOML specification.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* Gemini adapter for command generation.
* File path: .gemini/commands/opsx/<id>.toml
* Format: TOML with description and prompt fields
*/
export const geminiAdapter: ToolCommandAdapter = {
toolId: 'gemini',
getFilePath(commandId: string): string {
return path.join('.gemini', 'commands', 'opsx', `${commandId}.toml`);
},
formatFile(content: CommandContent): string {
return `description = "${content.description}"
prompt = """
${content.body}
"""
`;
},
};
@@ -1,30 +0,0 @@
/**
* GitHub Copilot Command Adapter
*
* Formats commands for GitHub Copilot following its .prompt.md specification.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* GitHub Copilot adapter for command generation.
* File path: .github/prompts/opsx-<id>.prompt.md
* Frontmatter: description
*/
export const githubCopilotAdapter: ToolCommandAdapter = {
toolId: 'github-copilot',
getFilePath(commandId: string): string {
return path.join('.github', 'prompts', `opsx-${commandId}.prompt.md`);
},
formatFile(content: CommandContent): string {
return `---
description: ${content.description}
---
${content.body}
`;
},
};
@@ -1,33 +0,0 @@
/**
* iFlow Command Adapter
*
* Formats commands for iFlow following its frontmatter specification.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* iFlow adapter for command generation.
* File path: .iflow/commands/opsx-<id>.md
* Frontmatter: name, id, category, description
*/
export const iflowAdapter: ToolCommandAdapter = {
toolId: 'iflow',
getFilePath(commandId: string): string {
return path.join('.iflow', 'commands', `opsx-${commandId}.md`);
},
formatFile(content: CommandContent): string {
return `---
name: /opsx-${content.id}
id: opsx-${content.id}
category: ${content.category}
description: ${content.description}
---
${content.body}
`;
},
};
@@ -1,27 +0,0 @@
/**
* Command Adapters Index
*
* Re-exports all tool command adapters.
*/
export { amazonQAdapter } from './amazon-q.js';
export { antigravityAdapter } from './antigravity.js';
export { auggieAdapter } from './auggie.js';
export { claudeAdapter } from './claude.js';
export { clineAdapter } from './cline.js';
export { codexAdapter } from './codex.js';
export { codebuddyAdapter } from './codebuddy.js';
export { continueAdapter } from './continue.js';
export { costrictAdapter } from './costrict.js';
export { crushAdapter } from './crush.js';
export { cursorAdapter } from './cursor.js';
export { factoryAdapter } from './factory.js';
export { geminiAdapter } from './gemini.js';
export { githubCopilotAdapter } from './github-copilot.js';
export { iflowAdapter } from './iflow.js';
export { kilocodeAdapter } from './kilocode.js';
export { opencodeAdapter } from './opencode.js';
export { qoderAdapter } from './qoder.js';
export { qwenAdapter } from './qwen.js';
export { roocodeAdapter } from './roocode.js';
export { windsurfAdapter } from './windsurf.js';
@@ -1,27 +0,0 @@
/**
* Kilo Code Command Adapter
*
* Formats commands for Kilo Code following its workflow specification.
* Kilo Code workflows don't use frontmatter.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* Kilo Code adapter for command generation.
* File path: .kilocode/workflows/opsx-<id>.md
* Format: Plain markdown without frontmatter
*/
export const kilocodeAdapter: ToolCommandAdapter = {
toolId: 'kilocode',
getFilePath(commandId: string): string {
return path.join('.kilocode', 'workflows', `opsx-${commandId}.md`);
},
formatFile(content: CommandContent): string {
return `${content.body}
`;
},
};
@@ -1,30 +0,0 @@
/**
* OpenCode Command Adapter
*
* Formats commands for OpenCode following its frontmatter specification.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* OpenCode adapter for command generation.
* File path: .opencode/command/opsx-<id>.md
* Frontmatter: description
*/
export const opencodeAdapter: ToolCommandAdapter = {
toolId: 'opencode',
getFilePath(commandId: string): string {
return path.join('.opencode', 'command', `opsx-${commandId}.md`);
},
formatFile(content: CommandContent): string {
return `---
description: ${content.description}
---
${content.body}
`;
},
};
@@ -1,34 +0,0 @@
/**
* Qoder Command Adapter
*
* Formats commands for Qoder following its frontmatter specification.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* Qoder adapter for command generation.
* File path: .qoder/commands/opsx/<id>.md
* Frontmatter: name, description, category, tags
*/
export const qoderAdapter: ToolCommandAdapter = {
toolId: 'qoder',
getFilePath(commandId: string): string {
return path.join('.qoder', 'commands', 'opsx', `${commandId}.md`);
},
formatFile(content: CommandContent): string {
const tagsStr = content.tags.join(', ');
return `---
name: ${content.name}
description: ${content.description}
category: ${content.category}
tags: [${tagsStr}]
---
${content.body}
`;
},
};
@@ -1,30 +0,0 @@
/**
* Qwen Code Command Adapter
*
* Formats commands for Qwen Code following its TOML specification.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* Qwen adapter for command generation.
* File path: .qwen/commands/opsx-<id>.toml
* Format: TOML with description and prompt fields
*/
export const qwenAdapter: ToolCommandAdapter = {
toolId: 'qwen',
getFilePath(commandId: string): string {
return path.join('.qwen', 'commands', `opsx-${commandId}.toml`);
},
formatFile(content: CommandContent): string {
return `description = "${content.description}"
prompt = """
${content.body}
"""
`;
},
};
@@ -1,31 +0,0 @@
/**
* RooCode Command Adapter
*
* Formats commands for RooCode following its workflow specification.
* RooCode uses markdown headers instead of YAML frontmatter.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* RooCode adapter for command generation.
* File path: .roo/commands/opsx-<id>.md
* Format: Markdown header with description
*/
export const roocodeAdapter: ToolCommandAdapter = {
toolId: 'roocode',
getFilePath(commandId: string): string {
return path.join('.roo', 'commands', `opsx-${commandId}.md`);
},
formatFile(content: CommandContent): string {
return `# ${content.name}
${content.description}
${content.body}
`;
},
};
@@ -1,57 +0,0 @@
/**
* Windsurf Command Adapter
*
* Formats commands for Windsurf following its frontmatter specification.
* Windsurf uses a similar format to Claude but may have different conventions.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* Escapes a string value for safe YAML output.
* Quotes the string if it contains special YAML characters.
*/
function escapeYamlValue(value: string): string {
// Check if value needs quoting (contains special YAML characters or starts/ends with whitespace)
const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value);
if (needsQuoting) {
// Use double quotes and escape internal double quotes and backslashes
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
return `"${escaped}"`;
}
return value;
}
/**
* Formats a tags array as a YAML array with proper escaping.
*/
function formatTagsArray(tags: string[]): string {
const escapedTags = tags.map((tag) => escapeYamlValue(tag));
return `[${escapedTags.join(', ')}]`;
}
/**
* Windsurf adapter for command generation.
* File path: .windsurf/commands/opsx/<id>.md
* Frontmatter: name, description, category, tags
*/
export const windsurfAdapter: ToolCommandAdapter = {
toolId: 'windsurf',
getFilePath(commandId: string): string {
return path.join('.windsurf', 'commands', 'opsx', `${commandId}.md`);
},
formatFile(content: CommandContent): string {
return `---
name: ${escapeYamlValue(content.name)}
description: ${escapeYamlValue(content.description)}
category: ${escapeYamlValue(content.category)}
tags: ${formatTagsArray(content.tags)}
---
${content.body}
`;
},
};
-36
View File
@@ -1,36 +0,0 @@
/**
* Command Generator
*
* Functions for generating command files using tool adapters.
*/
import type { CommandContent, ToolCommandAdapter, GeneratedCommand } from './types.js';
/**
* Generate a single command file using the provided adapter.
* @param content - The tool-agnostic command content
* @param adapter - The tool-specific adapter
* @returns Generated command with path and file content
*/
export function generateCommand(
content: CommandContent,
adapter: ToolCommandAdapter
): GeneratedCommand {
return {
path: adapter.getFilePath(content.id),
fileContent: adapter.formatFile(content),
};
}
/**
* Generate multiple command files using the provided adapter.
* @param contents - Array of tool-agnostic command contents
* @param adapter - The tool-specific adapter
* @returns Array of generated commands with paths and file contents
*/
export function generateCommands(
contents: CommandContent[],
adapter: ToolCommandAdapter
): GeneratedCommand[] {
return contents.map((content) => generateCommand(content, adapter));
}
-33
View File
@@ -1,33 +0,0 @@
/**
* Command Generation Module
*
* Generic command generation system with tool-specific adapters.
*
* Usage:
* ```typescript
* import { generateCommands, CommandAdapterRegistry, type CommandContent } from './command-generation/index.js';
*
* const contents: CommandContent[] = [...];
* const adapter = CommandAdapterRegistry.get('cursor');
* if (adapter) {
* const commands = generateCommands(contents, adapter);
* // Write commands to disk
* }
* ```
*/
// Types
export type {
CommandContent,
ToolCommandAdapter,
GeneratedCommand,
} from './types.js';
// Registry
export { CommandAdapterRegistry } from './registry.js';
// Generator functions
export { generateCommand, generateCommands } from './generator.js';
// Adapters (for direct access if needed)
export { claudeAdapter, cursorAdapter, windsurfAdapter } from './adapters/index.js';
-95
View File
@@ -1,95 +0,0 @@
/**
* Command Adapter Registry
*
* Centralized registry for tool command adapters.
* Similar pattern to existing SlashCommandRegistry in the codebase.
*/
import type { ToolCommandAdapter } from './types.js';
import { amazonQAdapter } from './adapters/amazon-q.js';
import { antigravityAdapter } from './adapters/antigravity.js';
import { auggieAdapter } from './adapters/auggie.js';
import { claudeAdapter } from './adapters/claude.js';
import { clineAdapter } from './adapters/cline.js';
import { codexAdapter } from './adapters/codex.js';
import { codebuddyAdapter } from './adapters/codebuddy.js';
import { continueAdapter } from './adapters/continue.js';
import { costrictAdapter } from './adapters/costrict.js';
import { crushAdapter } from './adapters/crush.js';
import { cursorAdapter } from './adapters/cursor.js';
import { factoryAdapter } from './adapters/factory.js';
import { geminiAdapter } from './adapters/gemini.js';
import { githubCopilotAdapter } from './adapters/github-copilot.js';
import { iflowAdapter } from './adapters/iflow.js';
import { kilocodeAdapter } from './adapters/kilocode.js';
import { opencodeAdapter } from './adapters/opencode.js';
import { qoderAdapter } from './adapters/qoder.js';
import { qwenAdapter } from './adapters/qwen.js';
import { roocodeAdapter } from './adapters/roocode.js';
import { windsurfAdapter } from './adapters/windsurf.js';
/**
* Registry for looking up tool command adapters.
*/
export class CommandAdapterRegistry {
private static adapters: Map<string, ToolCommandAdapter> = new Map();
// Static initializer - register built-in adapters
static {
CommandAdapterRegistry.register(amazonQAdapter);
CommandAdapterRegistry.register(antigravityAdapter);
CommandAdapterRegistry.register(auggieAdapter);
CommandAdapterRegistry.register(claudeAdapter);
CommandAdapterRegistry.register(clineAdapter);
CommandAdapterRegistry.register(codexAdapter);
CommandAdapterRegistry.register(codebuddyAdapter);
CommandAdapterRegistry.register(continueAdapter);
CommandAdapterRegistry.register(costrictAdapter);
CommandAdapterRegistry.register(crushAdapter);
CommandAdapterRegistry.register(cursorAdapter);
CommandAdapterRegistry.register(factoryAdapter);
CommandAdapterRegistry.register(geminiAdapter);
CommandAdapterRegistry.register(githubCopilotAdapter);
CommandAdapterRegistry.register(iflowAdapter);
CommandAdapterRegistry.register(kilocodeAdapter);
CommandAdapterRegistry.register(opencodeAdapter);
CommandAdapterRegistry.register(qoderAdapter);
CommandAdapterRegistry.register(qwenAdapter);
CommandAdapterRegistry.register(roocodeAdapter);
CommandAdapterRegistry.register(windsurfAdapter);
}
/**
* Register a tool command adapter.
* @param adapter - The adapter to register
*/
static register(adapter: ToolCommandAdapter): void {
CommandAdapterRegistry.adapters.set(adapter.toolId, adapter);
}
/**
* Get an adapter by tool ID.
* @param toolId - The tool identifier (e.g., 'claude', 'cursor')
* @returns The adapter or undefined if not registered
*/
static get(toolId: string): ToolCommandAdapter | undefined {
return CommandAdapterRegistry.adapters.get(toolId);
}
/**
* Get all registered adapters.
* @returns Array of all registered adapters
*/
static getAll(): ToolCommandAdapter[] {
return Array.from(CommandAdapterRegistry.adapters.values());
}
/**
* Check if an adapter is registered for a tool.
* @param toolId - The tool identifier
* @returns True if an adapter exists
*/
static has(toolId: string): boolean {
return CommandAdapterRegistry.adapters.has(toolId);
}
}
-57
View File
@@ -1,57 +0,0 @@
/**
* Command Generation Types
*
* Tool-agnostic interfaces for command generation.
* These types separate "what to generate" from "how to format it".
*/
/**
* Tool-agnostic command data.
* Represents the content of a command without any tool-specific formatting.
*/
export interface CommandContent {
/** Command identifier (e.g., 'explore', 'apply', 'new') */
id: string;
/** Human-readable name (e.g., 'OpenSpec Explore') */
name: string;
/** Brief description of command purpose */
description: string;
/** Grouping category (e.g., 'Workflow') */
category: string;
/** Array of tag strings */
tags: string[];
/** The command instruction content (body text) */
body: string;
}
/**
* Per-tool formatting strategy.
* Each AI tool implements this interface to handle its specific file path
* and frontmatter format requirements.
*/
export interface ToolCommandAdapter {
/** Tool identifier matching AIToolOption.value (e.g., 'claude', 'cursor') */
toolId: string;
/**
* Returns the relative file path for a command.
* @param commandId - The command identifier (e.g., 'explore')
* @returns Relative path from project root (e.g., '.claude/commands/opsx/explore.md')
*/
getFilePath(commandId: string): string;
/**
* Formats the complete file content including frontmatter.
* @param content - The tool-agnostic command content
* @returns Complete file content ready to write
*/
formatFile(content: CommandContent): string;
}
/**
* Result of generating a command file.
*/
export interface GeneratedCommand {
/** Relative file path from project root */
path: string;
/** Complete file content (frontmatter + body) */
fileContent: string;
}
+187 -27
View File
@@ -1,39 +1,199 @@
import { stringify as stringifyYaml } from 'yaml';
import { listSchemasWithInfo, resolveSchema } from './artifact-graph/resolver.js';
import type { ProjectConfig } from './project-config.js';
/**
* Serialize config to YAML string with helpful comments.
* Check if an error is an ExitPromptError (user cancelled with Ctrl+C).
* Used instead of instanceof check since @inquirer modules use dynamic imports.
*/
export function isExitPromptError(error: unknown): boolean {
return (
error !== null &&
typeof error === 'object' &&
'name' in error &&
(error as { name: string }).name === 'ExitPromptError'
);
}
/**
* Result of interactive config creation prompts.
*/
export interface ConfigPromptResult {
/** Whether to create config file */
createConfig: boolean;
/** Selected schema name */
schema?: string;
/** Project context (optional) */
context?: string;
/** Per-artifact rules (optional) */
rules?: Record<string, string[]>;
}
/**
* Prompt user to create project config interactively.
* Used by experimental setup command.
*
* @param projectRoot - Optional project root for project-local schema resolution
* @returns Config prompt result
* @throws ExitPromptError if user cancels (Ctrl+C)
*/
export async function promptForConfig(
projectRoot?: string
): Promise<ConfigPromptResult> {
// Dynamic imports to prevent pre-commit hook hangs (see #367)
const { confirm, select, editor, checkbox } = await import('@inquirer/prompts');
// Ask if user wants to create config
const shouldCreate = await confirm({
message: 'Create openspec/config.yaml?',
default: true,
});
if (!shouldCreate) {
return { createConfig: false };
}
// Get available schemas
const schemas = listSchemasWithInfo(projectRoot);
if (schemas.length === 0) {
throw new Error('No schemas found. Cannot create config.');
}
// Prompt for schema selection
const selectedSchema = await select({
message: 'Default schema for new changes?',
choices: schemas.map((s) => ({
name: `${s.name} (${s.artifacts.join(' → ')})`,
value: s.name,
description: s.description || undefined,
})),
});
// Prompt for project context
console.log('\nAdd project context? (optional)');
console.log('Context is shown to AI when creating artifacts.');
console.log('Examples: tech stack, conventions, style guides, domain knowledge\n');
const contextInput = await editor({
message: 'Press Enter to skip, or edit context:',
default: '',
waitForUseInput: false,
});
const context = contextInput.trim() || undefined;
// Prompt for per-artifact rules
const addRules = await confirm({
message: 'Add per-artifact rules? (optional)',
default: false,
});
let rules: Record<string, string[]> | undefined;
if (addRules) {
// Load the selected schema to get artifact list
const schema = resolveSchema(selectedSchema, projectRoot);
const artifactIds = schema.artifacts.map((a) => a.id);
// Let user select which artifacts to add rules for
const selectedArtifacts = await checkbox({
message: 'Which artifacts should have custom rules?',
choices: artifactIds.map((id) => ({
name: id,
value: id,
})),
});
if (selectedArtifacts.length > 0) {
rules = {};
// For each selected artifact, collect rules line by line
for (const artifactId of selectedArtifacts) {
const artifactRules = await promptForArtifactRules(artifactId);
if (artifactRules.length > 0) {
rules[artifactId] = artifactRules;
}
}
// If no rules were actually added, set to undefined
if (Object.keys(rules).length === 0) {
rules = undefined;
}
}
}
return {
createConfig: true,
schema: selectedSchema,
context,
rules,
};
}
/**
* Prompt for rules for a specific artifact.
* Collects rules one per line until user enters empty line.
*
* @param artifactId - The artifact ID to collect rules for
* @returns Array of rules
*/
async function promptForArtifactRules(artifactId: string): Promise<string[]> {
// Dynamic import to prevent pre-commit hook hangs (see #367)
const { input } = await import('@inquirer/prompts');
const rules: string[] = [];
console.log(`\nRules for ${artifactId} artifact:`);
console.log('Enter rules one per line, press Enter on empty line to finish:\n');
while (true) {
const rule = await input({
message: '│',
validate: () => {
// Empty string is valid (signals end of input)
return true;
},
});
const trimmed = rule.trim();
// Empty line signals end of input
if (!trimmed) {
break;
}
rules.push(trimmed);
}
return rules;
}
/**
* Serialize config to YAML string with proper multi-line formatting.
*
* @param config - Partial config object (schema required, context/rules optional)
* @returns YAML string ready to write to file
*/
export function serializeConfig(config: Partial<ProjectConfig>): string {
const lines: string[] = [];
// Build clean config object (only include defined fields)
const cleanConfig: Record<string, unknown> = {
schema: config.schema,
};
// Schema (required)
lines.push(`schema: ${config.schema}`);
lines.push('');
if (config.context) {
cleanConfig.context = config.context;
}
// Context section with comments
lines.push('# Project context (optional)');
lines.push('# This is shown to AI when creating artifacts.');
lines.push('# Add your tech stack, conventions, style guides, domain knowledge, etc.');
lines.push('# Example:');
lines.push('# context: |');
lines.push('# Tech stack: TypeScript, React, Node.js');
lines.push('# We use conventional commits');
lines.push('# Domain: e-commerce platform');
lines.push('');
if (config.rules && Object.keys(config.rules).length > 0) {
cleanConfig.rules = config.rules;
}
// Rules section with comments
lines.push('# Per-artifact rules (optional)');
lines.push('# Add custom rules for specific artifacts.');
lines.push('# Example:');
lines.push('# rules:');
lines.push('# proposal:');
lines.push('# - Keep proposals under 500 words');
lines.push('# - Always include a "Non-goals" section');
lines.push('# tasks:');
lines.push('# - Break tasks into chunks of max 2 hours');
return lines.join('\n') + '\n';
// Serialize to YAML with proper formatting
return stringifyYaml(cleanConfig, {
indent: 2,
lineWidth: 0, // Don't wrap long lines
defaultStringType: 'PLAIN',
defaultKeyType: 'PLAIN',
});
}
+21 -22
View File
@@ -14,30 +14,29 @@ export interface AIToolOption {
value: string;
available: boolean;
successLabel?: string;
skillsDir?: string; // e.g., '.claude' - /skills suffix per Agent Skills spec
}
export const AI_TOOLS: AIToolOption[] = [
{ name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer', skillsDir: '.amazonq' },
{ name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity', skillsDir: '.agent' },
{ name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie', skillsDir: '.augment' },
{ name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code', skillsDir: '.claude' },
{ name: 'Cline', value: 'cline', available: true, successLabel: 'Cline', skillsDir: '.cline' },
{ name: 'Codex', value: 'codex', available: true, successLabel: 'Codex', skillsDir: '.codex' },
{ name: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code', skillsDir: '.codebuddy' },
{ name: 'Continue', value: 'continue', available: true, successLabel: 'Continue (VS Code / JetBrains / Cli)', skillsDir: '.continue' },
{ name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict', skillsDir: '.cospec' },
{ name: 'Crush', value: 'crush', available: true, successLabel: 'Crush', skillsDir: '.crush' },
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor', skillsDir: '.cursor' },
{ name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid', skillsDir: '.factory' },
{ name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI', skillsDir: '.gemini' },
{ name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github' },
{ name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow', skillsDir: '.iflow' },
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code', skillsDir: '.kilocode' },
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode', skillsDir: '.opencode' },
{ name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder' },
{ name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code', skillsDir: '.qwen' },
{ name: 'RooCode', value: 'roocode', available: true, successLabel: 'RooCode', skillsDir: '.roo' },
{ name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf', skillsDir: '.windsurf' },
{ name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer' },
{ name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity' },
{ name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie' },
{ name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code' },
{ name: 'Cline', value: 'cline', available: true, successLabel: 'Cline' },
{ name: 'Codex', value: 'codex', available: true, successLabel: 'Codex' },
{ name: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code' },
{ name: 'Continue', value: 'continue', available: true, successLabel: 'Continue (VS Code / JetBrains / Cli)' },
{ name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict' },
{ name: 'Crush', value: 'crush', available: true, successLabel: 'Crush' },
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor' },
{ name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid' },
{ name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI' },
{ name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot' },
{ name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow' },
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code' },
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode' },
{ name: 'Qoder (CLI)', value: 'qoder', available: true, successLabel: 'Qoder' },
{ name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code' },
{ name: 'RooCode', value: 'roocode', available: true, successLabel: 'RooCode' },
{ name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf' },
{ name: 'AGENTS.md (works with Amp, VS Code, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' }
];
+23
View File
@@ -0,0 +1,23 @@
import path from 'path';
import { ToolConfigurator } from './base.js';
import { FileSystemUtils } from '../../utils/file-system.js';
import { TemplateManager } from '../templates/index.js';
import { OPENSPEC_MARKERS } from '../config.js';
export class AgentsStandardConfigurator implements ToolConfigurator {
name = 'AGENTS.md standard';
configFileName = 'AGENTS.md';
isAvailable = true;
async configure(projectPath: string, _openspecDir: string): Promise<void> {
const filePath = path.join(projectPath, this.configFileName);
const content = TemplateManager.getAgentsStandardTemplate();
await FileSystemUtils.updateFileWithMarkers(
filePath,
content,
OPENSPEC_MARKERS.start,
OPENSPEC_MARKERS.end
);
}
}
+6
View File
@@ -0,0 +1,6 @@
export interface ToolConfigurator {
name: string;
configFileName: string;
isAvailable: boolean;
configure(projectPath: string, openspecDir: string): Promise<void>;
}
+23
View File
@@ -0,0 +1,23 @@
import path from 'path';
import { ToolConfigurator } from './base.js';
import { FileSystemUtils } from '../../utils/file-system.js';
import { TemplateManager } from '../templates/index.js';
import { OPENSPEC_MARKERS } from '../config.js';
export class 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
);
}
}
+23
View File
@@ -0,0 +1,23 @@
import path from 'path';
import { ToolConfigurator } from './base.js';
import { FileSystemUtils } from '../../utils/file-system.js';
import { TemplateManager } from '../templates/index.js';
import { OPENSPEC_MARKERS } from '../config.js';
export class ClineConfigurator implements ToolConfigurator {
name = 'Cline';
configFileName = 'CLINE.md';
isAvailable = true;
async configure(projectPath: string, openspecDir: string): Promise<void> {
const filePath = path.join(projectPath, this.configFileName);
const content = TemplateManager.getClineTemplate();
await FileSystemUtils.updateFileWithMarkers(
filePath,
content,
OPENSPEC_MARKERS.start,
OPENSPEC_MARKERS.end
);
}
}
+24
View File
@@ -0,0 +1,24 @@
import path from 'path';
import { ToolConfigurator } from './base.js';
import { FileSystemUtils } from '../../utils/file-system.js';
import { TemplateManager } from '../templates/index.js';
import { OPENSPEC_MARKERS } from '../config.js';
export class CodeBuddyConfigurator implements ToolConfigurator {
name = 'CodeBuddy';
configFileName = 'CODEBUDDY.md';
isAvailable = true;
async configure(projectPath: string, openspecDir: string): Promise<void> {
const filePath = path.join(projectPath, this.configFileName);
const content = TemplateManager.getClaudeTemplate();
await FileSystemUtils.updateFileWithMarkers(
filePath,
content,
OPENSPEC_MARKERS.start,
OPENSPEC_MARKERS.end
);
}
}
+23
View File
@@ -0,0 +1,23 @@
import path from 'path';
import { ToolConfigurator } from './base.js';
import { FileSystemUtils } from '../../utils/file-system.js';
import { TemplateManager } from '../templates/index.js';
import { OPENSPEC_MARKERS } from '../config.js';
export class CostrictConfigurator implements ToolConfigurator {
name = 'CoStrict';
configFileName = 'COSTRICT.md';
isAvailable = true;
async configure(projectPath: string, openspecDir: string): Promise<void> {
const filePath = path.join(projectPath, this.configFileName);
const content = TemplateManager.getCostrictTemplate();
await FileSystemUtils.updateFileWithMarkers(
filePath,
content,
OPENSPEC_MARKERS.start,
OPENSPEC_MARKERS.end
);
}
}
+23
View File
@@ -0,0 +1,23 @@
import path from "path";
import { ToolConfigurator } from "./base.js";
import { FileSystemUtils } from "../../utils/file-system.js";
import { TemplateManager } from "../templates/index.js";
import { OPENSPEC_MARKERS } from "../config.js";
export class IflowConfigurator implements ToolConfigurator {
name = "iFlow";
configFileName = "IFLOW.md";
isAvailable = true;
async configure(projectPath: string, openspecDir: string): Promise<void> {
const filePath = path.join(projectPath, this.configFileName);
const content = TemplateManager.getClaudeTemplate();
await FileSystemUtils.updateFileWithMarkers(
filePath,
content,
OPENSPEC_MARKERS.start,
OPENSPEC_MARKERS.end
);
}
}
+53
View File
@@ -0,0 +1,53 @@
import path from 'path';
import { ToolConfigurator } from './base.js';
import { FileSystemUtils } from '../../utils/file-system.js';
import { TemplateManager } from '../templates/index.js';
import { OPENSPEC_MARKERS } from '../config.js';
/**
* Qoder AI Tool Configurator
*
* Configures OpenSpec integration for Qoder AI coding assistant.
* Creates and manages QODER.md configuration file with OpenSpec instructions.
*
* @implements {ToolConfigurator}
*/
export class QoderConfigurator implements ToolConfigurator {
/** Display name for the Qoder tool */
name = 'Qoder';
/** Configuration file name at project root */
configFileName = 'QODER.md';
/** Indicates tool is available for configuration */
isAvailable = true;
/**
* Configure Qoder integration for a project
*
* Creates or updates QODER.md file with OpenSpec instructions.
* Uses Claude-compatible template for instruction content.
* Wrapped with OpenSpec markers for future updates.
*
* @param {string} projectPath - Absolute path to project root directory
* @param {string} openspecDir - Path to openspec directory (unused but required by interface)
* @returns {Promise<void>} Resolves when configuration is complete
*/
async configure(projectPath: string, openspecDir: string): Promise<void> {
// Construct full path to QODER.md at project root
const filePath = path.join(projectPath, this.configFileName);
// Get Claude-compatible instruction template
// This ensures Qoder receives the same high-quality OpenSpec instructions
const content = TemplateManager.getClaudeTemplate();
// Write or update file with managed content between markers
// This allows future updates to refresh instructions automatically
await FileSystemUtils.updateFileWithMarkers(
filePath,
content,
OPENSPEC_MARKERS.start,
OPENSPEC_MARKERS.end
);
}
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Qwen Code configurator for OpenSpec integration.
* This class handles the configuration of Qwen Code as an AI tool within OpenSpec.
*
* @implements {ToolConfigurator}
*/
import path from 'path';
import { ToolConfigurator } from './base.js';
import { FileSystemUtils } from '../../utils/file-system.js';
import { TemplateManager } from '../templates/index.js';
import { OPENSPEC_MARKERS } from '../config.js';
/**
* QwenConfigurator class provides integration with Qwen Code
* by creating and managing the necessary configuration files.
* Currently configures the QWEN.md file with OpenSpec instructions.
*/
export class QwenConfigurator implements ToolConfigurator {
/** Display name for the Qwen Code tool */
name = 'Qwen Code';
/** Configuration file name for Qwen Code */
configFileName = 'QWEN.md';
/** Availability status for the Qwen Code tool */
isAvailable = true;
/**
* Configures the Qwen Code integration by creating or updating the QWEN.md file
* with OpenSpec instructions and markers.
*
* @param {string} projectPath - The path to the project root
* @param {string} _openspecDir - The path to the openspec directory (unused)
* @returns {Promise<void>} A promise that resolves when configuration is complete
*/
async configure(projectPath: string, _openspecDir: string): Promise<void> {
const filePath = path.join(projectPath, this.configFileName);
const content = TemplateManager.getAgentsStandardTemplate();
await FileSystemUtils.updateFileWithMarkers(
filePath,
content,
OPENSPEC_MARKERS.start,
OPENSPEC_MARKERS.end
);
}
}
+49
View File
@@ -0,0 +1,49 @@
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);
}
}
+51
View File
@@ -0,0 +1,51 @@
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];
}
}
@@ -0,0 +1,28 @@
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';
const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: '.agent/workflows/openspec-proposal.md',
apply: '.agent/workflows/openspec-apply.md',
archive: '.agent/workflows/openspec-archive.md'
};
const DESCRIPTIONS: Record<SlashCommandId, string> = {
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
archive: 'Archive a deployed OpenSpec change and update specs.'
};
export class AntigravitySlashCommandConfigurator extends SlashCommandConfigurator {
readonly toolId = 'antigravity';
readonly isAvailable = true;
protected getRelativePath(id: SlashCommandId): string {
return FILE_PATHS[id];
}
protected getFrontmatter(id: SlashCommandId): string | undefined {
const description = DESCRIPTIONS[id];
return `---\ndescription: ${description}\n---`;
}
}
+37
View File
@@ -0,0 +1,37 @@
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];
}
}
+95
View File
@@ -0,0 +1,95 @@
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);
}
}
+42
View File
@@ -0,0 +1,42 @@
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];
}
}
+27
View File
@@ -0,0 +1,27 @@
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';
const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: '.clinerules/workflows/openspec-proposal.md',
apply: '.clinerules/workflows/openspec-apply.md',
archive: '.clinerules/workflows/openspec-archive.md'
};
export class ClineSlashCommandConfigurator extends SlashCommandConfigurator {
readonly toolId = 'cline';
readonly isAvailable = true;
protected getRelativePath(id: SlashCommandId): string {
return FILE_PATHS[id];
}
protected getFrontmatter(id: SlashCommandId): string | undefined {
const descriptions: Record<SlashCommandId, string> = {
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
archive: 'Archive a deployed OpenSpec change and update specs.'
};
const description = descriptions[id];
return `# OpenSpec: ${id.charAt(0).toUpperCase() + id.slice(1)}\n\n${description}`;
}
}
+40
View File
@@ -0,0 +1,40 @@
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];
}
}
+126
View File
@@ -0,0 +1,126 @@
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);
}
}
+51
View File
@@ -0,0 +1,51 @@
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];
}
}
+36
View File
@@ -0,0 +1,36 @@
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';
const FILE_PATHS = {
proposal: '.cospec/openspec/commands/openspec-proposal.md',
apply: '.cospec/openspec/commands/openspec-apply.md',
archive: '.cospec/openspec/commands/openspec-archive.md',
} as const satisfies Record<SlashCommandId, string>;
const FRONTMATTER = {
proposal: `---
description: "Scaffold a new OpenSpec change and validate strictly."
argument-hint: feature description or request
---`,
apply: `---
description: "Implement an approved OpenSpec change and keep tasks in sync."
argument-hint: change-id
---`,
archive: `---
description: "Archive a deployed OpenSpec change and update specs."
argument-hint: change-id
---`
} as const satisfies Record<SlashCommandId, string>;
export class CostrictSlashCommandConfigurator extends SlashCommandConfigurator {
readonly toolId = 'costrict';
readonly isAvailable = true;
protected getRelativePath(id: SlashCommandId): string {
return FILE_PATHS[id];
}
protected getFrontmatter(id: SlashCommandId): string | undefined {
return FRONTMATTER[id];
}
}
+42
View File
@@ -0,0 +1,42 @@
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];
}
}
+42
View File
@@ -0,0 +1,42 @@
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];
}
}
+41
View File
@@ -0,0 +1,41 @@
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`;
}
}
+27
View File
@@ -0,0 +1,27 @@
import { TomlSlashCommandConfigurator } from './toml-base.js';
import { SlashCommandId } from '../../templates/index.js';
const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: '.gemini/commands/openspec/proposal.toml',
apply: '.gemini/commands/openspec/apply.toml',
archive: '.gemini/commands/openspec/archive.toml'
};
const DESCRIPTIONS: Record<SlashCommandId, string> = {
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
archive: 'Archive a deployed OpenSpec change and update specs.'
};
export class GeminiSlashCommandConfigurator extends TomlSlashCommandConfigurator {
readonly toolId = 'gemini';
readonly isAvailable = true;
protected getRelativePath(id: SlashCommandId): string {
return FILE_PATHS[id];
}
protected getDescription(id: SlashCommandId): string {
return DESCRIPTIONS[id];
}
}
@@ -0,0 +1,39 @@
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];
}
}
+42
View File
@@ -0,0 +1,42 @@
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';
const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: '.iflow/commands/openspec-proposal.md',
apply: '.iflow/commands/openspec-apply.md',
archive: '.iflow/commands/openspec-archive.md'
};
const FRONTMATTER: Record<SlashCommandId, string> = {
proposal: `---
name: /openspec-proposal
id: openspec-proposal
category: OpenSpec
description: Scaffold a new OpenSpec change and validate strictly.
---`,
apply: `---
name: /openspec-apply
id: openspec-apply
category: OpenSpec
description: Implement an approved OpenSpec change and keep tasks in sync.
---`,
archive: `---
name: /openspec-archive
id: openspec-archive
category: OpenSpec
description: Archive a deployed OpenSpec change and update specs.
---`
};
export class IflowSlashCommandConfigurator extends SlashCommandConfigurator {
readonly toolId = 'iflow';
readonly isAvailable = true;
protected getRelativePath(id: SlashCommandId): string {
return FILE_PATHS[id];
}
protected getFrontmatter(id: SlashCommandId): string {
return FRONTMATTER[id];
}
}
+21
View File
@@ -0,0 +1,21 @@
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;
}
}
+83
View File
@@ -0,0 +1,83 @@
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;
}
}
+84
View File
@@ -0,0 +1,84 @@
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';
/**
* File paths for Qoder slash commands
* Maps each OpenSpec workflow stage to its command file location
* Commands are stored in .qoder/commands/openspec/ directory
*/
const FILE_PATHS: Record<SlashCommandId, string> = {
// Create and validate new change proposals
proposal: '.qoder/commands/openspec/proposal.md',
// Implement approved changes with task tracking
apply: '.qoder/commands/openspec/apply.md',
// Archive completed changes and update specs
archive: '.qoder/commands/openspec/archive.md'
};
/**
* YAML frontmatter for Qoder slash commands
* Defines metadata displayed in Qoder's command palette
* Each command is categorized and tagged for easy discovery
*/
const FRONTMATTER: Record<SlashCommandId, string> = {
proposal: `---
name: OpenSpec: Proposal
description: Scaffold a new OpenSpec change and validate strictly.
category: OpenSpec
tags: [openspec, change]
---`,
apply: `---
name: OpenSpec: Apply
description: Implement an approved OpenSpec change and keep tasks in sync.
category: OpenSpec
tags: [openspec, apply]
---`,
archive: `---
name: OpenSpec: Archive
description: Archive a deployed OpenSpec change and update specs.
category: OpenSpec
tags: [openspec, archive]
---`
};
/**
* Qoder Slash Command Configurator
*
* Manages OpenSpec slash commands for Qoder AI assistant.
* Creates three workflow commands: proposal, apply, and archive.
* Uses colon-separated command format (/openspec:proposal).
*
* @extends {SlashCommandConfigurator}
*/
export class QoderSlashCommandConfigurator extends SlashCommandConfigurator {
/** Unique identifier for Qoder tool */
readonly toolId = 'qoder';
/** Indicates slash commands are available for this tool */
readonly isAvailable = true;
/**
* Get relative file path for a slash command
*
* @param {SlashCommandId} id - Command identifier (proposal, apply, or archive)
* @returns {string} Relative path from project root to command file
*/
protected getRelativePath(id: SlashCommandId): string {
return FILE_PATHS[id];
}
/**
* Get YAML frontmatter for a slash command
*
* Frontmatter defines how the command appears in Qoder's UI,
* including display name, description, and categorization.
*
* @param {SlashCommandId} id - Command identifier (proposal, apply, or archive)
* @returns {string} YAML frontmatter block with command metadata
*/
protected getFrontmatter(id: SlashCommandId): string {
return FRONTMATTER[id];
}
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Qwen slash command configurator for OpenSpec integration.
* This class handles the generation of Qwen-specific slash command files
* in the .qwen/commands directory structure.
*
* @implements {SlashCommandConfigurator}
*/
import { TomlSlashCommandConfigurator } from './toml-base.js';
import { SlashCommandId } from '../../templates/index.js';
/**
* Mapping of slash command IDs to their corresponding file paths in .qwen/commands directory.
* @type {Record<SlashCommandId, string>}
*/
const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: '.qwen/commands/openspec-proposal.toml',
apply: '.qwen/commands/openspec-apply.toml',
archive: '.qwen/commands/openspec-archive.toml'
};
const DESCRIPTIONS: Record<SlashCommandId, string> = {
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
archive: 'Archive a deployed OpenSpec change and update specs.'
};
/**
* QwenSlashCommandConfigurator class provides integration with Qwen Code
* by creating the necessary slash command files in the .qwen/commands directory.
*
* The slash commands include:
* - /openspec-proposal: Create an OpenSpec change proposal
* - /openspec-apply: Apply an approved OpenSpec change
* - /openspec-archive: Archive a deployed OpenSpec change
*/
export class QwenSlashCommandConfigurator extends TomlSlashCommandConfigurator {
/** Unique identifier for the Qwen tool */
readonly toolId = 'qwen';
/** Availability status for the Qwen tool */
readonly isAvailable = true;
/**
* Returns the relative file path for a given slash command ID.
* @param {SlashCommandId} id - The slash command identifier
* @returns {string} The relative path to the command file
*/
protected getRelativePath(id: SlashCommandId): string {
return FILE_PATHS[id];
}
protected getDescription(id: SlashCommandId): string {
return DESCRIPTIONS[id];
}
}
+84
View File
@@ -0,0 +1,84 @@
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());
}
}
+27
View File
@@ -0,0 +1,27 @@
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';
const NEW_FILE_PATHS: Record<SlashCommandId, string> = {
proposal: '.roo/commands/openspec-proposal.md',
apply: '.roo/commands/openspec-apply.md',
archive: '.roo/commands/openspec-archive.md'
};
export class RooCodeSlashCommandConfigurator extends SlashCommandConfigurator {
readonly toolId = 'roocode';
readonly isAvailable = true;
protected getRelativePath(id: SlashCommandId): string {
return NEW_FILE_PATHS[id];
}
protected getFrontmatter(id: SlashCommandId): string | undefined {
const descriptions: Record<SlashCommandId, string> = {
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
archive: 'Archive a deployed OpenSpec change and update specs.'
};
const description = descriptions[id];
return `# OpenSpec: ${id.charAt(0).toUpperCase() + id.slice(1)}\n\n${description}`;
}
}
+66
View File
@@ -0,0 +1,66 @@
import { FileSystemUtils } from '../../../utils/file-system.js';
import { SlashCommandConfigurator } from './base.js';
import { SlashCommandId } from '../../templates/index.js';
import { OPENSPEC_MARKERS } from '../../config.js';
export abstract class TomlSlashCommandConfigurator extends SlashCommandConfigurator {
protected getFrontmatter(_id: SlashCommandId): string | undefined {
// TOML doesn't use separate frontmatter - it's all in one structure
return undefined;
}
protected abstract getDescription(id: SlashCommandId): string;
// Override to generate TOML format with markers inside the prompt field
async generateAll(projectPath: string, _openspecDir: string): Promise<string[]> {
const createdOrUpdated: string[] = [];
for (const target of this.getTargets()) {
const body = this.getBody(target.id);
const filePath = FileSystemUtils.joinPath(projectPath, target.path);
if (await FileSystemUtils.fileExists(filePath)) {
await this.updateBody(filePath, body);
} else {
const tomlContent = this.generateTOML(target.id, body);
await FileSystemUtils.writeFile(filePath, tomlContent);
}
createdOrUpdated.push(target.path);
}
return createdOrUpdated;
}
private generateTOML(id: SlashCommandId, body: string): string {
const description = this.getDescription(id);
// TOML format with triple-quoted string for multi-line prompt
// Markers are inside the prompt value
return `description = "${description}"
prompt = """
${OPENSPEC_MARKERS.start}
${body}
${OPENSPEC_MARKERS.end}
"""
`;
}
// Override updateBody to handle TOML format
protected async updateBody(filePath: string, body: string): Promise<void> {
const content = await FileSystemUtils.readFile(filePath);
const startIndex = content.indexOf(OPENSPEC_MARKERS.start);
const endIndex = content.indexOf(OPENSPEC_MARKERS.end);
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
throw new Error(`Missing OpenSpec markers in ${filePath}`);
}
const before = content.slice(0, startIndex + OPENSPEC_MARKERS.start.length);
const after = content.slice(endIndex);
const updatedContent = `${before}\n${body}\n${after}`;
await FileSystemUtils.writeFile(filePath, updatedContent);
}
}
+27
View File
@@ -0,0 +1,27 @@
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---`;
}
}
+806 -417
View File
File diff suppressed because it is too large Load Diff

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