mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-04 06:18:24 +08:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
835c15a2f6 | ||
|
|
847aa81c0f | ||
|
|
39bebefcc4 | ||
|
|
cf8b6212c8 | ||
|
|
c157483685 | ||
|
|
f90c7c3354 | ||
|
|
9381bd3b24 | ||
|
|
ae83b4e16d | ||
|
|
d48528134b | ||
|
|
54bd3f1ccd | ||
|
|
675e870bf1 | ||
|
|
07eaf7b691 | ||
|
|
153721d14a | ||
|
|
70c2e17525 | ||
|
|
e2c333e493 | ||
|
|
e137dd3981 | ||
|
|
2beb8e77e8 | ||
|
|
3b16b13613 | ||
|
|
c4cfdc7c49 | ||
|
|
e0736807b4 | ||
|
|
fdb05a723e |
@@ -3,6 +3,8 @@ 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:
|
||||
@@ -11,7 +13,8 @@ on:
|
||||
type: string
|
||||
|
||||
env:
|
||||
TAG_NAME: ${{ inputs.tag_name }}
|
||||
# repository_dispatch passes tag via client_payload, workflow_dispatch via inputs
|
||||
TAG_NAME: ${{ github.event.client_payload.tag_name || inputs.tag_name }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -60,6 +60,9 @@ 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:
|
||||
@@ -68,4 +71,6 @@ jobs:
|
||||
# Get version from package.json (just bumped by changesets)
|
||||
TAG="v$(jq -r .version package.json)"
|
||||
echo "Triggering polish workflow for $TAG"
|
||||
gh workflow run polish-release-notes.yml -f tag_name="$TAG"
|
||||
gh api repos/${{ github.repository }}/dispatches \
|
||||
--method POST \
|
||||
--input - <<< "{\"event_type\":\"polish-release-notes\",\"client_payload\":{\"tag_name\":\"$TAG\"}}"
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<!-- OPENSPEC:START -->
|
||||
# OpenSpec Instructions
|
||||
|
||||
These instructions are for AI assistants working in this project.
|
||||
|
||||
Always open `@/openspec/AGENTS.md` when the request:
|
||||
- Mentions planning or proposals (words like proposal, spec, change, plan)
|
||||
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
|
||||
- Sounds ambiguous and you need the authoritative spec before coding
|
||||
|
||||
Use `@/openspec/AGENTS.md` to learn:
|
||||
- How to create and apply change proposals
|
||||
- Spec format and conventions
|
||||
- Project structure and guidelines
|
||||
|
||||
Keep this managed block so 'openspec update' can refresh the instructions.
|
||||
|
||||
<!-- OPENSPEC:END -->
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @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
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub>🧪 <strong>New:</strong> <a href="docs/experimental-workflow.md">Experimental Workflow (OPSX)</a> — schema-driven, hackable, fluid. Iterate on workflows without code changes.</sub>
|
||||
<sub>🧪 <strong>OPSX Workflow</strong> — schema-driven, hackable, fluid. See <a href="docs/experimental-workflow.md">workflow docs</a> for details.</sub>
|
||||
</p>
|
||||
|
||||
# OpenSpec
|
||||
@@ -89,43 +89,26 @@ See the full comparison in [How OpenSpec Compares](#how-openspec-compares).
|
||||
|
||||
### Supported AI Tools
|
||||
|
||||
OpenSpec generates **Agent Skills** and **/opsx:\* slash commands** for supported tools during `openspec init`.
|
||||
|
||||
<details>
|
||||
<summary><strong>Native Slash Commands</strong> (click to expand)</summary>
|
||||
<summary><strong>Tools with Agent Skills + Slash Commands</strong> (click to expand)</summary>
|
||||
|
||||
These tools have built-in OpenSpec commands. Select the OpenSpec integration when prompted.
|
||||
These tools support the full OpenSpec workflow with skills and commands:
|
||||
|
||||
| Tool | Commands |
|
||||
|------|----------|
|
||||
| **Amazon Q Developer** | `@openspec-proposal`, `@openspec-apply`, `@openspec-archive` (`.amazonq/prompts/`) |
|
||||
| **Antigravity** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.agent/workflows/`) |
|
||||
| **Auggie (Augment CLI)** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.augment/commands/`) |
|
||||
| **Claude Code** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` |
|
||||
| **Cline** | Workflows in `.clinerules/workflows/` directory (`.clinerules/workflows/openspec-*.md`) |
|
||||
| **CodeBuddy Code (CLI)** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.codebuddy/commands/`) — see [docs](https://www.codebuddy.ai/cli) |
|
||||
| **Codex** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (global: `~/.codex/prompts`, auto-installed) |
|
||||
| **Continue** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.continue/prompts/`) |
|
||||
| **CoStrict** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.cospec/openspec/commands/`) — see [docs](https://costrict.ai)|
|
||||
| **Crush** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.crush/commands/openspec/`) |
|
||||
| **Cursor** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
|
||||
| **Factory Droid** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.factory/commands/`) |
|
||||
| **Gemini CLI** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.gemini/commands/openspec/`) |
|
||||
| **GitHub Copilot** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.github/prompts/`) |
|
||||
| **iFlow (iflow-cli)** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.iflow/commands/`) |
|
||||
| **Kilo Code** | `/openspec-proposal.md`, `/openspec-apply.md`, `/openspec-archive.md` (`.kilocode/workflows/`) |
|
||||
| **OpenCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
|
||||
| **Qoder (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/`) |
|
||||
| Tool | Skills Location | Commands |
|
||||
|------|-----------------|----------|
|
||||
| **Claude Code** | `.claude/skills/` | `/opsx:new`, `/opsx:apply`, `/opsx:archive`, etc. |
|
||||
| **Cursor** | `.cursor/skills/` | `/opsx:*` commands via prompts |
|
||||
|
||||
Kilo Code discovers team workflows automatically. Save the generated files under `.kilocode/workflows/` and trigger them from the command palette with `/openspec-proposal.md`, `/openspec-apply.md`, or `/openspec-archive.md`.
|
||||
Run `openspec init` and select the tools you use. Skills and commands are generated automatically.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>AGENTS.md Compatible</strong> (click to expand)</summary>
|
||||
|
||||
These tools automatically read workflow instructions from `openspec/AGENTS.md`. Ask them to follow the OpenSpec workflow if they need a reminder. Learn more about the [AGENTS.md convention](https://agents.md/).
|
||||
Tools that support AGENTS.md can follow OpenSpec workflows by reading `openspec/AGENTS.md`. Ask them to follow the OpenSpec workflow if they need a reminder. Learn more about the [AGENTS.md convention](https://agents.md/).
|
||||
|
||||
| Tools |
|
||||
|-------|
|
||||
@@ -197,102 +180,139 @@ openspec init
|
||||
```
|
||||
|
||||
**What happens during initialization:**
|
||||
- You'll be prompted to pick any natively supported AI tools (Claude Code, CodeBuddy, Cursor, OpenCode, Qoder,etc.); other assistants always rely on the shared `AGENTS.md` stub
|
||||
- OpenSpec automatically configures slash commands for the tools you choose and always writes a managed `AGENTS.md` hand-off at the project root
|
||||
- A new `openspec/` directory structure is created in your project
|
||||
- You'll see an interactive tool selector to pick AI tools (Claude Code, Cursor, etc.)
|
||||
- OpenSpec generates **Agent Skills** in tool-specific directories (e.g., `.claude/skills/`)
|
||||
- **/opsx:\* slash commands** are created for each selected tool
|
||||
- A `openspec/config.yaml` file is created for project configuration
|
||||
- The `openspec/` directory structure is created (specs, changes, archive)
|
||||
|
||||
**After setup:**
|
||||
- Primary AI tools can trigger `/openspec` workflows without additional configuration
|
||||
- Run `openspec list` to verify the setup and view any active changes
|
||||
- If your coding assistant doesn't surface the new slash commands right away, restart it. Slash commands are loaded at startup,
|
||||
so a fresh launch ensures they appear
|
||||
**Legacy upgrade:** If you have files from an older OpenSpec version, init will detect them and offer to clean up automatically. Use `--force` to skip the confirmation prompt.
|
||||
|
||||
### Optional: Populate Project Context
|
||||
|
||||
After `openspec init` completes, you'll receive a suggested prompt to help populate your project context:
|
||||
|
||||
```text
|
||||
Populate your project context:
|
||||
"Please read openspec/project.md and help me fill it out with details about my project, tech stack, and conventions"
|
||||
**Non-interactive mode:** For CI or scripted setups:
|
||||
```bash
|
||||
openspec init --tools claude,cursor # Specific tools
|
||||
openspec init --tools all # All supported tools
|
||||
openspec init --tools none # Skip tool setup
|
||||
```
|
||||
|
||||
Use `openspec/project.md` to define project-level conventions, standards, architectural patterns, and other guidelines that should be followed across all changes.
|
||||
**After setup:**
|
||||
- Run `/opsx:new` to start your first change
|
||||
- Run `openspec list` to verify the setup and view any active changes
|
||||
- Restart your IDE for slash commands to take effect
|
||||
|
||||
### Optional: Configure Project Context
|
||||
|
||||
After `openspec init`, you can customize `openspec/config.yaml` to inject project-specific context into all artifacts:
|
||||
|
||||
```yaml
|
||||
# openspec/config.yaml
|
||||
schema: spec-driven
|
||||
|
||||
context: |
|
||||
Tech stack: TypeScript, React, Node.js
|
||||
Testing: Vitest for unit tests
|
||||
Style: ESLint with Prettier
|
||||
|
||||
rules:
|
||||
proposal:
|
||||
- Include rollback plan
|
||||
specs:
|
||||
- Use Given/When/Then format for scenarios
|
||||
```
|
||||
|
||||
This context is automatically included in artifact instructions, helping the AI understand your project's conventions.
|
||||
|
||||
### Create Your First Change
|
||||
|
||||
Here's a real example showing the complete OpenSpec workflow. This works with any AI tool. Those with native slash commands will recognize the shortcuts automatically.
|
||||
|
||||
#### 1. Draft the Proposal
|
||||
Start by asking your AI to create a change proposal:
|
||||
Here's a real example showing the complete OpenSpec workflow using `/opsx:*` commands.
|
||||
|
||||
#### 1. Start a New Change
|
||||
```text
|
||||
You: Create an OpenSpec change proposal for adding profile search filters by role and team
|
||||
(Shortcut for tools with slash commands: /openspec:proposal Add profile search filters)
|
||||
You: /opsx:new
|
||||
|
||||
AI: I'll create an OpenSpec change proposal for profile filters.
|
||||
*Scaffolds openspec/changes/add-profile-filters/ with proposal.md, tasks.md, spec deltas.*
|
||||
AI: What do you want to build?
|
||||
You: Add profile search filters by role and team
|
||||
|
||||
AI: I'll create a new change for profile filters.
|
||||
*Creates openspec/changes/add-profile-filters/ with proposal.md*
|
||||
```
|
||||
|
||||
#### 2. Verify & Review
|
||||
Check that the change was created correctly and review the proposal:
|
||||
#### 2. Build Out Artifacts
|
||||
Use `/opsx:continue` to create artifacts one at a time, or `/opsx:ff` to fast-forward through all planning artifacts:
|
||||
|
||||
```text
|
||||
You: /opsx:continue
|
||||
|
||||
AI: Proposal is done. Ready to create: specs, design
|
||||
Which artifact should I create?
|
||||
You: specs
|
||||
|
||||
AI: *Creates specs with requirements and scenarios*
|
||||
```
|
||||
|
||||
Or fast-forward through everything:
|
||||
```text
|
||||
You: /opsx:ff add-profile-filters
|
||||
|
||||
AI: *Creates proposal → specs → design → tasks in sequence*
|
||||
```
|
||||
|
||||
#### 3. Verify & Review
|
||||
```bash
|
||||
$ openspec list # Confirm the change folder exists
|
||||
$ openspec validate add-profile-filters # Validate spec formatting
|
||||
$ openspec show add-profile-filters # Review proposal, tasks, and spec delta
|
||||
```
|
||||
|
||||
#### 3. Refine the Specs
|
||||
Iterate on the specifications until they match your needs:
|
||||
|
||||
```text
|
||||
You: Can you add acceptance criteria for the role and team filters?
|
||||
|
||||
AI: I'll update the spec delta with scenarios for role and team filters.
|
||||
*Edits openspec/changes/add-profile-filters/specs/profile/spec.md and tasks.md.*
|
||||
openspec list # Confirm the change folder exists
|
||||
openspec validate add-profile-filters # Validate spec formatting
|
||||
openspec show add-profile-filters # Review proposal, tasks, and spec delta
|
||||
```
|
||||
|
||||
#### 4. Implement the Change
|
||||
Once specs look good, start implementation:
|
||||
|
||||
```text
|
||||
You: The specs look good. Let's implement this change.
|
||||
(Shortcut for tools with slash commands: /openspec:apply add-profile-filters)
|
||||
You: /opsx:apply
|
||||
|
||||
AI: I'll work through the tasks in the add-profile-filters change.
|
||||
*Implements tasks from openspec/changes/add-profile-filters/tasks.md*
|
||||
*Marks tasks complete: Task 1.1 ✓, Task 1.2 ✓, Task 2.1 ✓...*
|
||||
*Implements tasks, marking them complete as it goes*
|
||||
```
|
||||
|
||||
**Key feature:** If you discover issues during implementation, you can update specs, design, or tasks — then continue. No phase gates.
|
||||
|
||||
#### 5. Archive the Completed Change
|
||||
After implementation is complete, archive the change:
|
||||
|
||||
```text
|
||||
AI: All tasks are complete. The implementation is ready.
|
||||
|
||||
You: Please archive the change
|
||||
(Shortcut for tools with slash commands: /openspec:archive add-profile-filters)
|
||||
You: /opsx:archive
|
||||
|
||||
AI: I'll archive the add-profile-filters change.
|
||||
*Runs: openspec archive add-profile-filters --yes*
|
||||
✓ Change archived successfully. Specs updated. Ready for the next feature!
|
||||
*Runs: openspec archive add-profile-filters --yes*
|
||||
✓ Change archived. Specs updated. Ready for the next feature!
|
||||
```
|
||||
|
||||
Or run the command yourself in terminal:
|
||||
Or run directly in terminal:
|
||||
```bash
|
||||
$ openspec archive add-profile-filters --yes # Archive the completed change without prompts
|
||||
openspec archive add-profile-filters --yes
|
||||
```
|
||||
|
||||
**Note:** Tools with native slash commands (Claude Code, CodeBuddy, Cursor, Codex, Qoder, RooCode) can use the shortcuts shown. All other tools work with natural language requests to "create an OpenSpec proposal", "apply the OpenSpec change", or "archive the change".
|
||||
|
||||
## Command Reference
|
||||
|
||||
### Slash Commands (in your AI tool)
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `/opsx:explore` | Think through ideas, investigate problems, clarify requirements |
|
||||
| `/opsx:new` | Start a new change |
|
||||
| `/opsx:continue` | Create the next artifact (based on what's ready) |
|
||||
| `/opsx:ff` | Fast-forward — create all planning artifacts at once |
|
||||
| `/opsx:apply` | Implement tasks, updating artifacts as needed |
|
||||
| `/opsx:sync` | Sync delta specs to main specs |
|
||||
| `/opsx:archive` | Archive when done |
|
||||
| `/opsx:verify` | Verify implementation matches change artifacts |
|
||||
|
||||
### CLI Commands (in terminal)
|
||||
|
||||
```bash
|
||||
openspec init # Initialize OpenSpec with skills and commands
|
||||
openspec list # View active change folders
|
||||
openspec view # Interactive dashboard of specs and changes
|
||||
openspec show <change> # Display change details (proposal, tasks, spec updates)
|
||||
openspec validate <change> # Check spec formatting and structure
|
||||
openspec archive <change> [--yes|-y] # Move a completed change into archive/ (non-interactive with --yes)
|
||||
openspec archive <change> [--yes|-y] # Move a completed change into archive/
|
||||
openspec update # Refresh skills and commands for configured tools
|
||||
```
|
||||
|
||||
## Example: How AI Creates OpenSpec Files
|
||||
@@ -392,12 +412,12 @@ Without specs, AI coding assistants generate code from vague prompts, often miss
|
||||
|
||||
## Team Adoption
|
||||
|
||||
1. **Initialize OpenSpec** – Run `openspec init` in your repo.
|
||||
2. **Start with new features** – Ask your AI to capture upcoming work as change proposals.
|
||||
1. **Initialize OpenSpec** – Run `openspec init` in your repo and select your team's tools.
|
||||
2. **Start with new features** – Use `/opsx:new` to capture upcoming work as change proposals.
|
||||
3. **Grow incrementally** – Each change archives into living specs that document your system.
|
||||
4. **Stay flexible** – Different teammates can use Claude Code, CodeBuddy, Cursor, or any AGENTS.md-compatible tool while sharing the same specs.
|
||||
4. **Stay flexible** – Different teammates can use Claude Code, Cursor, or any AGENTS.md-compatible tool while sharing the same specs.
|
||||
|
||||
Run `openspec update` whenever someone switches tools so your agents pick up the latest instructions and slash-command bindings.
|
||||
Run `openspec update` to refresh skills and commands when upgrading OpenSpec or adding new tools.
|
||||
|
||||
## Updating OpenSpec
|
||||
|
||||
@@ -405,42 +425,38 @@ Run `openspec update` whenever someone switches tools so your agents pick up the
|
||||
```bash
|
||||
npm install -g @fission-ai/openspec@latest
|
||||
```
|
||||
2. **Refresh agent instructions**
|
||||
- Run `openspec update` inside each project to regenerate AI guidance and ensure the latest slash commands are active.
|
||||
2. **Refresh skills and commands**
|
||||
```bash
|
||||
openspec update
|
||||
```
|
||||
This regenerates skills and slash commands for all configured tools.
|
||||
|
||||
## Experimental Features
|
||||
3. **Restart your IDE** for slash commands to take effect.
|
||||
|
||||
## Workflow Customization
|
||||
|
||||
<details>
|
||||
<summary><strong>🧪 OPSX: Fluid, Iterative Workflow</strong> (Claude Code only)</summary>
|
||||
<summary><strong>Custom Schemas & Templates</strong></summary>
|
||||
|
||||
**Why this exists:**
|
||||
- Standard workflow is locked down — you can't tweak instructions or customize
|
||||
- When AI output is bad, you can't improve the prompts yourself
|
||||
- Same workflow for everyone, no way to match how your team works
|
||||
OpenSpec uses a **schema-driven workflow** that you can customize:
|
||||
|
||||
**What's different:**
|
||||
**Why customize:**
|
||||
- **Hackable** — edit templates and schemas yourself, test immediately, no rebuild
|
||||
- **Granular** — each artifact has its own instructions, test and tweak individually
|
||||
- **Customizable** — define your own workflows, artifacts, and dependencies
|
||||
- **Fluid** — no phase gates, update any artifact anytime
|
||||
|
||||
```
|
||||
You can always go back:
|
||||
**Built-in schemas:**
|
||||
- `spec-driven` (default): proposal → specs → design → tasks
|
||||
- `tdd`: tests → implementation → docs
|
||||
|
||||
proposal ──→ specs ──→ design ──→ tasks ──→ implement
|
||||
▲ ▲ ▲ │
|
||||
└───────────┴──────────┴────────────────────┘
|
||||
**Create custom schemas:**
|
||||
```bash
|
||||
openspec schema init my-workflow # Create new schema interactively
|
||||
openspec schema fork spec-driven my-workflow # Fork existing schema
|
||||
openspec schemas # List available schemas
|
||||
```
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `/opsx:new` | Start a new change |
|
||||
| `/opsx:continue` | Create the next artifact (based on what's ready) |
|
||||
| `/opsx:ff` | Fast-forward (all planning artifacts at once) |
|
||||
| `/opsx:apply` | Implement tasks, updating artifacts as needed |
|
||||
| `/opsx:archive` | Archive when done |
|
||||
|
||||
**Setup:** `openspec artifact-experimental-setup`
|
||||
Schemas are stored in `openspec/schemas/` (project) or `~/.local/share/openspec/schemas/` (global).
|
||||
|
||||
[Full documentation →](docs/experimental-workflow.md)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Experimental Workflow (OPSX)
|
||||
|
||||
> **Status:** Experimental. Things might break. Feedback welcome on [Discord](https://discord.gg/BYjPaKbqMt).
|
||||
> **Status:** Experimental. Things might break. Feedback welcome on [Discord](https://discord.gg/YctCnvvshC).
|
||||
>
|
||||
> **Compatibility:** Claude Code only (for now)
|
||||
|
||||
@@ -73,7 +73,7 @@ You can always go back:
|
||||
openspec init
|
||||
|
||||
# 2. Generate the experimental skills
|
||||
openspec artifact-experimental-setup
|
||||
openspec experimental
|
||||
```
|
||||
|
||||
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 `artifact-experimental-setup`, or manually:
|
||||
Config is created during `experimental`, 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/BYjPaKbqMt) 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/YctCnvvshC) or open an issue on [GitHub](https://github.com/Fission-AI/openspec/issues).
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
{
|
||||
default = pkgs.stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "openspec";
|
||||
version = "0.20.0";
|
||||
version = "0.23.0";
|
||||
|
||||
src = ./.;
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
inherit (finalAttrs) pname version src;
|
||||
pnpm = pkgs.pnpm_9;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-m/7IdY1ou9ljjYAcx3W8AyEJvIZfCBWIWxproQ/INPA=";
|
||||
hash = "sha256-9s2kdvd7svK4hofnD66HkDc86WTQeayfF5y7L2dmjNg=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
|
||||
@@ -1,456 +0,0 @@
|
||||
# OpenSpec Instructions
|
||||
|
||||
Instructions for AI coding assistants using OpenSpec for spec-driven development.
|
||||
|
||||
## TL;DR Quick Checklist
|
||||
|
||||
- Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search)
|
||||
- Decide scope: new capability vs modify existing capability
|
||||
- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`)
|
||||
- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability
|
||||
- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement
|
||||
- Validate: `openspec validate [change-id] --strict --no-interactive` and fix issues
|
||||
- Request approval: Do not start implementation until proposal is approved
|
||||
|
||||
## Three-Stage Workflow
|
||||
|
||||
### Stage 1: Creating Changes
|
||||
Create proposal when you need to:
|
||||
- Add features or functionality
|
||||
- Make breaking changes (API, schema)
|
||||
- Change architecture or patterns
|
||||
- Optimize performance (changes behavior)
|
||||
- Update security patterns
|
||||
|
||||
Triggers (examples):
|
||||
- "Help me create a change proposal"
|
||||
- "Help me plan a change"
|
||||
- "Help me create a proposal"
|
||||
- "I want to create a spec proposal"
|
||||
- "I want to create a spec"
|
||||
|
||||
Loose matching guidance:
|
||||
- Contains one of: `proposal`, `change`, `spec`
|
||||
- With one of: `create`, `plan`, `make`, `start`, `help`
|
||||
|
||||
Skip proposal for:
|
||||
- Bug fixes (restore intended behavior)
|
||||
- Typos, formatting, comments
|
||||
- Dependency updates (non-breaking)
|
||||
- Configuration changes
|
||||
- Tests for existing behavior
|
||||
|
||||
**Workflow**
|
||||
1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context.
|
||||
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes/<id>/`.
|
||||
3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement.
|
||||
4. Run `openspec validate <id> --strict --no-interactive` and resolve any issues before sharing the proposal.
|
||||
|
||||
### Stage 2: Implementing Changes
|
||||
Track these steps as TODOs and complete them one by one.
|
||||
1. **Read proposal.md** - Understand what's being built
|
||||
2. **Read design.md** (if exists) - Review technical decisions
|
||||
3. **Read tasks.md** - Get implementation checklist
|
||||
4. **Implement tasks sequentially** - Complete in order
|
||||
5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses
|
||||
6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality
|
||||
7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
|
||||
|
||||
### Stage 3: Archiving Changes
|
||||
After deployment, create separate PR to:
|
||||
- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/`
|
||||
- Update `specs/` if capabilities changed
|
||||
- Use `openspec archive <change-id> --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly)
|
||||
- Run `openspec validate --strict --no-interactive` to confirm the archived change passes checks
|
||||
|
||||
## Before Any Task
|
||||
|
||||
**Context Checklist:**
|
||||
- [ ] Read relevant specs in `specs/[capability]/spec.md`
|
||||
- [ ] Check pending changes in `changes/` for conflicts
|
||||
- [ ] Read `openspec/project.md` for conventions
|
||||
- [ ] Run `openspec list` to see active changes
|
||||
- [ ] Run `openspec list --specs` to see existing capabilities
|
||||
|
||||
**Before Creating Specs:**
|
||||
- Always check if capability already exists
|
||||
- Prefer modifying existing specs over creating duplicates
|
||||
- Use `openspec show [spec]` to review current state
|
||||
- If request is ambiguous, ask 1–2 clarifying questions before scaffolding
|
||||
|
||||
### Search Guidance
|
||||
- Enumerate specs: `openspec spec list --long` (or `--json` for scripts)
|
||||
- Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available)
|
||||
- Show details:
|
||||
- Spec: `openspec show <spec-id> --type spec` (use `--json` for filters)
|
||||
- Change: `openspec show <change-id> --json --deltas-only`
|
||||
- Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs`
|
||||
|
||||
## Quick Start
|
||||
|
||||
### CLI Commands
|
||||
|
||||
```bash
|
||||
# Essential commands
|
||||
openspec list # List active changes
|
||||
openspec list --specs # List specifications
|
||||
openspec show [item] # Display change or spec
|
||||
openspec validate [item] # Validate changes or specs
|
||||
openspec archive <change-id> [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
|
||||
|
||||
# Project management
|
||||
openspec init [path] # Initialize OpenSpec
|
||||
openspec update [path] # Update instruction files
|
||||
|
||||
# Interactive mode
|
||||
openspec show # Prompts for selection
|
||||
openspec validate # Bulk validation mode
|
||||
|
||||
# Debugging
|
||||
openspec show [change] --json --deltas-only
|
||||
openspec validate [change] --strict --no-interactive
|
||||
```
|
||||
|
||||
### Command Flags
|
||||
|
||||
- `--json` - Machine-readable output
|
||||
- `--type change|spec` - Disambiguate items
|
||||
- `--strict` - Comprehensive validation
|
||||
- `--no-interactive` - Disable prompts
|
||||
- `--skip-specs` - Archive without spec updates
|
||||
- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive)
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
openspec/
|
||||
├── project.md # Project conventions
|
||||
├── specs/ # Current truth - what IS built
|
||||
│ └── [capability]/ # Single focused capability
|
||||
│ ├── spec.md # Requirements and scenarios
|
||||
│ └── design.md # Technical patterns
|
||||
├── changes/ # Proposals - what SHOULD change
|
||||
│ ├── [change-name]/
|
||||
│ │ ├── proposal.md # Why, what, impact
|
||||
│ │ ├── tasks.md # Implementation checklist
|
||||
│ │ ├── design.md # Technical decisions (optional; see criteria)
|
||||
│ │ └── specs/ # Delta changes
|
||||
│ │ └── [capability]/
|
||||
│ │ └── spec.md # ADDED/MODIFIED/REMOVED
|
||||
│ └── archive/ # Completed changes
|
||||
```
|
||||
|
||||
## Creating Change Proposals
|
||||
|
||||
### Decision Tree
|
||||
|
||||
```
|
||||
New request?
|
||||
├─ Bug fix restoring spec behavior? → Fix directly
|
||||
├─ Typo/format/comment? → Fix directly
|
||||
├─ New feature/capability? → Create proposal
|
||||
├─ Breaking change? → Create proposal
|
||||
├─ Architecture change? → Create proposal
|
||||
└─ Unclear? → Create proposal (safer)
|
||||
```
|
||||
|
||||
### Proposal Structure
|
||||
|
||||
1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique)
|
||||
|
||||
2. **Write proposal.md:**
|
||||
```markdown
|
||||
# Change: [Brief description of change]
|
||||
|
||||
## Why
|
||||
[1-2 sentences on problem/opportunity]
|
||||
|
||||
## What Changes
|
||||
- [Bullet list of changes]
|
||||
- [Mark breaking changes with **BREAKING**]
|
||||
|
||||
## Impact
|
||||
- Affected specs: [list capabilities]
|
||||
- Affected code: [key files/systems]
|
||||
```
|
||||
|
||||
3. **Create spec deltas:** `specs/[capability]/spec.md`
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
### Requirement: New Feature
|
||||
The system SHALL provide...
|
||||
|
||||
#### Scenario: Success case
|
||||
- **WHEN** user performs action
|
||||
- **THEN** expected result
|
||||
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Existing Feature
|
||||
[Complete modified requirement]
|
||||
|
||||
## REMOVED Requirements
|
||||
### Requirement: Old Feature
|
||||
**Reason**: [Why removing]
|
||||
**Migration**: [How to handle]
|
||||
```
|
||||
If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs/<capability>/spec.md`—one per capability.
|
||||
|
||||
4. **Create tasks.md:**
|
||||
```markdown
|
||||
## 1. Implementation
|
||||
- [ ] 1.1 Create database schema
|
||||
- [ ] 1.2 Implement API endpoint
|
||||
- [ ] 1.3 Add frontend component
|
||||
- [ ] 1.4 Write tests
|
||||
```
|
||||
|
||||
5. **Create design.md when needed:**
|
||||
Create `design.md` if any of the following apply; otherwise omit it:
|
||||
- Cross-cutting change (multiple services/modules) or a new architectural pattern
|
||||
- New external dependency or significant data model changes
|
||||
- Security, performance, or migration complexity
|
||||
- Ambiguity that benefits from technical decisions before coding
|
||||
|
||||
Minimal `design.md` skeleton:
|
||||
```markdown
|
||||
## Context
|
||||
[Background, constraints, stakeholders]
|
||||
|
||||
## Goals / Non-Goals
|
||||
- Goals: [...]
|
||||
- Non-Goals: [...]
|
||||
|
||||
## Decisions
|
||||
- Decision: [What and why]
|
||||
- Alternatives considered: [Options + rationale]
|
||||
|
||||
## Risks / Trade-offs
|
||||
- [Risk] → Mitigation
|
||||
|
||||
## Migration Plan
|
||||
[Steps, rollback]
|
||||
|
||||
## Open Questions
|
||||
- [...]
|
||||
```
|
||||
|
||||
## Spec File Format
|
||||
|
||||
### Critical: Scenario Formatting
|
||||
|
||||
**CORRECT** (use #### headers):
|
||||
```markdown
|
||||
#### Scenario: User login success
|
||||
- **WHEN** valid credentials provided
|
||||
- **THEN** return JWT token
|
||||
```
|
||||
|
||||
**WRONG** (don't use bullets or bold):
|
||||
```markdown
|
||||
- **Scenario: User login** ❌
|
||||
**Scenario**: User login ❌
|
||||
### Scenario: User login ❌
|
||||
```
|
||||
|
||||
Every requirement MUST have at least one scenario.
|
||||
|
||||
### Requirement Wording
|
||||
- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative)
|
||||
|
||||
### Delta Operations
|
||||
|
||||
- `## ADDED Requirements` - New capabilities
|
||||
- `## MODIFIED Requirements` - Changed behavior
|
||||
- `## REMOVED Requirements` - Deprecated features
|
||||
- `## RENAMED Requirements` - Name changes
|
||||
|
||||
Headers matched with `trim(header)` - whitespace ignored.
|
||||
|
||||
#### When to use ADDED vs MODIFIED
|
||||
- ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement.
|
||||
- MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details.
|
||||
- RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name.
|
||||
|
||||
Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you aren’t explicitly changing the existing requirement, add a new requirement under ADDED instead.
|
||||
|
||||
Authoring a MODIFIED requirement correctly:
|
||||
1) Locate the existing requirement in `openspec/specs/<capability>/spec.md`.
|
||||
2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios).
|
||||
3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior.
|
||||
4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`.
|
||||
|
||||
Example for RENAMED:
|
||||
```markdown
|
||||
## RENAMED Requirements
|
||||
- FROM: `### Requirement: Login`
|
||||
- TO: `### Requirement: User Authentication`
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Errors
|
||||
|
||||
**"Change must have at least one delta"**
|
||||
- Check `changes/[name]/specs/` exists with .md files
|
||||
- Verify files have operation prefixes (## ADDED Requirements)
|
||||
|
||||
**"Requirement must have at least one scenario"**
|
||||
- Check scenarios use `#### Scenario:` format (4 hashtags)
|
||||
- Don't use bullet points or bold for scenario headers
|
||||
|
||||
**Silent scenario parsing failures**
|
||||
- Exact format required: `#### Scenario: Name`
|
||||
- Debug with: `openspec show [change] --json --deltas-only`
|
||||
|
||||
### Validation Tips
|
||||
|
||||
```bash
|
||||
# Always use strict mode for comprehensive checks
|
||||
openspec validate [change] --strict --no-interactive
|
||||
|
||||
# Debug delta parsing
|
||||
openspec show [change] --json | jq '.deltas'
|
||||
|
||||
# Check specific requirement
|
||||
openspec show [spec] --json -r 1
|
||||
```
|
||||
|
||||
## Happy Path Script
|
||||
|
||||
```bash
|
||||
# 1) Explore current state
|
||||
openspec spec list --long
|
||||
openspec list
|
||||
# Optional full-text search:
|
||||
# rg -n "Requirement:|Scenario:" openspec/specs
|
||||
# rg -n "^#|Requirement:" openspec/changes
|
||||
|
||||
# 2) Choose change id and scaffold
|
||||
CHANGE=add-two-factor-auth
|
||||
mkdir -p openspec/changes/$CHANGE/{specs/auth}
|
||||
printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md
|
||||
printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md
|
||||
|
||||
# 3) Add deltas (example)
|
||||
cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF'
|
||||
## ADDED Requirements
|
||||
### Requirement: Two-Factor Authentication
|
||||
Users MUST provide a second factor during login.
|
||||
|
||||
#### Scenario: OTP required
|
||||
- **WHEN** valid credentials are provided
|
||||
- **THEN** an OTP challenge is required
|
||||
EOF
|
||||
|
||||
# 4) Validate
|
||||
openspec validate $CHANGE --strict --no-interactive
|
||||
```
|
||||
|
||||
## Multi-Capability Example
|
||||
|
||||
```
|
||||
openspec/changes/add-2fa-notify/
|
||||
├── proposal.md
|
||||
├── tasks.md
|
||||
└── specs/
|
||||
├── auth/
|
||||
│ └── spec.md # ADDED: Two-Factor Authentication
|
||||
└── notifications/
|
||||
└── spec.md # ADDED: OTP email notification
|
||||
```
|
||||
|
||||
auth/spec.md
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
### Requirement: Two-Factor Authentication
|
||||
...
|
||||
```
|
||||
|
||||
notifications/spec.md
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
### Requirement: OTP Email Notification
|
||||
...
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Simplicity First
|
||||
- Default to <100 lines of new code
|
||||
- Single-file implementations until proven insufficient
|
||||
- Avoid frameworks without clear justification
|
||||
- Choose boring, proven patterns
|
||||
|
||||
### Complexity Triggers
|
||||
Only add complexity with:
|
||||
- Performance data showing current solution too slow
|
||||
- Concrete scale requirements (>1000 users, >100MB data)
|
||||
- Multiple proven use cases requiring abstraction
|
||||
|
||||
### Clear References
|
||||
- Use `file.ts:42` format for code locations
|
||||
- Reference specs as `specs/auth/spec.md`
|
||||
- Link related changes and PRs
|
||||
|
||||
### Capability Naming
|
||||
- Use verb-noun: `user-auth`, `payment-capture`
|
||||
- Single purpose per capability
|
||||
- 10-minute understandability rule
|
||||
- Split if description needs "AND"
|
||||
|
||||
### Change ID Naming
|
||||
- Use kebab-case, short and descriptive: `add-two-factor-auth`
|
||||
- Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-`
|
||||
- Ensure uniqueness; if taken, append `-2`, `-3`, etc.
|
||||
|
||||
## Tool Selection Guide
|
||||
|
||||
| Task | Tool | Why |
|
||||
|------|------|-----|
|
||||
| Find files by pattern | Glob | Fast pattern matching |
|
||||
| Search code content | Grep | Optimized regex search |
|
||||
| Read specific files | Read | Direct file access |
|
||||
| Explore unknown scope | Task | Multi-step investigation |
|
||||
|
||||
## Error Recovery
|
||||
|
||||
### Change Conflicts
|
||||
1. Run `openspec list` to see active changes
|
||||
2. Check for overlapping specs
|
||||
3. Coordinate with change owners
|
||||
4. Consider combining proposals
|
||||
|
||||
### Validation Failures
|
||||
1. Run with `--strict` flag
|
||||
2. Check JSON output for details
|
||||
3. Verify spec file format
|
||||
4. Ensure scenarios properly formatted
|
||||
|
||||
### Missing Context
|
||||
1. Read project.md first
|
||||
2. Check related specs
|
||||
3. Review recent archives
|
||||
4. Ask for clarification
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Stage Indicators
|
||||
- `changes/` - Proposed, not yet built
|
||||
- `specs/` - Built and deployed
|
||||
- `archive/` - Completed changes
|
||||
|
||||
### File Purposes
|
||||
- `proposal.md` - Why and what
|
||||
- `tasks.md` - Implementation steps
|
||||
- `design.md` - Technical decisions
|
||||
- `spec.md` - Requirements and behavior
|
||||
|
||||
### CLI Essentials
|
||||
```bash
|
||||
openspec list # What's in progress?
|
||||
openspec show [item] # View details
|
||||
openspec validate --strict --no-interactive # Is it correct?
|
||||
openspec archive <change-id> [--yes|-y] # Mark complete (add --yes for automation)
|
||||
```
|
||||
|
||||
Remember: Specs are truth. Changes are proposals. Keep them in sync.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-01-23
|
||||
@@ -0,0 +1,193 @@
|
||||
## 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.
|
||||
@@ -0,0 +1,32 @@
|
||||
## 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
|
||||
@@ -0,0 +1,176 @@
|
||||
## 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.
|
||||
@@ -0,0 +1,158 @@
|
||||
## 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
|
||||
@@ -0,0 +1,67 @@
|
||||
## 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)
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-01-22
|
||||
@@ -0,0 +1,144 @@
|
||||
## 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
|
||||
@@ -0,0 +1,36 @@
|
||||
## 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
|
||||
@@ -0,0 +1,63 @@
|
||||
# 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
|
||||
@@ -0,0 +1,60 @@
|
||||
# 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>"
|
||||
@@ -0,0 +1,98 @@
|
||||
# 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
|
||||
@@ -0,0 +1,55 @@
|
||||
## 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
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-01-20
|
||||
@@ -0,0 +1,28 @@
|
||||
## 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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fission-ai/openspec",
|
||||
"version": "0.22.0",
|
||||
"version": "0.23.0",
|
||||
"description": "AI-native system for spec-driven development",
|
||||
"keywords": [
|
||||
"openspec",
|
||||
|
||||
Generated
+14
-14
@@ -463,8 +463,8 @@ packages:
|
||||
'@types/node':
|
||||
optional: true
|
||||
|
||||
'@inquirer/type@3.0.8':
|
||||
resolution: {integrity: sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==}
|
||||
'@inquirer/type@3.0.10':
|
||||
resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==}
|
||||
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.8(@types/node@24.2.0)
|
||||
'@inquirer/type': 3.0.10(@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.8(@types/node@24.2.0)
|
||||
'@inquirer/type': 3.0.10(@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.8(@types/node@24.2.0)
|
||||
'@inquirer/type': 3.0.10(@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.8(@types/node@24.2.0)
|
||||
'@inquirer/type': 3.0.10(@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.8(@types/node@24.2.0)
|
||||
'@inquirer/type': 3.0.10(@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.8(@types/node@24.2.0)
|
||||
'@inquirer/type': 3.0.10(@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.8(@types/node@24.2.0)
|
||||
'@inquirer/type': 3.0.10(@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.8(@types/node@24.2.0)
|
||||
'@inquirer/type': 3.0.10(@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.8(@types/node@24.2.0)
|
||||
'@inquirer/type': 3.0.10(@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.8(@types/node@24.2.0)
|
||||
'@inquirer/type': 3.0.10(@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.8(@types/node@24.2.0)
|
||||
'@inquirer/type': 3.0.10(@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.8(@types/node@24.2.0)':
|
||||
'@inquirer/type@3.0.10(@types/node@24.2.0)':
|
||||
optionalDependencies:
|
||||
'@types/node': 24.2.0
|
||||
|
||||
|
||||
@@ -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
|
||||
CORRECT_HASH=$(echo "$BUILD_OUTPUT" | grep -oP 'got:\s+\Ksha256-[A-Za-z0-9+/=]+' | head -1)
|
||||
# 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:]]*//')
|
||||
|
||||
if [ -z "$CORRECT_HASH" ]; then
|
||||
echo "❌ Error: Could not extract hash from build output"
|
||||
|
||||
+136
-9
@@ -15,8 +15,21 @@ import { ShowCommand } from '../commands/show.js';
|
||||
import { CompletionCommand } from '../commands/completion.js';
|
||||
import { FeedbackCommand } from '../commands/feedback.js';
|
||||
import { registerConfigCommand } from '../commands/config.js';
|
||||
import { registerArtifactWorkflowCommands } from '../commands/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();
|
||||
@@ -74,18 +87,19 @@ program.hook('postAction', async () => {
|
||||
await shutdown();
|
||||
});
|
||||
|
||||
const availableToolIds = AI_TOOLS.filter((tool) => tool.available).map((tool) => tool.value);
|
||||
const availableToolIds = AI_TOOLS.filter((tool) => tool.skillsDir).map((tool) => tool.value);
|
||||
const toolsOptionDescription = `Configure AI tools non-interactively. Use "all", "none", or a comma-separated list of: ${availableToolIds.join(', ')}`;
|
||||
|
||||
program
|
||||
.command('init [path]')
|
||||
.description('Initialize OpenSpec in your project')
|
||||
.option('--tools <tools>', toolsOptionDescription)
|
||||
.action(async (targetPath = '.', options?: { tools?: string }) => {
|
||||
.option('--force', 'Auto-cleanup legacy files without prompting')
|
||||
.action(async (targetPath = '.', options?: { tools?: string; force?: boolean }) => {
|
||||
try {
|
||||
// Validate that the path is a valid directory
|
||||
const resolvedPath = path.resolve(targetPath);
|
||||
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(resolvedPath);
|
||||
if (!stats.isDirectory()) {
|
||||
@@ -101,10 +115,11 @@ program
|
||||
throw new Error(`Cannot access path "${targetPath}": ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const { InitCommand } = await import('../core/init.js');
|
||||
const initCommand = new InitCommand({
|
||||
tools: options?.tools,
|
||||
force: options?.force,
|
||||
});
|
||||
await initCommand.execute(targetPath);
|
||||
} catch (error) {
|
||||
@@ -114,13 +129,36 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
// Hidden alias: 'experimental' -> 'init' for backwards compatibility
|
||||
program
|
||||
.command('experimental', { hidden: true })
|
||||
.description('Alias for init (deprecated)')
|
||||
.option('--tool <tool-id>', 'Target AI tool (maps to --tools)')
|
||||
.option('--no-interactive', 'Disable interactive prompts')
|
||||
.action(async (options?: { tool?: string; noInteractive?: boolean }) => {
|
||||
try {
|
||||
console.log('Note: "openspec experimental" is deprecated. Use "openspec init" instead.');
|
||||
const { InitCommand } = await import('../core/init.js');
|
||||
const initCommand = new InitCommand({
|
||||
tools: options?.tool,
|
||||
interactive: options?.noInteractive === true ? false : undefined,
|
||||
});
|
||||
await initCommand.execute('.');
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('update [path]')
|
||||
.description('Update OpenSpec instruction files')
|
||||
.action(async (targetPath = '.') => {
|
||||
.option('--force', 'Force update even when tools are up to date')
|
||||
.action(async (targetPath = '.', options?: { force?: boolean }) => {
|
||||
try {
|
||||
const resolvedPath = path.resolve(targetPath);
|
||||
const updateCommand = new UpdateCommand();
|
||||
const updateCommand = new UpdateCommand({ force: options?.force });
|
||||
await updateCommand.execute(resolvedPath);
|
||||
} catch (error) {
|
||||
console.log(); // Empty line for spacing
|
||||
@@ -375,7 +413,96 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
// Register artifact workflow commands (experimental)
|
||||
registerArtifactWorkflowCommands(program);
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Workflow Commands (formerly experimental)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
// Status command
|
||||
program
|
||||
.command('status')
|
||||
.description('Display artifact completion status for a change')
|
||||
.option('--change <id>', 'Change name to show status for')
|
||||
.option('--schema <name>', 'Schema override (auto-detected from config.yaml)')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (options: StatusOptions) => {
|
||||
try {
|
||||
await statusCommand(options);
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Instructions command
|
||||
program
|
||||
.command('instructions [artifact]')
|
||||
.description('Output enriched instructions for creating an artifact or applying tasks')
|
||||
.option('--change <id>', 'Change name')
|
||||
.option('--schema <name>', 'Schema override (auto-detected from config.yaml)')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (artifactId: string | undefined, options: InstructionsOptions) => {
|
||||
try {
|
||||
// Special case: "apply" is not an artifact, but a command to get apply instructions
|
||||
if (artifactId === 'apply') {
|
||||
await applyInstructionsCommand(options);
|
||||
} else {
|
||||
await instructionsCommand(artifactId, options);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Templates command
|
||||
program
|
||||
.command('templates')
|
||||
.description('Show resolved template paths for all artifacts in a schema')
|
||||
.option('--schema <name>', `Schema to use (default: ${DEFAULT_SCHEMA})`)
|
||||
.option('--json', 'Output as JSON mapping artifact IDs to template paths')
|
||||
.action(async (options: TemplatesOptions) => {
|
||||
try {
|
||||
await templatesCommand(options);
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Schemas command
|
||||
program
|
||||
.command('schemas')
|
||||
.description('List available workflow schemas with descriptions')
|
||||
.option('--json', 'Output as JSON (for agent use)')
|
||||
.action(async (options: SchemasOptions) => {
|
||||
try {
|
||||
await schemasCommand(options);
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// New command group with change subcommand
|
||||
const newCmd = program.command('new').description('Create new items');
|
||||
|
||||
newCmd
|
||||
.command('change <name>')
|
||||
.description('Create a new change directory')
|
||||
.option('--description <text>', 'Description to add to README.md')
|
||||
.option('--schema <name>', `Workflow schema to use (default: ${DEFAULT_SCHEMA})`)
|
||||
.action(async (name: string, options: NewChangeOptions) => {
|
||||
try {
|
||||
await newChangeCommand(name, options);
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program.parse();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Workflow CLI Commands
|
||||
*
|
||||
* Commands for the artifact-driven workflow: status, instructions, templates, schemas, new change.
|
||||
*/
|
||||
|
||||
export { statusCommand } from './status.js';
|
||||
export type { StatusOptions } from './status.js';
|
||||
|
||||
export { instructionsCommand, applyInstructionsCommand } from './instructions.js';
|
||||
export type { InstructionsOptions } from './instructions.js';
|
||||
|
||||
export { templatesCommand } from './templates.js';
|
||||
export type { TemplatesOptions } from './templates.js';
|
||||
|
||||
export { schemasCommand } from './schemas.js';
|
||||
export type { SchemasOptions } from './schemas.js';
|
||||
|
||||
export { newChangeCommand } from './new-change.js';
|
||||
export type { NewChangeOptions } from './new-change.js';
|
||||
|
||||
export { DEFAULT_SCHEMA } from './shared.js';
|
||||
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 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!'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,11 @@ export interface ArtifactInstructions {
|
||||
description: string;
|
||||
/** Guidance on how to create this artifact (from schema instruction field) */
|
||||
instruction: string | undefined;
|
||||
/** Template content (structure to follow) */
|
||||
/** 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: string;
|
||||
/** Dependencies with completion status and paths */
|
||||
dependencies: DependencyInfo[];
|
||||
@@ -218,14 +222,11 @@ 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
|
||||
// Try to read project config for context and rules
|
||||
let projectConfig = null;
|
||||
if (effectiveProjectRoot) {
|
||||
try {
|
||||
projectConfig = readProjectConfig(effectiveProjectRoot);
|
||||
@@ -252,23 +253,10 @@ export function generateInstructions(
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Add context (all artifacts)
|
||||
if (projectConfig?.context) {
|
||||
enrichedTemplate += `<context>\n${projectConfig.context}\n</context>\n\n`;
|
||||
}
|
||||
|
||||
// 2. Add rules (only for matching artifact)
|
||||
// Extract context and rules as separate fields (not prepended to template)
|
||||
const configContext = projectConfig?.context?.trim() || undefined;
|
||||
const rulesForArtifact = projectConfig?.rules?.[artifactId];
|
||||
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;
|
||||
const configRules = rulesForArtifact && rulesForArtifact.length > 0 ? rulesForArtifact : undefined;
|
||||
|
||||
return {
|
||||
changeName: context.changeName,
|
||||
@@ -278,7 +266,9 @@ export function generateInstructions(
|
||||
outputPath: artifact.generates,
|
||||
description: artifact.description,
|
||||
instruction: artifact.instruction,
|
||||
template: enrichedTemplate,
|
||||
context: configContext,
|
||||
rules: configRules,
|
||||
template: templateContent,
|
||||
dependencies,
|
||||
unlocks,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 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}
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 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}
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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}
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 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}
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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}
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 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}
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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}
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 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}
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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}
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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}
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 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}
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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}
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 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}
|
||||
"""
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 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}
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 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}
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 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';
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 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}
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 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}
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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}
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 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}
|
||||
"""
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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}
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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}
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 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';
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
+27
-187
@@ -1,199 +1,39 @@
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import { listSchemasWithInfo, resolveSchema } from './artifact-graph/resolver.js';
|
||||
import type { ProjectConfig } from './project-config.js';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Serialize config to YAML string with helpful comments.
|
||||
*
|
||||
* @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 {
|
||||
// Build clean config object (only include defined fields)
|
||||
const cleanConfig: Record<string, unknown> = {
|
||||
schema: config.schema,
|
||||
};
|
||||
const lines: string[] = [];
|
||||
|
||||
if (config.context) {
|
||||
cleanConfig.context = config.context;
|
||||
}
|
||||
// Schema (required)
|
||||
lines.push(`schema: ${config.schema}`);
|
||||
lines.push('');
|
||||
|
||||
if (config.rules && Object.keys(config.rules).length > 0) {
|
||||
cleanConfig.rules = config.rules;
|
||||
}
|
||||
// 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('');
|
||||
|
||||
// Serialize to YAML with proper formatting
|
||||
return stringifyYaml(cleanConfig, {
|
||||
indent: 2,
|
||||
lineWidth: 0, // Don't wrap long lines
|
||||
defaultStringType: 'PLAIN',
|
||||
defaultKeyType: 'PLAIN',
|
||||
});
|
||||
// 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';
|
||||
}
|
||||
|
||||
+22
-21
@@ -14,29 +14,30 @@ 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' },
|
||||
{ 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: '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: 'AGENTS.md (works with Amp, VS Code, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' }
|
||||
];
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import path from 'path';
|
||||
import { ToolConfigurator } from './base.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { TemplateManager } from '../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../config.js';
|
||||
|
||||
export class AgentsStandardConfigurator implements ToolConfigurator {
|
||||
name = 'AGENTS.md standard';
|
||||
configFileName = 'AGENTS.md';
|
||||
isAvailable = true;
|
||||
|
||||
async configure(projectPath: string, _openspecDir: string): Promise<void> {
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
const content = TemplateManager.getAgentsStandardTemplate();
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export interface ToolConfigurator {
|
||||
name: string;
|
||||
configFileName: string;
|
||||
isAvailable: boolean;
|
||||
configure(projectPath: string, openspecDir: string): Promise<void>;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import path from 'path';
|
||||
import { ToolConfigurator } from './base.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { TemplateManager } from '../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../config.js';
|
||||
|
||||
export class ClaudeConfigurator implements ToolConfigurator {
|
||||
name = 'Claude Code';
|
||||
configFileName = 'CLAUDE.md';
|
||||
isAvailable = true;
|
||||
|
||||
async configure(projectPath: string, openspecDir: string): Promise<void> {
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
const content = TemplateManager.getClaudeTemplate();
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import path from 'path';
|
||||
import { ToolConfigurator } from './base.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { TemplateManager } from '../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../config.js';
|
||||
|
||||
export class ClineConfigurator implements ToolConfigurator {
|
||||
name = 'Cline';
|
||||
configFileName = 'CLINE.md';
|
||||
isAvailable = true;
|
||||
|
||||
async configure(projectPath: string, openspecDir: string): Promise<void> {
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
const content = TemplateManager.getClineTemplate();
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import path from 'path';
|
||||
import { ToolConfigurator } from './base.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { TemplateManager } from '../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../config.js';
|
||||
|
||||
export class CodeBuddyConfigurator implements ToolConfigurator {
|
||||
name = 'CodeBuddy';
|
||||
configFileName = 'CODEBUDDY.md';
|
||||
isAvailable = true;
|
||||
|
||||
async configure(projectPath: string, openspecDir: string): Promise<void> {
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
const content = TemplateManager.getClaudeTemplate();
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import path from 'path';
|
||||
import { ToolConfigurator } from './base.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { TemplateManager } from '../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../config.js';
|
||||
|
||||
export class CostrictConfigurator implements ToolConfigurator {
|
||||
name = 'CoStrict';
|
||||
configFileName = 'COSTRICT.md';
|
||||
isAvailable = true;
|
||||
|
||||
async configure(projectPath: string, openspecDir: string): Promise<void> {
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
const content = TemplateManager.getCostrictTemplate();
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import path from "path";
|
||||
import { ToolConfigurator } from "./base.js";
|
||||
import { FileSystemUtils } from "../../utils/file-system.js";
|
||||
import { TemplateManager } from "../templates/index.js";
|
||||
import { OPENSPEC_MARKERS } from "../config.js";
|
||||
|
||||
export class IflowConfigurator implements ToolConfigurator {
|
||||
name = "iFlow";
|
||||
configFileName = "IFLOW.md";
|
||||
isAvailable = true;
|
||||
|
||||
async configure(projectPath: string, openspecDir: string): Promise<void> {
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
const content = TemplateManager.getClaudeTemplate();
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import path from 'path';
|
||||
import { ToolConfigurator } from './base.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { TemplateManager } from '../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../config.js';
|
||||
|
||||
/**
|
||||
* Qoder AI Tool Configurator
|
||||
*
|
||||
* Configures OpenSpec integration for Qoder AI coding assistant.
|
||||
* Creates and manages QODER.md configuration file with OpenSpec instructions.
|
||||
*
|
||||
* @implements {ToolConfigurator}
|
||||
*/
|
||||
export class QoderConfigurator implements ToolConfigurator {
|
||||
/** Display name for the Qoder tool */
|
||||
name = 'Qoder';
|
||||
|
||||
/** Configuration file name at project root */
|
||||
configFileName = 'QODER.md';
|
||||
|
||||
/** Indicates tool is available for configuration */
|
||||
isAvailable = true;
|
||||
|
||||
/**
|
||||
* Configure Qoder integration for a project
|
||||
*
|
||||
* Creates or updates QODER.md file with OpenSpec instructions.
|
||||
* Uses Claude-compatible template for instruction content.
|
||||
* Wrapped with OpenSpec markers for future updates.
|
||||
*
|
||||
* @param {string} projectPath - Absolute path to project root directory
|
||||
* @param {string} openspecDir - Path to openspec directory (unused but required by interface)
|
||||
* @returns {Promise<void>} Resolves when configuration is complete
|
||||
*/
|
||||
async configure(projectPath: string, openspecDir: string): Promise<void> {
|
||||
// Construct full path to QODER.md at project root
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
|
||||
// Get Claude-compatible instruction template
|
||||
// This ensures Qoder receives the same high-quality OpenSpec instructions
|
||||
const content = TemplateManager.getClaudeTemplate();
|
||||
|
||||
// Write or update file with managed content between markers
|
||||
// This allows future updates to refresh instructions automatically
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* Qwen Code configurator for OpenSpec integration.
|
||||
* This class handles the configuration of Qwen Code as an AI tool within OpenSpec.
|
||||
*
|
||||
* @implements {ToolConfigurator}
|
||||
*/
|
||||
import path from 'path';
|
||||
import { ToolConfigurator } from './base.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { TemplateManager } from '../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../config.js';
|
||||
|
||||
/**
|
||||
* QwenConfigurator class provides integration with Qwen Code
|
||||
* by creating and managing the necessary configuration files.
|
||||
* Currently configures the QWEN.md file with OpenSpec instructions.
|
||||
*/
|
||||
export class QwenConfigurator implements ToolConfigurator {
|
||||
/** Display name for the Qwen Code tool */
|
||||
name = 'Qwen Code';
|
||||
|
||||
/** Configuration file name for Qwen Code */
|
||||
configFileName = 'QWEN.md';
|
||||
|
||||
/** Availability status for the Qwen Code tool */
|
||||
isAvailable = true;
|
||||
|
||||
/**
|
||||
* Configures the Qwen Code integration by creating or updating the QWEN.md file
|
||||
* with OpenSpec instructions and markers.
|
||||
*
|
||||
* @param {string} projectPath - The path to the project root
|
||||
* @param {string} _openspecDir - The path to the openspec directory (unused)
|
||||
* @returns {Promise<void>} A promise that resolves when configuration is complete
|
||||
*/
|
||||
async configure(projectPath: string, _openspecDir: string): Promise<void> {
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
const content = TemplateManager.getAgentsStandardTemplate();
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { ToolConfigurator } from './base.js';
|
||||
import { ClaudeConfigurator } from './claude.js';
|
||||
import { ClineConfigurator } from './cline.js';
|
||||
import { CodeBuddyConfigurator } from './codebuddy.js';
|
||||
import { CostrictConfigurator } from './costrict.js';
|
||||
import { QoderConfigurator } from './qoder.js';
|
||||
import { IflowConfigurator } from './iflow.js';
|
||||
import { AgentsStandardConfigurator } from './agents.js';
|
||||
import { QwenConfigurator } from './qwen.js';
|
||||
|
||||
export class ToolRegistry {
|
||||
private static tools: Map<string, ToolConfigurator> = new Map();
|
||||
|
||||
static {
|
||||
const claudeConfigurator = new ClaudeConfigurator();
|
||||
const clineConfigurator = new ClineConfigurator();
|
||||
const codeBuddyConfigurator = new CodeBuddyConfigurator();
|
||||
const costrictConfigurator = new CostrictConfigurator();
|
||||
const qoderConfigurator = new QoderConfigurator();
|
||||
const iflowConfigurator = new IflowConfigurator();
|
||||
const agentsConfigurator = new AgentsStandardConfigurator();
|
||||
const qwenConfigurator = new QwenConfigurator();
|
||||
// Register with the ID that matches the checkbox value
|
||||
this.tools.set('claude', claudeConfigurator);
|
||||
this.tools.set('cline', clineConfigurator);
|
||||
this.tools.set('codebuddy', codeBuddyConfigurator);
|
||||
this.tools.set('costrict', costrictConfigurator);
|
||||
this.tools.set('qoder', qoderConfigurator);
|
||||
this.tools.set('iflow', iflowConfigurator);
|
||||
this.tools.set('agents', agentsConfigurator);
|
||||
this.tools.set('qwen', qwenConfigurator);
|
||||
}
|
||||
|
||||
static register(tool: ToolConfigurator): void {
|
||||
this.tools.set(tool.name.toLowerCase().replace(/\s+/g, '-'), tool);
|
||||
}
|
||||
|
||||
static get(toolId: string): ToolConfigurator | undefined {
|
||||
return this.tools.get(toolId);
|
||||
}
|
||||
|
||||
static getAll(): ToolConfigurator[] {
|
||||
return Array.from(this.tools.values());
|
||||
}
|
||||
|
||||
static getAvailable(): ToolConfigurator[] {
|
||||
return this.getAll().filter(tool => tool.isAvailable);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.amazonq/prompts/openspec-proposal.md',
|
||||
apply: '.amazonq/prompts/openspec-apply.md',
|
||||
archive: '.amazonq/prompts/openspec-archive.md'
|
||||
};
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
---
|
||||
|
||||
The user has requested the following change proposal. Use the openspec instructions to create their change proposal.
|
||||
|
||||
<UserRequest>
|
||||
$ARGUMENTS
|
||||
</UserRequest>`,
|
||||
apply: `---
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
---
|
||||
|
||||
The user wants to apply the following change. Use the openspec instructions to implement the approved change.
|
||||
|
||||
<ChangeId>
|
||||
$ARGUMENTS
|
||||
</ChangeId>`,
|
||||
archive: `---
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
---
|
||||
|
||||
The user wants to archive the following deployed change. Use the openspec instructions to archive the change and update specs.
|
||||
|
||||
<ChangeId>
|
||||
$ARGUMENTS
|
||||
</ChangeId>`
|
||||
};
|
||||
|
||||
export class AmazonQSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'amazon-q';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.agent/workflows/openspec-proposal.md',
|
||||
apply: '.agent/workflows/openspec-apply.md',
|
||||
archive: '.agent/workflows/openspec-archive.md'
|
||||
};
|
||||
|
||||
const DESCRIPTIONS: Record<SlashCommandId, string> = {
|
||||
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
|
||||
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
|
||||
archive: 'Archive a deployed OpenSpec change and update specs.'
|
||||
};
|
||||
|
||||
export class AntigravitySlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'antigravity';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string | undefined {
|
||||
const description = DESCRIPTIONS[id];
|
||||
return `---\ndescription: ${description}\n---`;
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.augment/commands/openspec-proposal.md',
|
||||
apply: '.augment/commands/openspec-apply.md',
|
||||
archive: '.augment/commands/openspec-archive.md'
|
||||
};
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
argument-hint: feature description or request
|
||||
---`,
|
||||
apply: `---
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
argument-hint: change-id
|
||||
---`,
|
||||
archive: `---
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
argument-hint: change-id
|
||||
---`
|
||||
};
|
||||
|
||||
export class AuggieSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'auggie';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { FileSystemUtils } from '../../../utils/file-system.js';
|
||||
import { TemplateManager, SlashCommandId } from '../../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../../config.js';
|
||||
|
||||
export interface SlashCommandTarget {
|
||||
id: SlashCommandId;
|
||||
path: string;
|
||||
kind: 'slash';
|
||||
}
|
||||
|
||||
const ALL_COMMANDS: SlashCommandId[] = ['proposal', 'apply', 'archive'];
|
||||
|
||||
export abstract class SlashCommandConfigurator {
|
||||
abstract readonly toolId: string;
|
||||
abstract readonly isAvailable: boolean;
|
||||
|
||||
getTargets(): SlashCommandTarget[] {
|
||||
return ALL_COMMANDS.map((id) => ({
|
||||
id,
|
||||
path: this.getRelativePath(id),
|
||||
kind: 'slash'
|
||||
}));
|
||||
}
|
||||
|
||||
async generateAll(projectPath: string, _openspecDir: string): Promise<string[]> {
|
||||
const createdOrUpdated: string[] = [];
|
||||
|
||||
for (const target of this.getTargets()) {
|
||||
const body = this.getBody(target.id);
|
||||
const filePath = FileSystemUtils.joinPath(projectPath, target.path);
|
||||
|
||||
if (await FileSystemUtils.fileExists(filePath)) {
|
||||
await this.updateBody(filePath, body);
|
||||
} else {
|
||||
const frontmatter = this.getFrontmatter(target.id);
|
||||
const sections: string[] = [];
|
||||
if (frontmatter) {
|
||||
sections.push(frontmatter.trim());
|
||||
}
|
||||
sections.push(`${OPENSPEC_MARKERS.start}\n${body}\n${OPENSPEC_MARKERS.end}`);
|
||||
const content = sections.join('\n') + '\n';
|
||||
await FileSystemUtils.writeFile(filePath, content);
|
||||
}
|
||||
|
||||
createdOrUpdated.push(target.path);
|
||||
}
|
||||
|
||||
return createdOrUpdated;
|
||||
}
|
||||
|
||||
async updateExisting(projectPath: string, _openspecDir: string): Promise<string[]> {
|
||||
const updated: string[] = [];
|
||||
|
||||
for (const target of this.getTargets()) {
|
||||
const filePath = FileSystemUtils.joinPath(projectPath, target.path);
|
||||
if (await FileSystemUtils.fileExists(filePath)) {
|
||||
const body = this.getBody(target.id);
|
||||
await this.updateBody(filePath, body);
|
||||
updated.push(target.path);
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
protected abstract getRelativePath(id: SlashCommandId): string;
|
||||
protected abstract getFrontmatter(id: SlashCommandId): string | undefined;
|
||||
|
||||
protected getBody(id: SlashCommandId): string {
|
||||
return TemplateManager.getSlashCommandBody(id).trim();
|
||||
}
|
||||
|
||||
// Resolve absolute path for a given slash command target. Subclasses may override
|
||||
// to redirect to tool-specific locations (e.g., global directories).
|
||||
resolveAbsolutePath(projectPath: string, id: SlashCommandId): string {
|
||||
const rel = this.getRelativePath(id);
|
||||
return FileSystemUtils.joinPath(projectPath, rel);
|
||||
}
|
||||
|
||||
protected async updateBody(filePath: string, body: string): Promise<void> {
|
||||
const content = await FileSystemUtils.readFile(filePath);
|
||||
const startIndex = content.indexOf(OPENSPEC_MARKERS.start);
|
||||
const endIndex = content.indexOf(OPENSPEC_MARKERS.end);
|
||||
|
||||
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
|
||||
throw new Error(`Missing OpenSpec markers in ${filePath}`);
|
||||
}
|
||||
|
||||
const before = content.slice(0, startIndex + OPENSPEC_MARKERS.start.length);
|
||||
const after = content.slice(endIndex);
|
||||
const updatedContent = `${before}\n${body}\n${after}`;
|
||||
|
||||
await FileSystemUtils.writeFile(filePath, updatedContent);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.claude/commands/openspec/proposal.md',
|
||||
apply: '.claude/commands/openspec/apply.md',
|
||||
archive: '.claude/commands/openspec/archive.md'
|
||||
};
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
name: OpenSpec: Proposal
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
category: OpenSpec
|
||||
tags: [openspec, change]
|
||||
---`,
|
||||
apply: `---
|
||||
name: OpenSpec: Apply
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
category: OpenSpec
|
||||
tags: [openspec, apply]
|
||||
---`,
|
||||
archive: `---
|
||||
name: OpenSpec: Archive
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
category: OpenSpec
|
||||
tags: [openspec, archive]
|
||||
---`
|
||||
};
|
||||
|
||||
export class ClaudeSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'claude';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.clinerules/workflows/openspec-proposal.md',
|
||||
apply: '.clinerules/workflows/openspec-apply.md',
|
||||
archive: '.clinerules/workflows/openspec-archive.md'
|
||||
};
|
||||
|
||||
export class ClineSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'cline';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string | undefined {
|
||||
const descriptions: Record<SlashCommandId, string> = {
|
||||
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
|
||||
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
|
||||
archive: 'Archive a deployed OpenSpec change and update specs.'
|
||||
};
|
||||
const description = descriptions[id];
|
||||
return `# OpenSpec: ${id.charAt(0).toUpperCase() + id.slice(1)}\n\n${description}`;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.codebuddy/commands/openspec/proposal.md',
|
||||
apply: '.codebuddy/commands/openspec/apply.md',
|
||||
archive: '.codebuddy/commands/openspec/archive.md'
|
||||
};
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
name: OpenSpec: Proposal
|
||||
description: "Scaffold a new OpenSpec change and validate strictly."
|
||||
argument-hint: "[feature description or request]"
|
||||
---`,
|
||||
apply: `---
|
||||
name: OpenSpec: Apply
|
||||
description: "Implement an approved OpenSpec change and keep tasks in sync."
|
||||
argument-hint: "[change-id]"
|
||||
---`,
|
||||
archive: `---
|
||||
name: OpenSpec: Archive
|
||||
description: "Archive a deployed OpenSpec change and update specs."
|
||||
argument-hint: "[change-id]"
|
||||
---`
|
||||
};
|
||||
|
||||
export class CodeBuddySlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'codebuddy';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { SlashCommandConfigurator } from "./base.js";
|
||||
import { SlashCommandId, TemplateManager } from "../../templates/index.js";
|
||||
import { FileSystemUtils } from "../../../utils/file-system.js";
|
||||
import { OPENSPEC_MARKERS } from "../../config.js";
|
||||
|
||||
// Use POSIX-style paths for consistent logging across platforms.
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: ".codex/prompts/openspec-proposal.md",
|
||||
apply: ".codex/prompts/openspec-apply.md",
|
||||
archive: ".codex/prompts/openspec-archive.md",
|
||||
};
|
||||
|
||||
export class CodexSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = "codex";
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string | undefined {
|
||||
// Codex supports YAML frontmatter with description and argument-hint fields,
|
||||
// plus $ARGUMENTS to capture all arguments as a single string.
|
||||
const frontmatter: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
argument-hint: request or feature description
|
||||
---
|
||||
|
||||
$ARGUMENTS`,
|
||||
apply: `---
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
argument-hint: change-id
|
||||
---
|
||||
|
||||
$ARGUMENTS`,
|
||||
archive: `---
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
argument-hint: change-id
|
||||
---
|
||||
|
||||
$ARGUMENTS`,
|
||||
};
|
||||
return frontmatter[id];
|
||||
}
|
||||
|
||||
private getGlobalPromptsDir(): string {
|
||||
const home = (process.env.CODEX_HOME && process.env.CODEX_HOME.trim())
|
||||
? process.env.CODEX_HOME.trim()
|
||||
: FileSystemUtils.joinPath(os.homedir(), ".codex");
|
||||
return FileSystemUtils.joinPath(home, "prompts");
|
||||
}
|
||||
|
||||
// Codex discovers prompts globally. Generate directly in the global directory
|
||||
// and wrap shared body with markers.
|
||||
async generateAll(projectPath: string, _openspecDir: string): Promise<string[]> {
|
||||
const createdOrUpdated: string[] = [];
|
||||
for (const target of this.getTargets()) {
|
||||
const body = TemplateManager.getSlashCommandBody(target.id).trim();
|
||||
const promptsDir = this.getGlobalPromptsDir();
|
||||
const filePath = FileSystemUtils.joinPath(
|
||||
promptsDir,
|
||||
path.basename(target.path)
|
||||
);
|
||||
|
||||
await FileSystemUtils.createDirectory(path.dirname(filePath));
|
||||
|
||||
if (await FileSystemUtils.fileExists(filePath)) {
|
||||
await this.updateFullFile(filePath, target.id, body);
|
||||
} else {
|
||||
const frontmatter = this.getFrontmatter(target.id);
|
||||
const sections: string[] = [];
|
||||
if (frontmatter) sections.push(frontmatter.trim());
|
||||
sections.push(`${OPENSPEC_MARKERS.start}\n${body}\n${OPENSPEC_MARKERS.end}`);
|
||||
await FileSystemUtils.writeFile(filePath, sections.join("\n") + "\n");
|
||||
}
|
||||
|
||||
createdOrUpdated.push(target.path);
|
||||
}
|
||||
return createdOrUpdated;
|
||||
}
|
||||
|
||||
async updateExisting(projectPath: string, _openspecDir: string): Promise<string[]> {
|
||||
const updated: string[] = [];
|
||||
for (const target of this.getTargets()) {
|
||||
const promptsDir = this.getGlobalPromptsDir();
|
||||
const filePath = FileSystemUtils.joinPath(
|
||||
promptsDir,
|
||||
path.basename(target.path)
|
||||
);
|
||||
if (await FileSystemUtils.fileExists(filePath)) {
|
||||
const body = TemplateManager.getSlashCommandBody(target.id).trim();
|
||||
await this.updateFullFile(filePath, target.id, body);
|
||||
updated.push(target.path);
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Update both frontmatter and body in an existing file
|
||||
private async updateFullFile(filePath: string, id: SlashCommandId, body: string): Promise<void> {
|
||||
const content = await FileSystemUtils.readFile(filePath);
|
||||
const startIndex = content.indexOf(OPENSPEC_MARKERS.start);
|
||||
|
||||
if (startIndex === -1) {
|
||||
throw new Error(`Missing OpenSpec start marker in ${filePath}`);
|
||||
}
|
||||
|
||||
// Replace everything before the start marker with the new frontmatter
|
||||
const frontmatter = this.getFrontmatter(id);
|
||||
const sections: string[] = [];
|
||||
if (frontmatter) sections.push(frontmatter.trim());
|
||||
sections.push(`${OPENSPEC_MARKERS.start}\n${body}\n${OPENSPEC_MARKERS.end}`);
|
||||
|
||||
await FileSystemUtils.writeFile(filePath, sections.join("\n") + "\n");
|
||||
}
|
||||
|
||||
// Resolve to the global prompts location for configuration detection
|
||||
resolveAbsolutePath(_projectPath: string, id: SlashCommandId): string {
|
||||
const promptsDir = this.getGlobalPromptsDir();
|
||||
const fileName = path.basename(FILE_PATHS[id]);
|
||||
return FileSystemUtils.joinPath(promptsDir, fileName);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.continue/prompts/openspec-proposal.prompt',
|
||||
apply: '.continue/prompts/openspec-apply.prompt',
|
||||
archive: '.continue/prompts/openspec-archive.prompt'
|
||||
};
|
||||
|
||||
/*
|
||||
* Continue .prompt format requires YAML frontmatter:
|
||||
* ---
|
||||
* name: commandName
|
||||
* description: description
|
||||
* invokable: true
|
||||
* ---
|
||||
* Body...
|
||||
*
|
||||
* The 'invokable: true' field is required to make the prompt available as a slash command.
|
||||
* We use 'openspec-proposal' as the name so the command becomes /openspec-proposal.
|
||||
*/
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
name: openspec-proposal
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
invokable: true
|
||||
---`,
|
||||
apply: `---
|
||||
name: openspec-apply
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
invokable: true
|
||||
---`,
|
||||
archive: `---
|
||||
name: openspec-archive
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
invokable: true
|
||||
---`
|
||||
};
|
||||
|
||||
export class ContinueSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'continue';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS = {
|
||||
proposal: '.cospec/openspec/commands/openspec-proposal.md',
|
||||
apply: '.cospec/openspec/commands/openspec-apply.md',
|
||||
archive: '.cospec/openspec/commands/openspec-archive.md',
|
||||
} as const satisfies Record<SlashCommandId, string>;
|
||||
|
||||
const FRONTMATTER = {
|
||||
proposal: `---
|
||||
description: "Scaffold a new OpenSpec change and validate strictly."
|
||||
argument-hint: feature description or request
|
||||
---`,
|
||||
apply: `---
|
||||
description: "Implement an approved OpenSpec change and keep tasks in sync."
|
||||
argument-hint: change-id
|
||||
---`,
|
||||
archive: `---
|
||||
description: "Archive a deployed OpenSpec change and update specs."
|
||||
argument-hint: change-id
|
||||
---`
|
||||
} as const satisfies Record<SlashCommandId, string>;
|
||||
|
||||
export class CostrictSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'costrict';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string | undefined {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.crush/commands/openspec/proposal.md',
|
||||
apply: '.crush/commands/openspec/apply.md',
|
||||
archive: '.crush/commands/openspec/archive.md'
|
||||
};
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
name: OpenSpec: Proposal
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
category: OpenSpec
|
||||
tags: [openspec, change]
|
||||
---`,
|
||||
apply: `---
|
||||
name: OpenSpec: Apply
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
category: OpenSpec
|
||||
tags: [openspec, apply]
|
||||
---`,
|
||||
archive: `---
|
||||
name: OpenSpec: Archive
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
category: OpenSpec
|
||||
tags: [openspec, archive]
|
||||
---`
|
||||
};
|
||||
|
||||
export class CrushSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'crush';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.cursor/commands/openspec-proposal.md',
|
||||
apply: '.cursor/commands/openspec-apply.md',
|
||||
archive: '.cursor/commands/openspec-archive.md'
|
||||
};
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
name: /openspec-proposal
|
||||
id: openspec-proposal
|
||||
category: OpenSpec
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
---`,
|
||||
apply: `---
|
||||
name: /openspec-apply
|
||||
id: openspec-apply
|
||||
category: OpenSpec
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
---`,
|
||||
archive: `---
|
||||
name: /openspec-archive
|
||||
id: openspec-archive
|
||||
category: OpenSpec
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
---`
|
||||
};
|
||||
|
||||
export class CursorSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'cursor';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.factory/commands/openspec-proposal.md',
|
||||
apply: '.factory/commands/openspec-apply.md',
|
||||
archive: '.factory/commands/openspec-archive.md'
|
||||
};
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
argument-hint: request or feature description
|
||||
---`,
|
||||
apply: `---
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
argument-hint: change-id
|
||||
---`,
|
||||
archive: `---
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
argument-hint: change-id
|
||||
---`
|
||||
};
|
||||
|
||||
export class FactorySlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'factory';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
|
||||
protected getBody(id: SlashCommandId): string {
|
||||
const baseBody = super.getBody(id);
|
||||
return `${baseBody}\n\n$ARGUMENTS`;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { TomlSlashCommandConfigurator } from './toml-base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.gemini/commands/openspec/proposal.toml',
|
||||
apply: '.gemini/commands/openspec/apply.toml',
|
||||
archive: '.gemini/commands/openspec/archive.toml'
|
||||
};
|
||||
|
||||
const DESCRIPTIONS: Record<SlashCommandId, string> = {
|
||||
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
|
||||
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
|
||||
archive: 'Archive a deployed OpenSpec change and update specs.'
|
||||
};
|
||||
|
||||
export class GeminiSlashCommandConfigurator extends TomlSlashCommandConfigurator {
|
||||
readonly toolId = 'gemini';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getDescription(id: SlashCommandId): string {
|
||||
return DESCRIPTIONS[id];
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.github/prompts/openspec-proposal.prompt.md',
|
||||
apply: '.github/prompts/openspec-apply.prompt.md',
|
||||
archive: '.github/prompts/openspec-archive.prompt.md'
|
||||
};
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
---
|
||||
|
||||
$ARGUMENTS`,
|
||||
apply: `---
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
---
|
||||
|
||||
$ARGUMENTS`,
|
||||
archive: `---
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
---
|
||||
|
||||
$ARGUMENTS`
|
||||
};
|
||||
|
||||
export class GitHubCopilotSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'github-copilot';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.iflow/commands/openspec-proposal.md',
|
||||
apply: '.iflow/commands/openspec-apply.md',
|
||||
archive: '.iflow/commands/openspec-archive.md'
|
||||
};
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
name: /openspec-proposal
|
||||
id: openspec-proposal
|
||||
category: OpenSpec
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
---`,
|
||||
apply: `---
|
||||
name: /openspec-apply
|
||||
id: openspec-apply
|
||||
category: OpenSpec
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
---`,
|
||||
archive: `---
|
||||
name: /openspec-archive
|
||||
id: openspec-archive
|
||||
category: OpenSpec
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
---`
|
||||
};
|
||||
|
||||
export class IflowSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'iflow';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { SlashCommandConfigurator } from "./base.js";
|
||||
import { SlashCommandId } from "../../templates/index.js";
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: ".kilocode/workflows/openspec-proposal.md",
|
||||
apply: ".kilocode/workflows/openspec-apply.md",
|
||||
archive: ".kilocode/workflows/openspec-archive.md"
|
||||
};
|
||||
|
||||
export class KiloCodeSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = "kilocode";
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(_id: SlashCommandId): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { SlashCommandConfigurator } from "./base.js";
|
||||
import { SlashCommandId } from "../../templates/index.js";
|
||||
import { FileSystemUtils } from "../../../utils/file-system.js";
|
||||
import { OPENSPEC_MARKERS } from "../../config.js";
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: ".opencode/command/openspec-proposal.md",
|
||||
apply: ".opencode/command/openspec-apply.md",
|
||||
archive: ".opencode/command/openspec-archive.md",
|
||||
};
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
---
|
||||
The user has requested the following change proposal. Use the openspec instructions to create their change proposal.
|
||||
<UserRequest>
|
||||
$ARGUMENTS
|
||||
</UserRequest>
|
||||
`,
|
||||
apply: `---
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
---
|
||||
The user has requested to implement the following change proposal. Find the change proposal and follow the instructions below. If you're not sure or if ambiguous, ask for clarification from the user.
|
||||
<UserRequest>
|
||||
$ARGUMENTS
|
||||
</UserRequest>
|
||||
`,
|
||||
archive: `---
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
---
|
||||
<ChangeId>
|
||||
$ARGUMENTS
|
||||
</ChangeId>
|
||||
`,
|
||||
};
|
||||
|
||||
export class OpenCodeSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = "opencode";
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string | undefined {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
|
||||
async generateAll(projectPath: string, _openspecDir: string): Promise<string[]> {
|
||||
const createdOrUpdated = await super.generateAll(projectPath, _openspecDir);
|
||||
await this.rewriteArchiveFile(projectPath);
|
||||
return createdOrUpdated;
|
||||
}
|
||||
|
||||
async updateExisting(projectPath: string, _openspecDir: string): Promise<string[]> {
|
||||
const updated = await super.updateExisting(projectPath, _openspecDir);
|
||||
const rewroteArchive = await this.rewriteArchiveFile(projectPath);
|
||||
if (rewroteArchive && !updated.includes(FILE_PATHS.archive)) {
|
||||
updated.push(FILE_PATHS.archive);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async rewriteArchiveFile(projectPath: string): Promise<boolean> {
|
||||
const archivePath = FileSystemUtils.joinPath(projectPath, FILE_PATHS.archive);
|
||||
if (!await FileSystemUtils.fileExists(archivePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = this.getBody("archive");
|
||||
const frontmatter = this.getFrontmatter("archive");
|
||||
const sections: string[] = [];
|
||||
|
||||
if (frontmatter) {
|
||||
sections.push(frontmatter.trim());
|
||||
}
|
||||
|
||||
sections.push(`${OPENSPEC_MARKERS.start}\n${body}\n${OPENSPEC_MARKERS.end}`);
|
||||
await FileSystemUtils.writeFile(archivePath, sections.join("\n") + "\n");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
/**
|
||||
* File paths for Qoder slash commands
|
||||
* Maps each OpenSpec workflow stage to its command file location
|
||||
* Commands are stored in .qoder/commands/openspec/ directory
|
||||
*/
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
// Create and validate new change proposals
|
||||
proposal: '.qoder/commands/openspec/proposal.md',
|
||||
|
||||
// Implement approved changes with task tracking
|
||||
apply: '.qoder/commands/openspec/apply.md',
|
||||
|
||||
// Archive completed changes and update specs
|
||||
archive: '.qoder/commands/openspec/archive.md'
|
||||
};
|
||||
|
||||
/**
|
||||
* YAML frontmatter for Qoder slash commands
|
||||
* Defines metadata displayed in Qoder's command palette
|
||||
* Each command is categorized and tagged for easy discovery
|
||||
*/
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
name: OpenSpec: Proposal
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
category: OpenSpec
|
||||
tags: [openspec, change]
|
||||
---`,
|
||||
apply: `---
|
||||
name: OpenSpec: Apply
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
category: OpenSpec
|
||||
tags: [openspec, apply]
|
||||
---`,
|
||||
archive: `---
|
||||
name: OpenSpec: Archive
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
category: OpenSpec
|
||||
tags: [openspec, archive]
|
||||
---`
|
||||
};
|
||||
|
||||
/**
|
||||
* Qoder Slash Command Configurator
|
||||
*
|
||||
* Manages OpenSpec slash commands for Qoder AI assistant.
|
||||
* Creates three workflow commands: proposal, apply, and archive.
|
||||
* Uses colon-separated command format (/openspec:proposal).
|
||||
*
|
||||
* @extends {SlashCommandConfigurator}
|
||||
*/
|
||||
export class QoderSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
/** Unique identifier for Qoder tool */
|
||||
readonly toolId = 'qoder';
|
||||
|
||||
/** Indicates slash commands are available for this tool */
|
||||
readonly isAvailable = true;
|
||||
|
||||
/**
|
||||
* Get relative file path for a slash command
|
||||
*
|
||||
* @param {SlashCommandId} id - Command identifier (proposal, apply, or archive)
|
||||
* @returns {string} Relative path from project root to command file
|
||||
*/
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get YAML frontmatter for a slash command
|
||||
*
|
||||
* Frontmatter defines how the command appears in Qoder's UI,
|
||||
* including display name, description, and categorization.
|
||||
*
|
||||
* @param {SlashCommandId} id - Command identifier (proposal, apply, or archive)
|
||||
* @returns {string} YAML frontmatter block with command metadata
|
||||
*/
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* Qwen slash command configurator for OpenSpec integration.
|
||||
* This class handles the generation of Qwen-specific slash command files
|
||||
* in the .qwen/commands directory structure.
|
||||
*
|
||||
* @implements {SlashCommandConfigurator}
|
||||
*/
|
||||
import { TomlSlashCommandConfigurator } from './toml-base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
/**
|
||||
* Mapping of slash command IDs to their corresponding file paths in .qwen/commands directory.
|
||||
* @type {Record<SlashCommandId, string>}
|
||||
*/
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.qwen/commands/openspec-proposal.toml',
|
||||
apply: '.qwen/commands/openspec-apply.toml',
|
||||
archive: '.qwen/commands/openspec-archive.toml'
|
||||
};
|
||||
|
||||
const DESCRIPTIONS: Record<SlashCommandId, string> = {
|
||||
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
|
||||
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
|
||||
archive: 'Archive a deployed OpenSpec change and update specs.'
|
||||
};
|
||||
|
||||
/**
|
||||
* QwenSlashCommandConfigurator class provides integration with Qwen Code
|
||||
* by creating the necessary slash command files in the .qwen/commands directory.
|
||||
*
|
||||
* The slash commands include:
|
||||
* - /openspec-proposal: Create an OpenSpec change proposal
|
||||
* - /openspec-apply: Apply an approved OpenSpec change
|
||||
* - /openspec-archive: Archive a deployed OpenSpec change
|
||||
*/
|
||||
export class QwenSlashCommandConfigurator extends TomlSlashCommandConfigurator {
|
||||
/** Unique identifier for the Qwen tool */
|
||||
readonly toolId = 'qwen';
|
||||
|
||||
/** Availability status for the Qwen tool */
|
||||
readonly isAvailable = true;
|
||||
|
||||
/**
|
||||
* Returns the relative file path for a given slash command ID.
|
||||
* @param {SlashCommandId} id - The slash command identifier
|
||||
* @returns {string} The relative path to the command file
|
||||
*/
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getDescription(id: SlashCommandId): string {
|
||||
return DESCRIPTIONS[id];
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { ClaudeSlashCommandConfigurator } from './claude.js';
|
||||
import { CodeBuddySlashCommandConfigurator } from './codebuddy.js';
|
||||
import { QoderSlashCommandConfigurator } from './qoder.js';
|
||||
import { CursorSlashCommandConfigurator } from './cursor.js';
|
||||
import { WindsurfSlashCommandConfigurator } from './windsurf.js';
|
||||
import { KiloCodeSlashCommandConfigurator } from './kilocode.js';
|
||||
import { OpenCodeSlashCommandConfigurator } from './opencode.js';
|
||||
import { CodexSlashCommandConfigurator } from './codex.js';
|
||||
import { GitHubCopilotSlashCommandConfigurator } from './github-copilot.js';
|
||||
import { AmazonQSlashCommandConfigurator } from './amazon-q.js';
|
||||
import { FactorySlashCommandConfigurator } from './factory.js';
|
||||
import { GeminiSlashCommandConfigurator } from './gemini.js';
|
||||
import { AuggieSlashCommandConfigurator } from './auggie.js';
|
||||
import { ClineSlashCommandConfigurator } from './cline.js';
|
||||
import { CrushSlashCommandConfigurator } from './crush.js';
|
||||
import { CostrictSlashCommandConfigurator } from './costrict.js';
|
||||
import { QwenSlashCommandConfigurator } from './qwen.js';
|
||||
import { RooCodeSlashCommandConfigurator } from './roocode.js';
|
||||
import { AntigravitySlashCommandConfigurator } from './antigravity.js';
|
||||
import { IflowSlashCommandConfigurator } from './iflow.js';
|
||||
import { ContinueSlashCommandConfigurator } from './continue.js';
|
||||
|
||||
export class SlashCommandRegistry {
|
||||
private static configurators: Map<string, SlashCommandConfigurator> = new Map();
|
||||
|
||||
static {
|
||||
const claude = new ClaudeSlashCommandConfigurator();
|
||||
const codeBuddy = new CodeBuddySlashCommandConfigurator();
|
||||
const qoder = new QoderSlashCommandConfigurator();
|
||||
const cursor = new CursorSlashCommandConfigurator();
|
||||
const windsurf = new WindsurfSlashCommandConfigurator();
|
||||
const kilocode = new KiloCodeSlashCommandConfigurator();
|
||||
const opencode = new OpenCodeSlashCommandConfigurator();
|
||||
const codex = new CodexSlashCommandConfigurator();
|
||||
const githubCopilot = new GitHubCopilotSlashCommandConfigurator();
|
||||
const amazonQ = new AmazonQSlashCommandConfigurator();
|
||||
const factory = new FactorySlashCommandConfigurator();
|
||||
const gemini = new GeminiSlashCommandConfigurator();
|
||||
const auggie = new AuggieSlashCommandConfigurator();
|
||||
const cline = new ClineSlashCommandConfigurator();
|
||||
const crush = new CrushSlashCommandConfigurator();
|
||||
const costrict = new CostrictSlashCommandConfigurator();
|
||||
const qwen = new QwenSlashCommandConfigurator();
|
||||
const roocode = new RooCodeSlashCommandConfigurator();
|
||||
const antigravity = new AntigravitySlashCommandConfigurator();
|
||||
const iflow = new IflowSlashCommandConfigurator();
|
||||
const continueTool = new ContinueSlashCommandConfigurator();
|
||||
|
||||
this.configurators.set(claude.toolId, claude);
|
||||
this.configurators.set(codeBuddy.toolId, codeBuddy);
|
||||
this.configurators.set(qoder.toolId, qoder);
|
||||
this.configurators.set(cursor.toolId, cursor);
|
||||
this.configurators.set(windsurf.toolId, windsurf);
|
||||
this.configurators.set(kilocode.toolId, kilocode);
|
||||
this.configurators.set(opencode.toolId, opencode);
|
||||
this.configurators.set(codex.toolId, codex);
|
||||
this.configurators.set(githubCopilot.toolId, githubCopilot);
|
||||
this.configurators.set(amazonQ.toolId, amazonQ);
|
||||
this.configurators.set(factory.toolId, factory);
|
||||
this.configurators.set(gemini.toolId, gemini);
|
||||
this.configurators.set(auggie.toolId, auggie);
|
||||
this.configurators.set(cline.toolId, cline);
|
||||
this.configurators.set(crush.toolId, crush);
|
||||
this.configurators.set(costrict.toolId, costrict);
|
||||
this.configurators.set(qwen.toolId, qwen);
|
||||
this.configurators.set(roocode.toolId, roocode);
|
||||
this.configurators.set(antigravity.toolId, antigravity);
|
||||
this.configurators.set(iflow.toolId, iflow);
|
||||
this.configurators.set(continueTool.toolId, continueTool);
|
||||
}
|
||||
|
||||
static register(configurator: SlashCommandConfigurator): void {
|
||||
this.configurators.set(configurator.toolId, configurator);
|
||||
}
|
||||
|
||||
static get(toolId: string): SlashCommandConfigurator | undefined {
|
||||
return this.configurators.get(toolId);
|
||||
}
|
||||
|
||||
static getAll(): SlashCommandConfigurator[] {
|
||||
return Array.from(this.configurators.values());
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const NEW_FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.roo/commands/openspec-proposal.md',
|
||||
apply: '.roo/commands/openspec-apply.md',
|
||||
archive: '.roo/commands/openspec-archive.md'
|
||||
};
|
||||
|
||||
export class RooCodeSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'roocode';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return NEW_FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string | undefined {
|
||||
const descriptions: Record<SlashCommandId, string> = {
|
||||
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
|
||||
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
|
||||
archive: 'Archive a deployed OpenSpec change and update specs.'
|
||||
};
|
||||
const description = descriptions[id];
|
||||
return `# OpenSpec: ${id.charAt(0).toUpperCase() + id.slice(1)}\n\n${description}`;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { FileSystemUtils } from '../../../utils/file-system.js';
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../../config.js';
|
||||
|
||||
export abstract class TomlSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
protected getFrontmatter(_id: SlashCommandId): string | undefined {
|
||||
// TOML doesn't use separate frontmatter - it's all in one structure
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected abstract getDescription(id: SlashCommandId): string;
|
||||
|
||||
// Override to generate TOML format with markers inside the prompt field
|
||||
async generateAll(projectPath: string, _openspecDir: string): Promise<string[]> {
|
||||
const createdOrUpdated: string[] = [];
|
||||
|
||||
for (const target of this.getTargets()) {
|
||||
const body = this.getBody(target.id);
|
||||
const filePath = FileSystemUtils.joinPath(projectPath, target.path);
|
||||
|
||||
if (await FileSystemUtils.fileExists(filePath)) {
|
||||
await this.updateBody(filePath, body);
|
||||
} else {
|
||||
const tomlContent = this.generateTOML(target.id, body);
|
||||
await FileSystemUtils.writeFile(filePath, tomlContent);
|
||||
}
|
||||
|
||||
createdOrUpdated.push(target.path);
|
||||
}
|
||||
|
||||
return createdOrUpdated;
|
||||
}
|
||||
|
||||
private generateTOML(id: SlashCommandId, body: string): string {
|
||||
const description = this.getDescription(id);
|
||||
|
||||
// TOML format with triple-quoted string for multi-line prompt
|
||||
// Markers are inside the prompt value
|
||||
return `description = "${description}"
|
||||
|
||||
prompt = """
|
||||
${OPENSPEC_MARKERS.start}
|
||||
${body}
|
||||
${OPENSPEC_MARKERS.end}
|
||||
"""
|
||||
`;
|
||||
}
|
||||
|
||||
// Override updateBody to handle TOML format
|
||||
protected async updateBody(filePath: string, body: string): Promise<void> {
|
||||
const content = await FileSystemUtils.readFile(filePath);
|
||||
const startIndex = content.indexOf(OPENSPEC_MARKERS.start);
|
||||
const endIndex = content.indexOf(OPENSPEC_MARKERS.end);
|
||||
|
||||
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
|
||||
throw new Error(`Missing OpenSpec markers in ${filePath}`);
|
||||
}
|
||||
|
||||
const before = content.slice(0, startIndex + OPENSPEC_MARKERS.start.length);
|
||||
const after = content.slice(endIndex);
|
||||
const updatedContent = `${before}\n${body}\n${after}`;
|
||||
|
||||
await FileSystemUtils.writeFile(filePath, updatedContent);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.windsurf/workflows/openspec-proposal.md',
|
||||
apply: '.windsurf/workflows/openspec-apply.md',
|
||||
archive: '.windsurf/workflows/openspec-archive.md'
|
||||
};
|
||||
|
||||
export class WindsurfSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'windsurf';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string | undefined {
|
||||
const descriptions: Record<SlashCommandId, string> = {
|
||||
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
|
||||
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
|
||||
archive: 'Archive a deployed OpenSpec change and update specs.'
|
||||
};
|
||||
const description = descriptions[id];
|
||||
return `---\ndescription: ${description}\nauto_execution_mode: 3\n---`;
|
||||
}
|
||||
}
|
||||
+412
-801
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,640 @@
|
||||
/**
|
||||
* Legacy cleanup module for detecting and removing OpenSpec artifacts
|
||||
* from previous init versions during the migration to the skill-based workflow.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
import chalk from 'chalk';
|
||||
import { FileSystemUtils, removeMarkerBlock as removeMarkerBlockUtil } from '../utils/file-system.js';
|
||||
import { OPENSPEC_MARKERS } from './config.js';
|
||||
|
||||
/**
|
||||
* Legacy config file names from the old ToolRegistry.
|
||||
* These were config files created at project root with OpenSpec markers.
|
||||
*/
|
||||
export const LEGACY_CONFIG_FILES = [
|
||||
'CLAUDE.md',
|
||||
'CLINE.md',
|
||||
'CODEBUDDY.md',
|
||||
'COSTRICT.md',
|
||||
'QODER.md',
|
||||
'IFLOW.md',
|
||||
'AGENTS.md', // root AGENTS.md (not openspec/AGENTS.md)
|
||||
'QWEN.md',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Legacy slash command patterns from the old SlashCommandRegistry.
|
||||
* These map toolId to the path pattern where legacy commands were created.
|
||||
* Some tools used a directory structure, others used individual files.
|
||||
*/
|
||||
export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPattern> = {
|
||||
// Directory-based: .tooldir/commands/openspec/ or .tooldir/commands/openspec/*.md
|
||||
'claude': { type: 'directory', path: '.claude/commands/openspec' },
|
||||
'codebuddy': { type: 'directory', path: '.codebuddy/commands/openspec' },
|
||||
'qoder': { type: 'directory', path: '.qoder/commands/openspec' },
|
||||
'crush': { type: 'directory', path: '.crush/commands/openspec' },
|
||||
'gemini': { type: 'directory', path: '.gemini/commands/openspec' },
|
||||
'costrict': { type: 'directory', path: '.cospec/openspec/commands' },
|
||||
|
||||
// File-based: individual openspec-*.md files in a commands/workflows/prompts folder
|
||||
'cursor': { type: 'files', pattern: '.cursor/commands/openspec-*.md' },
|
||||
'windsurf': { type: 'files', pattern: '.windsurf/workflows/openspec-*.md' },
|
||||
'kilocode': { type: 'files', pattern: '.kilocode/workflows/openspec-*.md' },
|
||||
'github-copilot': { type: 'files', pattern: '.github/prompts/openspec-*.prompt.md' },
|
||||
'amazon-q': { type: 'files', pattern: '.amazonq/prompts/openspec-*.md' },
|
||||
'cline': { type: 'files', pattern: '.clinerules/workflows/openspec-*.md' },
|
||||
'roocode': { type: 'files', pattern: '.roo/commands/openspec-*.md' },
|
||||
'auggie': { type: 'files', pattern: '.augment/commands/openspec-*.md' },
|
||||
'factory': { type: 'files', pattern: '.factory/commands/openspec-*.md' },
|
||||
'opencode': { type: 'files', pattern: '.opencode/command/openspec-*.md' },
|
||||
'continue': { type: 'files', pattern: '.continue/prompts/openspec-*.prompt' },
|
||||
'antigravity': { type: 'files', pattern: '.agent/workflows/openspec-*.md' },
|
||||
'iflow': { type: 'files', pattern: '.iflow/commands/openspec-*.md' },
|
||||
'qwen': { type: 'files', pattern: '.qwen/commands/openspec-*.toml' },
|
||||
'codex': { type: 'files', pattern: '.codex/prompts/openspec-*.md' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Pattern types for legacy slash commands
|
||||
*/
|
||||
export interface LegacySlashCommandPattern {
|
||||
type: 'directory' | 'files';
|
||||
path?: string; // For directory type
|
||||
pattern?: string; // For files type (glob pattern)
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of legacy artifact detection
|
||||
*/
|
||||
export interface LegacyDetectionResult {
|
||||
/** Config files with OpenSpec markers detected */
|
||||
configFiles: string[];
|
||||
/** Config files to update (remove markers only, never delete) */
|
||||
configFilesToUpdate: string[];
|
||||
/** Legacy slash command directories found */
|
||||
slashCommandDirs: string[];
|
||||
/** Legacy slash command files found (for file-based tools) */
|
||||
slashCommandFiles: string[];
|
||||
/** Whether openspec/AGENTS.md exists */
|
||||
hasOpenspecAgents: boolean;
|
||||
/** Whether openspec/project.md exists (preserved, migration hint only) */
|
||||
hasProjectMd: boolean;
|
||||
/** Whether root AGENTS.md has OpenSpec markers */
|
||||
hasRootAgentsWithMarkers: boolean;
|
||||
/** Whether any legacy artifacts were found */
|
||||
hasLegacyArtifacts: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects all legacy OpenSpec artifacts in a project.
|
||||
*
|
||||
* @param projectPath - The root path of the project
|
||||
* @returns Detection result with all found legacy artifacts
|
||||
*/
|
||||
export async function detectLegacyArtifacts(
|
||||
projectPath: string
|
||||
): Promise<LegacyDetectionResult> {
|
||||
const result: LegacyDetectionResult = {
|
||||
configFiles: [],
|
||||
configFilesToUpdate: [],
|
||||
slashCommandDirs: [],
|
||||
slashCommandFiles: [],
|
||||
hasOpenspecAgents: false,
|
||||
hasProjectMd: false,
|
||||
hasRootAgentsWithMarkers: false,
|
||||
hasLegacyArtifacts: false,
|
||||
};
|
||||
|
||||
// Detect legacy config files
|
||||
const configResult = await detectLegacyConfigFiles(projectPath);
|
||||
result.configFiles = configResult.allFiles;
|
||||
result.configFilesToUpdate = configResult.filesToUpdate;
|
||||
|
||||
// Detect legacy slash commands
|
||||
const slashResult = await detectLegacySlashCommands(projectPath);
|
||||
result.slashCommandDirs = slashResult.directories;
|
||||
result.slashCommandFiles = slashResult.files;
|
||||
|
||||
// Detect legacy structure files
|
||||
const structureResult = await detectLegacyStructureFiles(projectPath);
|
||||
result.hasOpenspecAgents = structureResult.hasOpenspecAgents;
|
||||
result.hasProjectMd = structureResult.hasProjectMd;
|
||||
result.hasRootAgentsWithMarkers = structureResult.hasRootAgentsWithMarkers;
|
||||
|
||||
// Determine if any legacy artifacts exist
|
||||
result.hasLegacyArtifacts =
|
||||
result.configFiles.length > 0 ||
|
||||
result.slashCommandDirs.length > 0 ||
|
||||
result.slashCommandFiles.length > 0 ||
|
||||
result.hasOpenspecAgents ||
|
||||
result.hasRootAgentsWithMarkers ||
|
||||
result.hasProjectMd;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects legacy config files with OpenSpec markers.
|
||||
* All config files with markers are candidates for update (marker removal only).
|
||||
* Config files are NEVER deleted - they belong to the user's project root.
|
||||
*
|
||||
* @param projectPath - The root path of the project
|
||||
* @returns Object with all files found and files to update
|
||||
*/
|
||||
export async function detectLegacyConfigFiles(
|
||||
projectPath: string
|
||||
): Promise<{
|
||||
allFiles: string[];
|
||||
filesToUpdate: string[];
|
||||
}> {
|
||||
const allFiles: string[] = [];
|
||||
const filesToUpdate: string[] = [];
|
||||
|
||||
for (const fileName of LEGACY_CONFIG_FILES) {
|
||||
const filePath = FileSystemUtils.joinPath(projectPath, fileName);
|
||||
|
||||
if (await FileSystemUtils.fileExists(filePath)) {
|
||||
const content = await FileSystemUtils.readFile(filePath);
|
||||
|
||||
if (hasOpenSpecMarkers(content)) {
|
||||
allFiles.push(fileName);
|
||||
filesToUpdate.push(fileName); // Always update, never delete config files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { allFiles, filesToUpdate };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects legacy slash command directories and files.
|
||||
*
|
||||
* @param projectPath - The root path of the project
|
||||
* @returns Object with directories and individual files found
|
||||
*/
|
||||
export async function detectLegacySlashCommands(
|
||||
projectPath: string
|
||||
): Promise<{
|
||||
directories: string[];
|
||||
files: string[];
|
||||
}> {
|
||||
const directories: string[] = [];
|
||||
const files: string[] = [];
|
||||
|
||||
for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
|
||||
if (pattern.type === 'directory' && pattern.path) {
|
||||
const dirPath = FileSystemUtils.joinPath(projectPath, pattern.path);
|
||||
if (await FileSystemUtils.directoryExists(dirPath)) {
|
||||
directories.push(pattern.path);
|
||||
}
|
||||
} else if (pattern.type === 'files' && pattern.pattern) {
|
||||
// For file-based patterns, check for individual files
|
||||
const foundFiles = await findLegacySlashCommandFiles(projectPath, pattern.pattern);
|
||||
files.push(...foundFiles);
|
||||
}
|
||||
}
|
||||
|
||||
return { directories, files };
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds legacy slash command files matching a glob pattern.
|
||||
*
|
||||
* @param projectPath - The root path of the project
|
||||
* @param pattern - Glob pattern like '.cursor/commands/openspec-*.md'
|
||||
* @returns Array of matching file paths relative to projectPath
|
||||
*/
|
||||
async function findLegacySlashCommandFiles(
|
||||
projectPath: string,
|
||||
pattern: string
|
||||
): Promise<string[]> {
|
||||
const foundFiles: string[] = [];
|
||||
|
||||
// Extract directory and file pattern from glob
|
||||
// Handle both forward and backward slashes for Windows compatibility
|
||||
const lastForwardSlash = pattern.lastIndexOf('/');
|
||||
const lastBackSlash = pattern.lastIndexOf('\\');
|
||||
const lastSeparator = Math.max(lastForwardSlash, lastBackSlash);
|
||||
const dirPart = pattern.substring(0, lastSeparator);
|
||||
const filePart = pattern.substring(lastSeparator + 1);
|
||||
|
||||
const dirPath = FileSystemUtils.joinPath(projectPath, dirPart);
|
||||
|
||||
if (!(await FileSystemUtils.directoryExists(dirPath))) {
|
||||
return foundFiles;
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath);
|
||||
|
||||
// Convert glob pattern to regex
|
||||
// openspec-*.md -> /^openspec-.*\.md$/
|
||||
// openspec-*.prompt.md -> /^openspec-.*\.prompt\.md$/
|
||||
// openspec-*.toml -> /^openspec-.*\.toml$/
|
||||
const regexPattern = filePart
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except *
|
||||
.replace(/\*/g, '.*'); // Replace * with .*
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (regex.test(entry)) {
|
||||
// Use forward slashes for consistency in relative paths (cross-platform)
|
||||
const normalizedDir = dirPart.replace(/\\/g, '/');
|
||||
foundFiles.push(`${normalizedDir}/${entry}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory doesn't exist or can't be read
|
||||
}
|
||||
|
||||
return foundFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects legacy OpenSpec structure files (AGENTS.md and project.md).
|
||||
*
|
||||
* @param projectPath - The root path of the project
|
||||
* @returns Object with detection results for structure files
|
||||
*/
|
||||
export async function detectLegacyStructureFiles(
|
||||
projectPath: string
|
||||
): Promise<{
|
||||
hasOpenspecAgents: boolean;
|
||||
hasProjectMd: boolean;
|
||||
hasRootAgentsWithMarkers: boolean;
|
||||
}> {
|
||||
let hasOpenspecAgents = false;
|
||||
let hasProjectMd = false;
|
||||
let hasRootAgentsWithMarkers = false;
|
||||
|
||||
// Check for openspec/AGENTS.md
|
||||
const openspecAgentsPath = FileSystemUtils.joinPath(projectPath, 'openspec', 'AGENTS.md');
|
||||
hasOpenspecAgents = await FileSystemUtils.fileExists(openspecAgentsPath);
|
||||
|
||||
// Check for openspec/project.md (for migration messaging, not deleted)
|
||||
const projectMdPath = FileSystemUtils.joinPath(projectPath, 'openspec', 'project.md');
|
||||
hasProjectMd = await FileSystemUtils.fileExists(projectMdPath);
|
||||
|
||||
// Check for root AGENTS.md with OpenSpec markers
|
||||
const rootAgentsPath = FileSystemUtils.joinPath(projectPath, 'AGENTS.md');
|
||||
if (await FileSystemUtils.fileExists(rootAgentsPath)) {
|
||||
const content = await FileSystemUtils.readFile(rootAgentsPath);
|
||||
hasRootAgentsWithMarkers = hasOpenSpecMarkers(content);
|
||||
}
|
||||
|
||||
return { hasOpenspecAgents, hasProjectMd, hasRootAgentsWithMarkers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if content contains OpenSpec markers.
|
||||
*
|
||||
* @param content - File content to check
|
||||
* @returns True if both start and end markers are present
|
||||
*/
|
||||
export function hasOpenSpecMarkers(content: string): boolean {
|
||||
return (
|
||||
content.includes(OPENSPEC_MARKERS.start) && content.includes(OPENSPEC_MARKERS.end)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if file content is 100% OpenSpec content (only markers and whitespace outside).
|
||||
*
|
||||
* @param content - File content to check
|
||||
* @returns True if content outside markers is only whitespace
|
||||
*/
|
||||
export function isOnlyOpenSpecContent(content: string): boolean {
|
||||
const startIndex = content.indexOf(OPENSPEC_MARKERS.start);
|
||||
const endIndex = content.indexOf(OPENSPEC_MARKERS.end);
|
||||
|
||||
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const before = content.substring(0, startIndex);
|
||||
const after = content.substring(endIndex + OPENSPEC_MARKERS.end.length);
|
||||
|
||||
return before.trim() === '' && after.trim() === '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the OpenSpec marker block from file content.
|
||||
* Only removes markers that are on their own lines (ignores inline mentions).
|
||||
* Cleans up double blank lines that may result from removal.
|
||||
*
|
||||
* @param content - File content with OpenSpec markers
|
||||
* @returns Content with marker block removed
|
||||
*/
|
||||
export function removeMarkerBlock(content: string): string {
|
||||
return removeMarkerBlockUtil(content, OPENSPEC_MARKERS.start, OPENSPEC_MARKERS.end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of cleanup operation
|
||||
*/
|
||||
export interface CleanupResult {
|
||||
/** Files that were deleted entirely */
|
||||
deletedFiles: string[];
|
||||
/** Files that had marker blocks removed */
|
||||
modifiedFiles: string[];
|
||||
/** Directories that were deleted */
|
||||
deletedDirs: string[];
|
||||
/** Whether project.md exists and needs manual migration */
|
||||
projectMdNeedsMigration: boolean;
|
||||
/** Error messages if any operations failed */
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up legacy OpenSpec artifacts from a project.
|
||||
* Preserves openspec/project.md (shows migration hint instead of deleting).
|
||||
*
|
||||
* @param projectPath - The root path of the project
|
||||
* @param detection - Detection result from detectLegacyArtifacts
|
||||
* @returns Cleanup result with summary of actions taken
|
||||
*/
|
||||
export async function cleanupLegacyArtifacts(
|
||||
projectPath: string,
|
||||
detection: LegacyDetectionResult
|
||||
): Promise<CleanupResult> {
|
||||
const result: CleanupResult = {
|
||||
deletedFiles: [],
|
||||
modifiedFiles: [],
|
||||
deletedDirs: [],
|
||||
projectMdNeedsMigration: detection.hasProjectMd,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
// Remove marker blocks from config files (NEVER delete config files)
|
||||
// Config files like CLAUDE.md, AGENTS.md belong to the user's project root
|
||||
for (const fileName of detection.configFilesToUpdate) {
|
||||
const filePath = FileSystemUtils.joinPath(projectPath, fileName);
|
||||
try {
|
||||
const content = await FileSystemUtils.readFile(filePath);
|
||||
const newContent = removeMarkerBlock(content);
|
||||
// Always write the file, even if empty - never delete user config files
|
||||
await FileSystemUtils.writeFile(filePath, newContent);
|
||||
result.modifiedFiles.push(fileName);
|
||||
} catch (error: any) {
|
||||
result.errors.push(`Failed to modify ${fileName}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete legacy slash command directories (these are 100% OpenSpec-managed)
|
||||
for (const dirPath of detection.slashCommandDirs) {
|
||||
const fullPath = FileSystemUtils.joinPath(projectPath, dirPath);
|
||||
try {
|
||||
await fs.rm(fullPath, { recursive: true, force: true });
|
||||
result.deletedDirs.push(dirPath);
|
||||
} catch (error: any) {
|
||||
result.errors.push(`Failed to delete directory ${dirPath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete legacy slash command files (these are 100% OpenSpec-managed)
|
||||
for (const filePath of detection.slashCommandFiles) {
|
||||
const fullPath = FileSystemUtils.joinPath(projectPath, filePath);
|
||||
try {
|
||||
await fs.unlink(fullPath);
|
||||
result.deletedFiles.push(filePath);
|
||||
} catch (error: any) {
|
||||
result.errors.push(`Failed to delete ${filePath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete openspec/AGENTS.md (this is inside openspec/, it's OpenSpec-managed)
|
||||
if (detection.hasOpenspecAgents) {
|
||||
const agentsPath = FileSystemUtils.joinPath(projectPath, 'openspec', 'AGENTS.md');
|
||||
if (await FileSystemUtils.fileExists(agentsPath)) {
|
||||
try {
|
||||
await fs.unlink(agentsPath);
|
||||
result.deletedFiles.push('openspec/AGENTS.md');
|
||||
} catch (error: any) {
|
||||
result.errors.push(`Failed to delete openspec/AGENTS.md: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle root AGENTS.md with OpenSpec markers - remove markers only, NEVER delete
|
||||
// Note: Root AGENTS.md is handled via configFilesToUpdate above (it's in LEGACY_CONFIG_FILES)
|
||||
// This hasRootAgentsWithMarkers flag is just for detection, cleanup happens via configFilesToUpdate
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a cleanup summary message for display.
|
||||
*
|
||||
* @param result - Cleanup result from cleanupLegacyArtifacts
|
||||
* @returns Formatted summary string for console output
|
||||
*/
|
||||
export function formatCleanupSummary(result: CleanupResult): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (result.deletedFiles.length > 0 || result.deletedDirs.length > 0 || result.modifiedFiles.length > 0) {
|
||||
lines.push('Cleaned up legacy files:');
|
||||
|
||||
for (const file of result.deletedFiles) {
|
||||
lines.push(` ✓ Removed ${file}`);
|
||||
}
|
||||
|
||||
for (const dir of result.deletedDirs) {
|
||||
lines.push(` ✓ Removed ${dir}/ (replaced by /opsx:*)`);
|
||||
}
|
||||
|
||||
for (const file of result.modifiedFiles) {
|
||||
lines.push(` ✓ Removed OpenSpec markers from ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.projectMdNeedsMigration) {
|
||||
if (lines.length > 0) {
|
||||
lines.push('');
|
||||
}
|
||||
lines.push(formatProjectMdMigrationHint());
|
||||
}
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
if (lines.length > 0) {
|
||||
lines.push('');
|
||||
}
|
||||
lines.push('Errors during cleanup:');
|
||||
for (const error of result.errors) {
|
||||
lines.push(` ⚠ ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build list of files to be removed with explanations.
|
||||
* Only includes OpenSpec-managed files (slash commands, openspec/AGENTS.md).
|
||||
* Config files like CLAUDE.md, AGENTS.md are NEVER deleted.
|
||||
*
|
||||
* @param detection - Detection result from detectLegacyArtifacts
|
||||
* @returns Array of objects with path and explanation
|
||||
*/
|
||||
function buildRemovalsList(detection: LegacyDetectionResult): Array<{ path: string; explanation: string }> {
|
||||
const removals: Array<{ path: string; explanation: string }> = [];
|
||||
|
||||
// Slash command directories (these are 100% OpenSpec-managed)
|
||||
for (const dir of detection.slashCommandDirs) {
|
||||
// Split on both forward and backward slashes for Windows compatibility
|
||||
const toolDir = dir.split(/[\/\\]/)[0];
|
||||
removals.push({ path: dir + '/', explanation: `replaced by ${toolDir}/skills/` });
|
||||
}
|
||||
|
||||
// Slash command files (these are 100% OpenSpec-managed)
|
||||
for (const file of detection.slashCommandFiles) {
|
||||
removals.push({ path: file, explanation: 'replaced by skills/' });
|
||||
}
|
||||
|
||||
// openspec/AGENTS.md (inside openspec/, it's OpenSpec-managed)
|
||||
if (detection.hasOpenspecAgents) {
|
||||
removals.push({ path: 'openspec/AGENTS.md', explanation: 'obsolete workflow file' });
|
||||
}
|
||||
|
||||
// Note: Config files (CLAUDE.md, AGENTS.md, etc.) are NEVER in the removals list
|
||||
// They always go to the updates list where only markers are removed
|
||||
|
||||
return removals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build list of files to be updated with explanations.
|
||||
* Includes ALL config files with markers - markers are removed, file is never deleted.
|
||||
*
|
||||
* @param detection - Detection result from detectLegacyArtifacts
|
||||
* @returns Array of objects with path and explanation
|
||||
*/
|
||||
function buildUpdatesList(detection: LegacyDetectionResult): Array<{ path: string; explanation: string }> {
|
||||
const updates: Array<{ path: string; explanation: string }> = [];
|
||||
|
||||
// All config files with markers get updated (markers removed, file preserved)
|
||||
for (const file of detection.configFilesToUpdate) {
|
||||
updates.push({ path: file, explanation: 'removing OpenSpec markers' });
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a detection summary message for display before cleanup.
|
||||
* Groups files by action type: removals, updates, and manual migration.
|
||||
*
|
||||
* @param detection - Detection result from detectLegacyArtifacts
|
||||
* @returns Formatted summary string showing what was found
|
||||
*/
|
||||
export function formatDetectionSummary(detection: LegacyDetectionResult): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
const removals = buildRemovalsList(detection);
|
||||
const updates = buildUpdatesList(detection);
|
||||
|
||||
// If nothing to show, return empty
|
||||
if (removals.length === 0 && updates.length === 0 && !detection.hasProjectMd) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Header - welcoming upgrade message
|
||||
lines.push(chalk.bold('Upgrading to the new OpenSpec'));
|
||||
lines.push('');
|
||||
lines.push('OpenSpec now uses agent skills, the emerging standard across coding');
|
||||
lines.push('agents. This simplifies your setup while keeping everything working');
|
||||
lines.push('as before.');
|
||||
lines.push('');
|
||||
|
||||
// Section 1: Files to remove (no user content to preserve)
|
||||
if (removals.length > 0) {
|
||||
lines.push(chalk.bold('Files to remove'));
|
||||
lines.push(chalk.dim('No user content to preserve:'));
|
||||
for (const { path } of removals) {
|
||||
lines.push(` • ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Section 2: Files to update (markers removed, content preserved)
|
||||
if (updates.length > 0) {
|
||||
if (removals.length > 0) lines.push('');
|
||||
lines.push(chalk.bold('Files to update'));
|
||||
lines.push(chalk.dim('OpenSpec markers will be removed, your content preserved:'));
|
||||
for (const { path } of updates) {
|
||||
lines.push(` • ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Section 3: Manual migration (project.md)
|
||||
if (detection.hasProjectMd) {
|
||||
if (removals.length > 0 || updates.length > 0) lines.push('');
|
||||
lines.push(formatProjectMdMigrationHint());
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tool IDs from detected legacy artifacts.
|
||||
* Uses LEGACY_SLASH_COMMAND_PATHS to map paths back to tool IDs.
|
||||
*
|
||||
* @param detection - Detection result from detectLegacyArtifacts
|
||||
* @returns Array of tool IDs that had legacy artifacts
|
||||
*/
|
||||
export function getToolsFromLegacyArtifacts(detection: LegacyDetectionResult): string[] {
|
||||
const tools = new Set<string>();
|
||||
|
||||
// Match directories to tool IDs
|
||||
for (const dir of detection.slashCommandDirs) {
|
||||
for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
|
||||
if (pattern.type === 'directory' && pattern.path === dir) {
|
||||
tools.add(toolId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Match files to tool IDs using glob patterns
|
||||
for (const file of detection.slashCommandFiles) {
|
||||
// Normalize file path to use forward slashes for consistent matching (Windows compatibility)
|
||||
const normalizedFile = file.replace(/\\/g, '/');
|
||||
for (const [toolId, pattern] of Object.entries(LEGACY_SLASH_COMMAND_PATHS)) {
|
||||
if (pattern.type === 'files' && pattern.pattern) {
|
||||
// Convert glob pattern to regex for matching
|
||||
// e.g., '.cursor/commands/openspec-*.md' -> /^\.cursor\/commands\/openspec-.*\.md$/
|
||||
const regexPattern = pattern.pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except *
|
||||
.replace(/\*/g, '.*'); // Replace * with .*
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
if (regex.test(normalizedFile)) {
|
||||
tools.add(toolId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(tools);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a migration hint message for project.md.
|
||||
* This is shown when project.md exists and needs manual migration to config.yaml.
|
||||
*
|
||||
* @returns Formatted migration hint string for console output
|
||||
*/
|
||||
export function formatProjectMdMigrationHint(): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(chalk.yellow.bold('Needs your attention'));
|
||||
lines.push(' • openspec/project.md');
|
||||
lines.push(chalk.dim(' We won\'t delete this file. It may contain useful project context.'));
|
||||
lines.push('');
|
||||
lines.push(chalk.dim(' The new openspec/config.yaml has a "context:" section for planning'));
|
||||
lines.push(chalk.dim(' context. This is included in every OpenSpec request and works more'));
|
||||
lines.push(chalk.dim(' reliably than the old project.md approach.'));
|
||||
lines.push('');
|
||||
lines.push(chalk.dim(' Review project.md, move any useful content to config.yaml\'s context'));
|
||||
lines.push(chalk.dim(' section, then delete the file when ready.'));
|
||||
return lines.join('\n');
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user