mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-04 22:47:37 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4afafcb2a3 | ||
|
|
9b6a763eb8 | ||
|
|
d32e50fe36 | ||
|
|
8386b91a71 | ||
|
|
8f9c3c7d0b | ||
|
|
9cdb0743f2 | ||
|
|
4e93d7a881 | ||
|
|
c4b6be41c1 | ||
|
|
a66580735c | ||
|
|
fb1d37e56e | ||
|
|
cf0de5e569 | ||
|
|
92b45462c6 | ||
|
|
5ab438f5fd | ||
|
|
5855fa2353 | ||
|
|
668a125d4d | ||
|
|
fef961f6e3 | ||
|
|
3677e0175f | ||
|
|
3ddf2586b4 | ||
|
|
ece61a6d68 | ||
|
|
ecddffc22e | ||
|
|
822464ec44 | ||
|
|
67ab683105 | ||
|
|
f82e243551 | ||
|
|
88b260d51f | ||
|
|
ce7422209f | ||
|
|
63b8a3e9f9 | ||
|
|
4cf7bf863d | ||
|
|
b30882b579 | ||
|
|
082abb4795 | ||
|
|
b81fa1e6cc |
@@ -0,0 +1,11 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
# Minimal configuration for getting started
|
||||
language: "en-US"
|
||||
reviews:
|
||||
profile: "chill"
|
||||
high_level_summary: true
|
||||
auto_review:
|
||||
enabled: true
|
||||
drafts: false
|
||||
base_branches:
|
||||
- ".*"
|
||||
@@ -0,0 +1,92 @@
|
||||
# Dev Container Setup
|
||||
|
||||
This directory contains the VS Code dev container configuration for OpenSpec development.
|
||||
|
||||
## What's Included
|
||||
|
||||
- **Node.js 20 LTS** (>=20.19.0) - TypeScript/JavaScript runtime
|
||||
- **pnpm** - Fast, disk space efficient package manager
|
||||
- **Git + GitHub CLI** - Version control tools
|
||||
- **VS Code Extensions**:
|
||||
- ESLint & Prettier for code quality
|
||||
- Vitest Explorer for running tests
|
||||
- GitLens for enhanced git integration
|
||||
- Error Lens for inline error highlighting
|
||||
- Code Spell Checker
|
||||
- Path IntelliSense
|
||||
|
||||
## How to Use
|
||||
|
||||
### First Time Setup
|
||||
|
||||
1. **Install Prerequisites** (on your local machine):
|
||||
- [VS Code](https://code.visualstudio.com/)
|
||||
- [Docker Desktop](https://www.docker.com/products/docker-desktop)
|
||||
- [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
|
||||
|
||||
2. **Open in Container**:
|
||||
- Open this project in VS Code
|
||||
- You'll see a notification: "Folder contains a Dev Container configuration file"
|
||||
- Click "Reopen in Container"
|
||||
|
||||
OR
|
||||
|
||||
- Open Command Palette (`Cmd/Ctrl+Shift+P`)
|
||||
- Type "Dev Containers: Reopen in Container"
|
||||
- Press Enter
|
||||
|
||||
3. **Wait for Setup**:
|
||||
- The container will build (first time takes a few minutes)
|
||||
- `pnpm install` runs automatically via `postCreateCommand`
|
||||
- All extensions install automatically
|
||||
|
||||
### Daily Development
|
||||
|
||||
Once set up, the container preserves your development environment:
|
||||
|
||||
```bash
|
||||
# Run development build
|
||||
pnpm run dev
|
||||
|
||||
# Run CLI in development
|
||||
pnpm run dev:cli
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
|
||||
# Run tests in watch mode
|
||||
pnpm test:watch
|
||||
|
||||
# Build the project
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
### SSH Keys
|
||||
|
||||
Your SSH keys are mounted read-only from `~/.ssh`, so git operations work seamlessly with GitHub/GitLab.
|
||||
|
||||
### Rebuilding the Container
|
||||
|
||||
If you modify `.devcontainer/devcontainer.json`:
|
||||
- Command Palette → "Dev Containers: Rebuild Container"
|
||||
|
||||
## Benefits
|
||||
|
||||
- No need to install Node.js or pnpm on your local machine
|
||||
- Consistent development environment across team members
|
||||
- Isolated from other Node.js projects on your machine
|
||||
- All dependencies and tools containerized
|
||||
- Easy onboarding for new developers
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Container won't build:**
|
||||
- Ensure Docker Desktop is running
|
||||
- Check Docker has enough memory allocated (recommend 4GB+)
|
||||
|
||||
**Extensions not appearing:**
|
||||
- Rebuild the container: "Dev Containers: Rebuild Container"
|
||||
|
||||
**Permission issues:**
|
||||
- The container runs as the `node` user (non-root)
|
||||
- Files created in the container are owned by this user
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "OpenSpec Development",
|
||||
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bookworm",
|
||||
|
||||
// Additional tools and features
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/git:1": {
|
||||
"version": "latest",
|
||||
"ppa": true
|
||||
},
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {
|
||||
"version": "latest"
|
||||
}
|
||||
},
|
||||
|
||||
// Configure tool-specific properties
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
// Set default container specific settings
|
||||
"settings": {
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit"
|
||||
},
|
||||
"files.eol": "\n",
|
||||
"terminal.integrated.defaultProfile.linux": "bash"
|
||||
},
|
||||
|
||||
// Add extensions you want installed when the container is created
|
||||
"extensions": [
|
||||
// TypeScript/JavaScript essentials
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
|
||||
// Testing
|
||||
"vitest.explorer",
|
||||
|
||||
// Git
|
||||
"eamodio.gitlens",
|
||||
|
||||
// Utilities
|
||||
"streetsidesoftware.code-spell-checker",
|
||||
"usernamehw.errorlens",
|
||||
"christian-kohler.path-intellisense"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally
|
||||
// "forwardPorts": [],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created
|
||||
"postCreateCommand": "corepack enable && corepack prepare pnpm@latest --activate && pnpm install",
|
||||
|
||||
// Configure mounts to preserve SSH keys for git operations
|
||||
"mounts": [
|
||||
"source=${localEnv:HOME}${localEnv:USERPROFILE}/.ssh,target=/home/node/.ssh,readonly,type=bind,consistency=cached"
|
||||
],
|
||||
|
||||
// Set the default user to 'node' (non-root user)
|
||||
"remoteUser": "node",
|
||||
|
||||
// Ensure git is properly configured
|
||||
"initializeCommand": "echo 'Initializing dev container...'"
|
||||
}
|
||||
@@ -147,3 +147,6 @@ docs/
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
.DS_Store
|
||||
|
||||
# Pnpm
|
||||
.pnpm-store/
|
||||
|
||||
@@ -1,5 +1,61 @@
|
||||
# @fission-ai/openspec
|
||||
|
||||
## 0.14.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 8386b91: Add support for new AI assistants and configuration improvements
|
||||
|
||||
- feat: add Qwen Code support with slash command integration
|
||||
- feat: add $ARGUMENTS support to apply slash command for dynamic variable passing
|
||||
- feat: add Qoder CLI support to configuration and documentation
|
||||
- feat: add CoStrict AI assistant support
|
||||
- fix: recreate missing openspec template files in extend mode
|
||||
- fix: prevent false 'already configured' detection for tools
|
||||
- fix: use change-id as fallback title instead of "Untitled Change"
|
||||
- docs: add guidance for populating project-level context
|
||||
- docs: add Crush to supported AI tools in README
|
||||
|
||||
## 0.13.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 668a125: Add support for multiple AI assistants and improve validation
|
||||
|
||||
This release adds support for several new AI coding assistants:
|
||||
|
||||
- CodeBuddy Code - AI-powered coding assistant
|
||||
- CodeRabbit - AI code review assistant
|
||||
- Cline - Claude-powered CLI assistant
|
||||
- Crush AI - AI assistant platform
|
||||
- Auggie (Augment CLI) - Code augmentation tool
|
||||
|
||||
New features:
|
||||
|
||||
- Archive slash command now supports arguments for more flexible workflows
|
||||
|
||||
Bug fixes:
|
||||
|
||||
- Delta spec validation now handles case-insensitive headers and properly detects empty sections
|
||||
- Archive validation now correctly honors --no-validate flag and ignores metadata
|
||||
|
||||
Documentation improvements:
|
||||
|
||||
- Added VS Code dev container configuration for easier development setup
|
||||
- Updated AGENTS.md with explicit change-id notation
|
||||
- Enhanced slash commands documentation with restart notes
|
||||
|
||||
## 0.12.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 082abb4: Add factory function support for slash commands and non-interactive init options
|
||||
|
||||
This release includes two new features:
|
||||
|
||||
- **Factory function support for slash commands**: Slash commands can now be defined as functions that return command objects, enabling dynamic command configuration
|
||||
- **Non-interactive init options**: Added `--tools`, `--all-tools`, and `--skip-tools` CLI flags to `openspec init` for automated initialization in CI/CD pipelines while maintaining backward compatibility with interactive mode
|
||||
|
||||
## 0.11.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -91,13 +91,23 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe
|
||||
| Tool | Commands |
|
||||
|------|----------|
|
||||
| **Claude Code** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` |
|
||||
| **CodeBuddy Code (CLI)** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.codebuddy/commands/`) — see [docs](https://www.codebuddy.ai/cli) |
|
||||
| **CoStrict** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.cospec/openspec/commands/`) — see [docs](https://costrict.ai)|
|
||||
| **Cursor** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
|
||||
| **Cline** | Rules in `.clinerules/` directory (`.clinerules/openspec-*.md`) |
|
||||
| **Crush** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.crush/commands/openspec/`) |
|
||||
| **Factory Droid** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.factory/commands/`) |
|
||||
| **Gemini CLI** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.gemini/commands/openspec/`) |
|
||||
| **OpenCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
|
||||
| **Kilo Code** | `/openspec-proposal.md`, `/openspec-apply.md`, `/openspec-archive.md` (`.kilocode/workflows/`) |
|
||||
| **Qoder (CLI)** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.qoder/commands/openspec/`) — see [docs](https://qoder.com/cli) |
|
||||
| **Windsurf** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.windsurf/workflows/`) |
|
||||
| **Codex** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (global: `~/.codex/prompts`, auto-installed) |
|
||||
| **GitHub Copilot** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.github/prompts/`) |
|
||||
| **Amazon Q Developer** | `@openspec-proposal`, `@openspec-apply`, `@openspec-archive` (`.amazonq/prompts/`) |
|
||||
| **Auggie (Augment CLI)** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.augment/commands/`) |
|
||||
| **Qwen Code** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.qwen/commands/`) |
|
||||
|
||||
|
||||
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`.
|
||||
|
||||
@@ -106,7 +116,7 @@ These tools automatically read workflow instructions from `openspec/AGENTS.md`.
|
||||
|
||||
| Tools |
|
||||
|-------|
|
||||
| Amp • Jules • Gemini CLI • Others |
|
||||
| Amp • Jules • Others |
|
||||
|
||||
### Install & Initialize
|
||||
|
||||
@@ -137,27 +147,26 @@ openspec init
|
||||
```
|
||||
|
||||
**What happens during initialization:**
|
||||
- You'll be prompted to pick any natively supported AI tools (Claude Code, Cursor, OpenCode, etc.); other assistants always rely on the shared `AGENTS.md` stub
|
||||
- You'll be prompted to pick any natively supported AI tools (Claude Code, CodeBuddy, Cursor, OpenCode, Qoder,etc.); other assistants always rely on the shared `AGENTS.md` stub
|
||||
- OpenSpec automatically configures slash commands for the tools you choose and always writes a managed `AGENTS.md` hand-off at the project root
|
||||
- A new `openspec/` directory structure is created in your project
|
||||
|
||||
**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
|
||||
|
||||
### Factory Droid custom commands
|
||||
### Optional: Populate Project Context
|
||||
|
||||
Factory Droid turns every file in `.factory/commands` into a slash command. Repos can ship shared commands under `<repo>/.factory/commands` while private shortcuts live in `~/.factory/commands`; the workspace copy wins when both exist.【F:openspec/changes/add-factory-slash-scaffold/specs/factory-cli/spec.md†L5-L16】 Only Markdown files and executables with a shebang are registered, and filenames are slugged to lowercase with spaces converted to hyphens so `/Code Review.mdx` becomes `/code-review`.【F:openspec/changes/add-factory-slash-scaffold/specs/factory-cli/spec.md†L8-L23】
|
||||
After `openspec init` completes, you'll receive a suggested prompt to help populate your project context:
|
||||
|
||||
Use the OpenSpec CLI to scaffold those files without memorizing Factory’s frontmatter:
|
||||
|
||||
```bash
|
||||
openspec factory slash "Code Review" \
|
||||
--description "Send a code review checklist" \
|
||||
--argument-hint "<branch-name>"
|
||||
```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"
|
||||
```
|
||||
|
||||
The command creates `.factory/commands/code-review.md` with YAML frontmatter, `$ARGUMENTS` placeholders, and helpful TODOs. Add `--personal` to target `~/.factory/commands`, `--executable` to generate a `code-review.sh` script with a ready-to-edit Bash template, or `--force` to overwrite an existing file. Executable templates include a proper shebang and `set -euo pipefail` so they run inside Factory just like the docs describe.【F:openspec/changes/add-factory-slash-scaffold/specs/factory-cli/spec.md†L8-L47】
|
||||
Use `openspec/project.md` to define project-level conventions, standards, architectural patterns, and other guidelines that should be followed across all changes.
|
||||
|
||||
### Create Your First Change
|
||||
|
||||
@@ -224,7 +233,7 @@ Or run the command yourself in terminal:
|
||||
$ openspec archive add-profile-filters --yes # Archive the completed change without prompts
|
||||
```
|
||||
|
||||
**Note:** Tools with native slash commands (Claude Code, Cursor, Codex) can use the shortcuts shown. All other tools work with natural language requests to "create an OpenSpec proposal", "apply the OpenSpec change", or "archive the change".
|
||||
**Note:** Tools with native slash commands (Claude Code, CodeBuddy, Cursor, Codex, Qoder) 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
|
||||
|
||||
@@ -336,7 +345,7 @@ Without specs, AI coding assistants generate code from vague prompts, often miss
|
||||
1. **Initialize OpenSpec** – Run `openspec init` in your repo.
|
||||
2. **Start with new features** – Ask your AI to capture upcoming work as change proposals.
|
||||
3. **Grow incrementally** – Each change archives into living specs that document your system.
|
||||
4. **Stay flexible** – Different teammates can use Claude Code, Cursor, or any AGENTS.md-compatible tool while sharing the same specs.
|
||||
4. **Stay flexible** – Different teammates can use Claude Code, CodeBuddy, Cursor, or any AGENTS.md-compatible tool while sharing the same specs.
|
||||
|
||||
Run `openspec update` whenever someone switches tools so your agents pick up the latest instructions and slash-command bindings.
|
||||
|
||||
|
||||
@@ -49,9 +49,7 @@ _Outcome:_ We prevent data loss immediately while we work on a richer merge stor
|
||||
- On conflict, write conflict markers inside the change delta (similar to Git) and require the author to hand-edit before re-running validation.
|
||||
2. **Enrich validator messages.**
|
||||
- `openspec validate` should flag unresolved conflict markers or fingerprint mismatches so errors appear early in the workflow.
|
||||
3. **Improve diff tooling.**
|
||||
- Extend `openspec diff` to compare change deltas against the live spec and highlight pending merges.
|
||||
4. **Optional:** Offer a `--rewrite-scenarios` helper that merges bullet lists of scenarios to reduce manual editing noise.
|
||||
3. **Optional:** Offer a `--rewrite-scenarios` helper that merges bullet lists of scenarios to reduce manual editing noise.
|
||||
|
||||
_Outcome:_ Contributors can safely reconcile their work with the latest spec before archiving, restoring true parallel development.
|
||||
|
||||
|
||||
+3
-5
@@ -60,7 +60,7 @@ Track these steps as TODOs and complete them one by one.
|
||||
After deployment, create separate PR to:
|
||||
- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/`
|
||||
- Update `specs/` if capabilities changed
|
||||
- Use `openspec archive [change] --skip-specs --yes` for tooling-only changes
|
||||
- Use `openspec archive <change-id> --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly)
|
||||
- Run `openspec validate --strict` to confirm the archived change passes checks
|
||||
|
||||
## Before Any Task
|
||||
@@ -95,9 +95,8 @@ After deployment, create separate PR to:
|
||||
openspec list # List active changes
|
||||
openspec list --specs # List specifications
|
||||
openspec show [item] # Display change or spec
|
||||
openspec diff [change] # Show spec differences
|
||||
openspec validate [item] # Validate changes or specs
|
||||
openspec archive [change] [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
|
||||
openspec archive <change-id> [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
|
||||
|
||||
# Project management
|
||||
openspec init [path] # Initialize OpenSpec
|
||||
@@ -448,9 +447,8 @@ Only add complexity with:
|
||||
```bash
|
||||
openspec list # What's in progress?
|
||||
openspec show [item] # View details
|
||||
openspec diff [change] # What's changing?
|
||||
openspec validate --strict # Is it correct?
|
||||
openspec archive [change] [--yes|-y] # Mark complete (add --yes for automation)
|
||||
openspec archive <change-id> [--yes|-y] # Mark complete (add --yes for automation)
|
||||
```
|
||||
|
||||
Remember: Specs are truth. Changes are proposals. Keep them in sync.
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
## Why
|
||||
Factory Droid lets engineers speed up reviews and deployment rituals by loading custom slash commands from `.factory/commands`. Without tooling, every team member has to remember naming rules, directory locations, and Markdown frontmatter details from the Factory docs. This friction keeps the workflow underused and risks inconsistently formatted prompts.
|
||||
|
||||
## What Changes
|
||||
- Add an `openspec factory slash <name>` command that scaffolds Factory Droid custom commands as Markdown prompts or executable scripts using the conventions from the Factory documentation.
|
||||
- Provide options for setting description text, argument hints, personal vs workspace scope, and executable mode so teams can generate the files they need without manual boilerplate.
|
||||
- Update documentation/specs to describe the Factory integration and ensure the generated files respect Factory’s slugging and directory rules.
|
||||
|
||||
## Impact
|
||||
- Affected specs: `specs/factory-cli`
|
||||
- Affected code: `src/cli/index.ts`, `src/commands/factory.ts`, `src/utils/slug.ts`, `README.md`
|
||||
@@ -1,47 +0,0 @@
|
||||
## ADDED Requirements
|
||||
### Requirement: Slash command scaffolding
|
||||
The CLI MUST provide `openspec factory slash <name>` to scaffold Factory Droid custom slash commands following the documented conventions.
|
||||
|
||||
#### Scenario: Create workspace markdown command
|
||||
- **GIVEN** the current directory has no `.factory/commands` folder
|
||||
- **WHEN** `openspec factory slash "Code Review" --description "Send a code review checklist" --argument-hint "<branch-name>"` runs without additional flags
|
||||
- **THEN** the CLI creates `.factory/commands/code-review.md`
|
||||
- **AND** the file contains YAML frontmatter with `description` and `argument-hint`
|
||||
- **AND** the body includes a placeholder referencing `$ARGUMENTS`
|
||||
- **AND** the command slug is lowercase with spaces converted to hyphens and non URL-safe characters removed.
|
||||
|
||||
#### Scenario: Personal command directory
|
||||
- **WHEN** `openspec factory slash deploy --personal` is executed
|
||||
- **THEN** the CLI creates (or reuses) the `~/.factory/commands` directory
|
||||
- **AND** writes `deploy.md` into that directory instead of the workspace folder.
|
||||
|
||||
#### Scenario: Executable command template
|
||||
- **WHEN** `openspec factory slash deploy --executable` runs
|
||||
- **THEN** the CLI writes `.factory/commands/deploy.sh`
|
||||
- **AND** the file begins with a shebang line (e.g., `#!/usr/bin/env bash`)
|
||||
- **AND** the template includes `set -euo pipefail`
|
||||
- **AND** `$1` receives the first argument passed to the slash command.
|
||||
|
||||
#### Scenario: Prevent accidental overwrite
|
||||
- **GIVEN** `.factory/commands/checklist.md` already exists
|
||||
- **WHEN** `openspec factory slash checklist` runs without `--force`
|
||||
- **THEN** the CLI aborts with an explanatory error instead of overwriting the file.
|
||||
|
||||
#### Scenario: Force overwrite of existing command
|
||||
- **WHEN** `openspec factory slash checklist --force` runs and the file exists
|
||||
- **THEN** the CLI replaces the file contents with the new template.
|
||||
|
||||
### Requirement: Option handling and feedback
|
||||
The CLI MUST provide helpful options and output when generating commands.
|
||||
|
||||
#### Scenario: Display success message
|
||||
- **WHEN** a command file is generated successfully
|
||||
- **THEN** the CLI prints the absolute path and how to trigger the slash command (e.g., `/code-review`).
|
||||
|
||||
#### Scenario: Argument validation
|
||||
- **WHEN** the provided name is empty or resolves to an empty slug
|
||||
- **THEN** the CLI exits with a validation error explaining that only letters, numbers, spaces, dashes, and underscores are supported.
|
||||
|
||||
#### Scenario: Description and argument hint defaults
|
||||
- **WHEN** description or argument hint values are omitted
|
||||
- **THEN** the CLI still emits valid frontmatter and leaves missing fields blank so Factory can register the command.
|
||||
@@ -1,12 +0,0 @@
|
||||
## 1. Research Factory command conventions
|
||||
- [x] 1.1 Review Factory docs for `.factory/commands` discovery, slug rules, Markdown frontmatter, and executable requirements.
|
||||
|
||||
## 2. CLI implementation
|
||||
- [x] 2.1 Add a `factory` command group with a `slash <name>` subcommand in `src/cli/index.ts`.
|
||||
- [x] 2.2 Implement generator logic that slugs names, creates the correct target directory (`.factory/commands` or `~/.factory/commands`), and writes Markdown or executable templates without clobbering existing files unless `--force` is used.
|
||||
- [x] 2.3 Support options for description, argument hints, executable mode, personal scope, and force overwrites.
|
||||
|
||||
## 3. Documentation and validation
|
||||
- [x] 3.1 Add a new spec describing the Factory CLI scaffold behavior with scenarios for Markdown and executable outputs.
|
||||
- [x] 3.2 Update docs/quickstart material so agents understand how to generate Factory slash commands with the new CLI command.
|
||||
- [x] 3.3 Run `openspec validate add-factory-slash-scaffold --strict` and ensure checklists are updated.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Add Archive Command Arguments
|
||||
|
||||
## Why
|
||||
The `/openspec:archive` slash command currently lacks argument support, forcing the AI to infer which change to archive from conversation context or by listing all changes. This creates a safety risk where the wrong proposal could be archived if the context is ambiguous or multiple changes exist. Users expect to specify the change ID explicitly, matching the behavior of the CLI command `openspec archive <id>`.
|
||||
|
||||
## What Changes
|
||||
- Add `$ARGUMENTS` placeholder to the OpenCode archive slash command frontmatter (matching existing pattern for proposal command)
|
||||
- Update archive command template steps to validate the specific change ID argument when provided
|
||||
- Note: Codex, GitHub Copilot, and Amazon Q already have `$ARGUMENTS` for archive; Claude/Cursor/Windsurf/Kilocode don't support arguments
|
||||
|
||||
## Impact
|
||||
- Affected specs: `cli-update` (slash command generation logic)
|
||||
- Affected code:
|
||||
- `src/core/configurators/slash/opencode.ts` (add `$ARGUMENTS` to archive frontmatter)
|
||||
- `src/core/templates/slash-command-templates.ts` (archive template steps for argument validation)
|
||||
- Breaking: No - this is additive functionality that makes the command safer
|
||||
- User-facing: Yes - OpenCode users will be able to pass the change ID as an argument: `/openspec:archive <change-id>`
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
# CLI Update Specification Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Slash Command Updates
|
||||
The update command SHALL refresh existing slash command files for configured tools without creating new ones, and ensure the OpenCode archive command accepts change ID arguments.
|
||||
|
||||
#### Scenario: Updating slash commands for OpenCode
|
||||
- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
- **AND** ensure the archive command includes `$ARGUMENTS` placeholder in frontmatter for accepting change ID arguments
|
||||
|
||||
### Requirement: Archive Command Argument Support
|
||||
The archive slash command template SHALL support optional change ID arguments for tools that support `$ARGUMENTS` placeholder.
|
||||
|
||||
#### Scenario: Archive command with change ID argument
|
||||
- **WHEN** a user invokes `/openspec:archive <change-id>` with a change ID
|
||||
- **THEN** the template SHALL instruct the AI to validate the provided change ID against `openspec list`
|
||||
- **AND** use the provided change ID for archiving if valid
|
||||
- **AND** fail fast if the provided change ID doesn't match an archivable change
|
||||
|
||||
#### Scenario: Archive command without argument (backward compatibility)
|
||||
- **WHEN** a user invokes `/openspec:archive` without providing a change ID
|
||||
- **THEN** the template SHALL instruct the AI to identify the change ID from context or by running `openspec list`
|
||||
- **AND** proceed with the existing behavior (maintaining backward compatibility)
|
||||
|
||||
#### Scenario: OpenCode archive template generation
|
||||
- **WHEN** generating the OpenCode archive slash command file
|
||||
- **THEN** include the `$ARGUMENTS` placeholder in the frontmatter
|
||||
- **AND** wrap it in a clear structure like `<ChangeId>\n $ARGUMENTS\n</ChangeId>` to indicate the expected argument
|
||||
- **AND** include validation steps in the template body to check if the change ID is valid
|
||||
@@ -0,0 +1,15 @@
|
||||
# Implementation Tasks
|
||||
|
||||
## 1. Update OpenCode Configurator
|
||||
- [x] 1.1 Add `$ARGUMENTS` placeholder to OpenCode archive frontmatter (matching the proposal pattern)
|
||||
- [x] 1.2 Format it as `<ChangeId>\n $ARGUMENTS\n</ChangeId>` or similar structure for clarity
|
||||
- [x] 1.3 Ensure `updateExisting` rewrites the archive frontmatter/body so `$ARGUMENTS` persists after `openspec update`
|
||||
|
||||
## 2. Update Slash Command Templates
|
||||
- [x] 2.1 Modify archive steps to validate change ID argument when provided via `$ARGUMENTS`
|
||||
- [x] 2.2 Keep backward compatibility - allow inferring from context if no argument provided
|
||||
- [x] 2.3 Add step to validate the change ID exists using `openspec list` before archiving
|
||||
|
||||
## 3. Update Documentation
|
||||
- [x] 3.1 Update AGENTS.md archive examples to show argument usage
|
||||
- [x] 3.2 Document that OpenCode now supports `/openspec:archive <change-id>`
|
||||
@@ -0,0 +1,15 @@
|
||||
## Why
|
||||
Add support for Cline (VS Code extension) in OpenSpec to enable developers to use Cline's AI-powered coding capabilities for spec-driven development workflows.
|
||||
|
||||
## What Changes
|
||||
- Add Cline slash command configurator for proposal, apply, and archive operations
|
||||
- Add Cline root CLINE.md configurator for project-level instructions
|
||||
- Add Cline template exports
|
||||
- Update tool and slash command registries to include Cline
|
||||
- Add comprehensive test coverage
|
||||
- **BREAKING**: None - this is additive functionality
|
||||
|
||||
## Impact
|
||||
- Affected specs: cli-init (new tool option)
|
||||
- Affected code: src/core/configurators/slash/cline.ts, src/core/configurators/cline.ts, registry files
|
||||
- New files: .clinerules/openspec-*.md, CLINE.md
|
||||
@@ -0,0 +1,97 @@
|
||||
## MODIFIED Requirements
|
||||
### Requirement: AI Tool Configuration Details
|
||||
|
||||
The command SHALL properly configure selected AI tools with OpenSpec-specific instructions using a marker system.
|
||||
|
||||
#### Scenario: Configuring Claude Code
|
||||
|
||||
- **WHEN** Claude Code is selected
|
||||
- **THEN** create or update `CLAUDE.md` in the project root directory (not inside openspec/)
|
||||
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
|
||||
|
||||
#### Scenario: Configuring CodeBuddy Code
|
||||
|
||||
- **WHEN** CodeBuddy Code is selected
|
||||
- **THEN** create or update `CODEBUDDY.md` in the project root directory (not inside openspec/)
|
||||
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
|
||||
|
||||
#### Scenario: Configuring Cline
|
||||
|
||||
- **WHEN** Cline is selected
|
||||
- **THEN** create or update `CLINE.md` in the project root directory (not inside openspec/)
|
||||
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
|
||||
|
||||
#### Scenario: Creating new CLAUDE.md
|
||||
|
||||
- **WHEN** CLAUDE.md does not exist
|
||||
- **THEN** create new file with stub instructions wrapped in markers so the full workflow stays in `openspec/AGENTS.md`:
|
||||
```markdown
|
||||
<!-- OPENSPEC:START -->
|
||||
# OpenSpec Instructions
|
||||
|
||||
This project uses OpenSpec to manage AI assistant workflows.
|
||||
|
||||
- Full guidance lives in '@/openspec/AGENTS.md'.
|
||||
- Keep this managed block so 'openspec update' can refresh the instructions.
|
||||
<!-- OPENSPEC:END -->
|
||||
```
|
||||
|
||||
### Requirement: Slash Command Configuration
|
||||
The init command SHALL generate slash command files for supported editors using shared templates.
|
||||
|
||||
#### Scenario: Generating slash commands for Claude Code
|
||||
- **WHEN** the user selects Claude Code during initialization
|
||||
- **THEN** create `.claude/commands/openspec/proposal.md`, `.claude/commands/openspec/apply.md`, and `.claude/commands/openspec/archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for CodeBuddy Code
|
||||
- **WHEN** the user selects CodeBuddy Code during initialization
|
||||
- **THEN** create `.codebuddy/commands/openspec/proposal.md`, `.codebuddy/commands/openspec/apply.md`, and `.codebuddy/commands/openspec/archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Cline
|
||||
- **WHEN** the user selects Cline during initialization
|
||||
- **THEN** create `.clinerules/openspec-proposal.md`, `.clinerules/openspec-apply.md`, and `.clinerules/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** include Cline-specific Markdown heading frontmatter
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Cursor
|
||||
- **WHEN** the user selects Cursor during initialization
|
||||
- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for OpenCode
|
||||
- **WHEN** the user selects OpenCode during initialization
|
||||
- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Windsurf
|
||||
- **WHEN** the user selects Windsurf during initialization
|
||||
- **THEN** create `.windsurf/workflows/openspec-proposal.md`, `.windsurf/workflows/openspec-apply.md`, and `.windsurf/workflows/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Kilo Code
|
||||
- **WHEN** the user selects Kilo Code during initialization
|
||||
- **THEN** create `.kilocode/workflows/openspec-proposal.md`, `.kilocode/workflows/openspec-apply.md`, and `.kilocode/workflows/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Codex
|
||||
- **WHEN** the user selects Codex during initialization
|
||||
- **THEN** create global prompt files at `~/.codex/prompts/openspec-proposal.md`, `~/.codex/prompts/openspec-apply.md`, and `~/.codex/prompts/openspec-archive.md` (or under `$CODEX_HOME/prompts` if set)
|
||||
- **AND** populate each file from shared templates that map the first numbered placeholder (`$1`) to the primary user input (e.g., change identifier or question text)
|
||||
- **AND** wrap the generated content in OpenSpec markers so `openspec update` can refresh the prompts without touching surrounding custom notes
|
||||
|
||||
#### Scenario: Generating slash commands for GitHub Copilot
|
||||
- **WHEN** the user selects GitHub Copilot during initialization
|
||||
- **THEN** create `.github/prompts/openspec-proposal.prompt.md`, `.github/prompts/openspec-apply.prompt.md`, and `.github/prompts/openspec-archive.prompt.md`
|
||||
- **AND** populate each file with YAML frontmatter containing a `description` field that summarizes the workflow stage
|
||||
- **AND** include `$ARGUMENTS` placeholder to capture user input
|
||||
- **AND** wrap the shared template body with OpenSpec markers so `openspec update` can refresh the content
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
@@ -0,0 +1,19 @@
|
||||
## 1. Implementation
|
||||
- [x] 1.1 Create ClineSlashCommandConfigurator class in src/core/configurators/slash/cline.ts
|
||||
- [x] 1.2 Create ClineConfigurator class in src/core/configurators/cline.ts
|
||||
- [x] 1.3 Create cline-template.ts for template exports
|
||||
- [x] 1.4 Define file paths for Cline rules (.clinerules/)
|
||||
- [x] 1.5 Create Cline-specific frontmatter (Markdown heading format)
|
||||
- [x] 1.6 Register Cline in slash/registry.ts
|
||||
- [x] 1.7 Register Cline in configurators/registry.ts
|
||||
- [x] 1.8 Add Cline to AI_TOOLS in config.ts
|
||||
- [x] 1.9 Add getClineTemplate() to templates/index.ts
|
||||
- [x] 1.10 Update README with Cline documentation
|
||||
|
||||
## 2. Testing
|
||||
- [x] 2.1 Add init tests for CLINE.md creation and updates
|
||||
- [x] 2.2 Add init tests for .clinerules/ file creation
|
||||
- [x] 2.3 Add update tests for CLINE.md updates
|
||||
- [x] 2.4 Add update tests for .clinerules/ file refreshes
|
||||
- [x] 2.5 Test integration with openspec init --tools cline
|
||||
- [x] 2.6 Verify all 225 tests pass
|
||||
@@ -0,0 +1,13 @@
|
||||
## Why
|
||||
Add support for Crush AI assistant in OpenSpec to enable developers to use Crush's enhanced capabilities for spec-driven development workflows.
|
||||
|
||||
## What Changes
|
||||
- Add Crush slash command configurator for proposal, apply, and archive operations
|
||||
- Add Crush-specific AGENTS.md configuration template
|
||||
- Update tool registry to include Crush configurator
|
||||
- **BREAKING**: None - this is additive functionality
|
||||
|
||||
## Impact
|
||||
- Affected specs: cli-init (new tool option)
|
||||
- Affected code: src/core/configurators/slash/crush.ts, registry.ts
|
||||
- New files: .crush/commands/openspec/ (proposal.md, apply.md, archive.md)
|
||||
@@ -0,0 +1,67 @@
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Slash Command Configuration
|
||||
The init command SHALL generate slash command files for supported editors using shared templates.
|
||||
|
||||
#### Scenario: Generating slash commands for Claude Code
|
||||
- **WHEN** the user selects Claude Code during initialization
|
||||
- **THEN** create `.claude/commands/openspec/proposal.md`, `.claude/commands/openspec/apply.md`, and `.claude/commands/openspec/archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for CodeBuddy Code
|
||||
- **WHEN** the user selects CodeBuddy Code during initialization
|
||||
- **THEN** create `.codebuddy/commands/openspec/proposal.md`, `.codebuddy/commands/openspec/apply.md`, and `.codebuddy/commands/openspec/archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Cline
|
||||
- **WHEN** the user selects Cline during initialization
|
||||
- **THEN** create `.clinerules/openspec-proposal.md`, `.clinerules/openspec-apply.md`, and `.clinerules/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** include Cline-specific Markdown heading frontmatter
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Crush
|
||||
- **WHEN** the user selects Crush during initialization
|
||||
- **THEN** create `.crush/commands/openspec/proposal.md`, `.crush/commands/openspec/apply.md`, and `.crush/commands/openspec/archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** include Crush-specific frontmatter with OpenSpec category and tags
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Cursor
|
||||
- **WHEN** the user selects Cursor during initialization
|
||||
- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for OpenCode
|
||||
- **WHEN** the user selects OpenCode during initialization
|
||||
- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Windsurf
|
||||
- **WHEN** the user selects Windsurf during initialization
|
||||
- **THEN** create `.windsurf/workflows/openspec-proposal.md`, `.windsurf/workflows/openspec-apply.md`, and `.windsurf/workflows/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Kilo Code
|
||||
- **WHEN** the user selects Kilo Code during initialization
|
||||
- **THEN** create `.kilocode/workflows/openspec-proposal.md`, `.kilocode/workflows/openspec-apply.md`, and `.kilocode/workflows/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Codex
|
||||
- **WHEN** the user selects Codex during initialization
|
||||
- **THEN** create global prompt files at `~/.codex/prompts/openspec-proposal.md`, `~/.codex/prompts/openspec-apply.md`, and `~/.codex/prompts/openspec-archive.md` (or under `$CODEX_HOME/prompts` if set)
|
||||
- **AND** populate each file from shared templates that map the first numbered placeholder (`$1`) to the primary user input (e.g., change identifier or question text)
|
||||
- **AND** wrap the generated content in OpenSpec markers so `openspec update` can refresh the prompts without touching surrounding custom notes
|
||||
|
||||
#### Scenario: Generating slash commands for GitHub Copilot
|
||||
- **WHEN** the user selects GitHub Copilot during initialization
|
||||
- **THEN** create `.github/prompts/openspec-proposal.prompt.md`, `.github/prompts/openspec-apply.prompt.md`, and `.github/prompts/openspec-archive.prompt.md`
|
||||
- **AND** populate each file with YAML frontmatter containing a `description` field that summarizes the workflow stage
|
||||
- **AND** include `$ARGUMENTS` placeholder to capture user input
|
||||
- **AND** wrap the shared template body with OpenSpec markers so `openspec update` can refresh the content
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
@@ -0,0 +1,7 @@
|
||||
## 1. Implementation
|
||||
- [x] 1.1 Create CrushSlashCommandConfigurator class in src/core/configurators/slash/crush.ts
|
||||
- [x] 1.2 Define file paths for Crush commands (.crush/commands/openspec/)
|
||||
- [x] 1.3 Create Crush-specific frontmatter for proposal, apply, archive commands
|
||||
- [x] 1.4 Register Crush configurator in slash/registry.ts
|
||||
- [x] 1.5 Add Crush to available tools in cli-init command
|
||||
- [x] 1.6 Test integration with openspec init --tool crush
|
||||
@@ -0,0 +1,12 @@
|
||||
## Why
|
||||
Factory's Droid CLI recently shipped custom slash commands that mirror other native assistant integrations. Teams using OpenSpec want the same managed workflows they already get for Cursor, Windsurf, and others so init/update can provision and refresh Factory commands without manual setup.
|
||||
|
||||
## What Changes
|
||||
- Extend the native tool registry so Factory/Droid appears alongside other slash-command integrations during `openspec init`.
|
||||
- Add shared templates that generate the three Factory custom commands (proposal, apply, archive) and wrap them in OpenSpec markers for safe refreshes.
|
||||
- Update the init and update command flows so they create or refresh Factory command files when the tool is selected or already present.
|
||||
- Refresh CLI specs to document the Factory support and align validation expectations.
|
||||
|
||||
## Impact
|
||||
- Affected specs: `specs/cli-init`, `specs/cli-update`
|
||||
- Affected code (expected): tool registry, slash-command template manager, init/update command helpers, documentation snippets
|
||||
@@ -0,0 +1,54 @@
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Slash Command Configuration
|
||||
The init command SHALL generate slash command files for supported editors using shared templates.
|
||||
|
||||
#### Scenario: Generating slash commands for Claude Code
|
||||
- **WHEN** the user selects Claude Code during initialization
|
||||
- **THEN** create `.claude/commands/openspec/proposal.md`, `.claude/commands/openspec/apply.md`, and `.claude/commands/openspec/archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Cursor
|
||||
- **WHEN** the user selects Cursor during initialization
|
||||
- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Factory Droid
|
||||
- **WHEN** the user selects Factory Droid during initialization
|
||||
- **THEN** create `.factory/commands/openspec-proposal.md`, `.factory/commands/openspec-apply.md`, and `.factory/commands/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates that include Factory-compatible YAML frontmatter for the `description` and `argument-hint` fields
|
||||
- **AND** include the `$ARGUMENTS` placeholder in the template body so droid receives any user-supplied input
|
||||
- **AND** wrap the generated content in OpenSpec managed markers so `openspec update` can safely refresh the commands
|
||||
|
||||
#### Scenario: Generating slash commands for OpenCode
|
||||
- **WHEN** the user selects OpenCode during initialization
|
||||
- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Windsurf
|
||||
- **WHEN** the user selects Windsurf during initialization
|
||||
- **THEN** create `.windsurf/workflows/openspec-proposal.md`, `.windsurf/workflows/openspec-apply.md`, and `.windsurf/workflows/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Kilo Code
|
||||
- **WHEN** the user selects Kilo Code during initialization
|
||||
- **THEN** create `.kilocode/workflows/openspec-proposal.md`, `.kilocode/workflows/openspec-apply.md`, and `.kilocode/workflows/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Codex
|
||||
- **WHEN** the user selects Codex during initialization
|
||||
- **THEN** create global prompt files at `~/.codex/prompts/openspec-proposal.md`, `~/.codex/prompts/openspec-apply.md`, and `~/.codex/prompts/openspec-archive.md` (or under `$CODEX_HOME/prompts` if set)
|
||||
- **AND** populate each file from shared templates that map the first numbered placeholder (`$1`) to the primary user input (e.g., change identifier or question text)
|
||||
- **AND** wrap the generated content in OpenSpec markers so `openspec update` can refresh the prompts without touching surrounding custom notes
|
||||
|
||||
#### Scenario: Generating slash commands for GitHub Copilot
|
||||
- **WHEN** the user selects GitHub Copilot during initialization
|
||||
- **THEN** create `.github/prompts/openspec-proposal.prompt.md`, `.github/prompts/openspec-apply.prompt.md`, and `.github/prompts/openspec-archive.prompt.md`
|
||||
- **AND** populate each file with YAML frontmatter containing a `description` field that summarizes the workflow stage
|
||||
- **AND** include `$ARGUMENTS` placeholder to capture user input
|
||||
- **AND** wrap the shared template body with OpenSpec markers so `openspec update` can refresh the content
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Slash Command Updates
|
||||
The update command SHALL refresh existing slash command files for configured tools without creating new ones.
|
||||
|
||||
#### Scenario: Updating slash commands for Claude Code
|
||||
- **WHEN** `.claude/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Updating slash commands for Cursor
|
||||
- **WHEN** `.cursor/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Updating slash commands for Factory Droid
|
||||
- **WHEN** `.factory/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh each file using the shared Factory templates that include YAML frontmatter for the `description` and `argument-hint` fields
|
||||
- **AND** ensure the template body retains the `$ARGUMENTS` placeholder so user input keeps flowing into droid
|
||||
- **AND** update only the content inside the OpenSpec managed markers, leaving any unmanaged notes untouched
|
||||
- **AND** skip creating missing files during update
|
||||
|
||||
#### Scenario: Updating slash commands for OpenCode
|
||||
- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Updating slash commands for Windsurf
|
||||
- **WHEN** `.windsurf/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh each file using shared templates wrapped in OpenSpec markers
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
- **AND** skip creating missing files (the update command only refreshes what already exists)
|
||||
|
||||
#### Scenario: Updating slash commands for Kilo Code
|
||||
- **WHEN** `.kilocode/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh each file using shared templates wrapped in OpenSpec markers
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
- **AND** skip creating missing files (the update command only refreshes what already exists)
|
||||
|
||||
#### Scenario: Updating slash commands for Codex
|
||||
- **GIVEN** the global Codex prompt directory contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **WHEN** a user runs `openspec update`
|
||||
- **THEN** refresh each file using the shared slash-command templates (including placeholder guidance)
|
||||
- **AND** preserve any unmanaged content outside the OpenSpec marker block
|
||||
- **AND** skip creation when a Codex prompt file is missing
|
||||
|
||||
#### Scenario: Updating slash commands for GitHub Copilot
|
||||
- **WHEN** `.github/prompts/` contains `openspec-proposal.prompt.md`, `openspec-apply.prompt.md`, and `openspec-archive.prompt.md`
|
||||
- **THEN** refresh each file using shared templates while preserving the YAML frontmatter
|
||||
- **AND** update only the OpenSpec-managed block between markers
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Missing slash command file
|
||||
- **WHEN** a tool lacks a slash command file
|
||||
- **THEN** do not create a new file during update
|
||||
@@ -0,0 +1,11 @@
|
||||
## 1. Factory tool registration
|
||||
- [x] 1.1 Add Factory/Droid metadata to the native tool registry used by init/update (ID, display name, command paths, availability flags).
|
||||
- [x] 1.2 Surface Factory in interactive prompts and non-interactive `--tools` parsing alongside existing slash-command integrations.
|
||||
|
||||
## 2. Slash command templates
|
||||
- [x] 2.1 Create shared templates for Factory's `openspec-proposal`, `openspec-apply`, and `openspec-archive` custom commands following Factory's CLI format.
|
||||
- [x] 2.2 Wire the templates into init/update so generation happens on create and refresh respects OpenSpec markers.
|
||||
|
||||
## 3. Verification
|
||||
- [x] 3.1 Update or add automated coverage that ensures Factory command files are scaffolded and refreshed correctly.
|
||||
- [x] 3.2 Document the new option in any user-facing copy (help text, README snippets) if required by spec.
|
||||
@@ -64,6 +64,18 @@ The command SHALL properly configure selected AI tools with OpenSpec-specific in
|
||||
- **THEN** create or update `CLAUDE.md` in the project root directory (not inside openspec/)
|
||||
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
|
||||
|
||||
#### Scenario: Configuring CodeBuddy Code
|
||||
|
||||
- **WHEN** CodeBuddy Code is selected
|
||||
- **THEN** create or update `CODEBUDDY.md` in the project root directory (not inside openspec/)
|
||||
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
|
||||
|
||||
#### Scenario: Configuring Cline
|
||||
|
||||
- **WHEN** Cline is selected
|
||||
- **THEN** create or update `CLINE.md` in the project root directory (not inside openspec/)
|
||||
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
|
||||
|
||||
#### Scenario: Creating new CLAUDE.md
|
||||
|
||||
- **WHEN** CLAUDE.md does not exist
|
||||
@@ -154,12 +166,39 @@ The init command SHALL generate slash command files for supported editors using
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for CodeBuddy Code
|
||||
- **WHEN** the user selects CodeBuddy Code during initialization
|
||||
- **THEN** create `.codebuddy/commands/openspec/proposal.md`, `.codebuddy/commands/openspec/apply.md`, and `.codebuddy/commands/openspec/archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Cline
|
||||
- **WHEN** the user selects Cline during initialization
|
||||
- **THEN** create `.clinerules/openspec-proposal.md`, `.clinerules/openspec-apply.md`, and `.clinerules/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** include Cline-specific Markdown heading frontmatter
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Crush
|
||||
- **WHEN** the user selects Crush during initialization
|
||||
- **THEN** create `.crush/commands/openspec/proposal.md`, `.crush/commands/openspec/apply.md`, and `.crush/commands/openspec/archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** include Crush-specific frontmatter with OpenSpec category and tags
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Cursor
|
||||
- **WHEN** the user selects Cursor during initialization
|
||||
- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates so command text matches other tools
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Factory Droid
|
||||
- **WHEN** the user selects Factory Droid during initialization
|
||||
- **THEN** create `.factory/commands/openspec-proposal.md`, `.factory/commands/openspec-apply.md`, and `.factory/commands/openspec-archive.md`
|
||||
- **AND** populate each file from shared templates that include Factory-compatible YAML frontmatter for the `description` and `argument-hint` fields
|
||||
- **AND** include the `$ARGUMENTS` placeholder in the template body so droid receives any user-supplied input
|
||||
- **AND** wrap the generated content in OpenSpec managed markers so `openspec update` can safely refresh the commands
|
||||
|
||||
#### Scenario: Generating slash commands for OpenCode
|
||||
- **WHEN** the user selects OpenCode during initialization
|
||||
- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md`
|
||||
@@ -192,6 +231,13 @@ The init command SHALL generate slash command files for supported editors using
|
||||
- **AND** wrap the shared template body with OpenSpec markers so `openspec update` can refresh the content
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Gemini CLI
|
||||
- **WHEN** the user selects Gemini CLI during initialization
|
||||
- **THEN** create `.gemini/commands/openspec/proposal.toml`, `.gemini/commands/openspec/apply.toml`, and `.gemini/commands/openspec/archive.toml`
|
||||
- **AND** populate each file as TOML that sets a stage-specific `description = "<summary>"` and a multi-line `prompt = """` block with the shared OpenSpec template
|
||||
- **AND** wrap the OpenSpec managed markers (`<!-- OPENSPEC:START -->` / `<!-- OPENSPEC:END -->`) inside the `prompt` value so `openspec update` can safely refresh the body between markers without touching the TOML framing
|
||||
- **AND** ensure the slash-command copy matches the existing proposal/apply/archive templates used by other tools
|
||||
|
||||
### Requirement: Non-Interactive Mode
|
||||
The command SHALL support non-interactive operation through command-line options for automation and CI/CD use cases.
|
||||
|
||||
|
||||
@@ -50,22 +50,47 @@ The update command SHALL always update the core OpenSpec files and display an AS
|
||||
- **AND** if a root-level stub exists, refresh it so it still directs contributors to `@/openspec/AGENTS.md`
|
||||
|
||||
### Requirement: Slash Command Updates
|
||||
The update command SHALL refresh existing slash command files for configured tools without creating new ones.
|
||||
The update command SHALL refresh existing slash command files for configured tools without creating new ones, and ensure the OpenCode archive command accepts change ID arguments.
|
||||
|
||||
#### Scenario: Updating slash commands for Claude Code
|
||||
- **WHEN** `.claude/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Updating slash commands for CodeBuddy Code
|
||||
- **WHEN** `.codebuddy/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Updating slash commands for Cline
|
||||
- **WHEN** `.clinerules/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** include Cline-specific Markdown heading frontmatter
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Updating slash commands for Crush
|
||||
- **WHEN** `.crush/commands/` contains `openspec/proposal.md`, `openspec/apply.md`, and `openspec/archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** include Crush-specific frontmatter with OpenSpec category and tags
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Updating slash commands for Cursor
|
||||
- **WHEN** `.cursor/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Updating slash commands for Factory Droid
|
||||
- **WHEN** `.factory/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh each file using the shared Factory templates that include YAML frontmatter for the `description` and `argument-hint` fields
|
||||
- **AND** ensure the template body retains the `$ARGUMENTS` placeholder so user input keeps flowing into droid
|
||||
- **AND** update only the content inside the OpenSpec managed markers, leaving any unmanaged notes untouched
|
||||
- **AND** skip creating missing files during update
|
||||
|
||||
#### Scenario: Updating slash commands for OpenCode
|
||||
- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
- **AND** ensure the archive command includes `$ARGUMENTS` placeholder in frontmatter for accepting change ID arguments
|
||||
|
||||
#### Scenario: Updating slash commands for Windsurf
|
||||
- **WHEN** `.windsurf/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
@@ -92,10 +117,36 @@ The update command SHALL refresh existing slash command files for configured too
|
||||
- **AND** update only the OpenSpec-managed block between markers
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Updating slash commands for Gemini CLI
|
||||
- **WHEN** `.gemini/commands/openspec/` contains `proposal.toml`, `apply.toml`, and `archive.toml`
|
||||
- **THEN** refresh the body of each file using the shared proposal/apply/archive templates
|
||||
- **AND** replace only the content between `<!-- OPENSPEC:START -->` and `<!-- OPENSPEC:END -->` markers inside the `prompt = """` block so the TOML framing (`description`, `prompt`) stays intact
|
||||
- **AND** skip creating any missing `.toml` files during update; only pre-existing Gemini commands are refreshed
|
||||
|
||||
#### Scenario: Missing slash command file
|
||||
- **WHEN** a tool lacks a slash command file
|
||||
- **THEN** do not create a new file during update
|
||||
|
||||
### Requirement: Archive Command Argument Support
|
||||
The archive slash command template SHALL support optional change ID arguments for tools that support `$ARGUMENTS` placeholder.
|
||||
|
||||
#### Scenario: Archive command with change ID argument
|
||||
- **WHEN** a user invokes `/openspec:archive <change-id>` with a change ID
|
||||
- **THEN** the template SHALL instruct the AI to validate the provided change ID against `openspec list`
|
||||
- **AND** use the provided change ID for archiving if valid
|
||||
- **AND** fail fast if the provided change ID doesn't match an archivable change
|
||||
|
||||
#### Scenario: Archive command without argument (backward compatibility)
|
||||
- **WHEN** a user invokes `/openspec:archive` without providing a change ID
|
||||
- **THEN** the template SHALL instruct the AI to identify the change ID from context or by running `openspec list`
|
||||
- **AND** proceed with the existing behavior (maintaining backward compatibility)
|
||||
|
||||
#### Scenario: OpenCode archive template generation
|
||||
- **WHEN** generating the OpenCode archive slash command file
|
||||
- **THEN** include the `$ARGUMENTS` placeholder in the frontmatter
|
||||
- **AND** wrap it in a clear structure like `<ChangeId>\n $ARGUMENTS\n</ChangeId>` to indicate the expected argument
|
||||
- **AND** include validation steps in the template body to check if the change ID is valid
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### Requirement: Error Handling
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fission-ai/openspec",
|
||||
"version": "0.11.0",
|
||||
"version": "0.14.0",
|
||||
"description": "AI-native system for spec-driven development",
|
||||
"keywords": [
|
||||
"openspec",
|
||||
@@ -59,7 +59,7 @@
|
||||
"@changesets/cli": "^2.27.7",
|
||||
"@types/node": "^24.2.0",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
Generated
+5
-5
@@ -37,8 +37,8 @@ importers:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(vitest@3.2.4)
|
||||
typescript:
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.2
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(@types/node@24.2.0)(@vitest/ui@3.2.4)
|
||||
@@ -1115,8 +1115,8 @@ packages:
|
||||
resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
typescript@5.9.2:
|
||||
resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==}
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
@@ -2223,7 +2223,7 @@ snapshots:
|
||||
|
||||
type-fest@0.21.3: {}
|
||||
|
||||
typescript@5.9.2: {}
|
||||
typescript@5.9.3: {}
|
||||
|
||||
undici-types@7.10.0: {}
|
||||
|
||||
|
||||
+1
-31
@@ -13,7 +13,6 @@ import { registerSpecCommand } from '../commands/spec.js';
|
||||
import { ChangeCommand } from '../commands/change.js';
|
||||
import { ValidateCommand } from '../commands/validate.js';
|
||||
import { ShowCommand } from '../commands/show.js';
|
||||
import { FactoryCommand } from '../commands/factory.js';
|
||||
|
||||
const program = new Command();
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -120,35 +119,6 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
const factoryCmd = program
|
||||
.command('factory')
|
||||
.description('Manage Factory Droid custom slash commands');
|
||||
|
||||
factoryCmd
|
||||
.command('slash <name>')
|
||||
.description('Scaffold a Factory slash command in .factory/commands')
|
||||
.option('--description <text>', 'Override the command description shown in slash suggestions')
|
||||
.option('--argument-hint <hint>', 'Append usage hints after the command name')
|
||||
.option('--personal', 'Create the command in ~/.factory/commands instead of the workspace directory')
|
||||
.option('--executable', 'Generate an executable script instead of Markdown')
|
||||
.option('--force', 'Overwrite the command if it already exists')
|
||||
.action(async (name: string, options?: {
|
||||
description?: string;
|
||||
argumentHint?: string;
|
||||
personal?: boolean;
|
||||
executable?: boolean;
|
||||
force?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
const factoryCommand = new FactoryCommand();
|
||||
await factoryCommand.slash(name, options ?? {});
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Change command with subcommands
|
||||
const changeCmd = program
|
||||
.command('change')
|
||||
@@ -217,7 +187,7 @@ program
|
||||
.option('-y, --yes', 'Skip confirmation prompts')
|
||||
.option('--skip-specs', 'Skip spec update operations (useful for infrastructure, tooling, or doc-only changes)')
|
||||
.option('--no-validate', 'Skip validation (not recommended, requires confirmation)')
|
||||
.action(async (changeName?: string, options?: { yes?: boolean; skipSpecs?: boolean; noValidate?: boolean }) => {
|
||||
.action(async (changeName?: string, options?: { yes?: boolean; skipSpecs?: boolean; noValidate?: boolean; validate?: boolean }) => {
|
||||
try {
|
||||
const archiveCommand = new ArchiveCommand();
|
||||
await archiveCommand.execute(changeName, options);
|
||||
|
||||
+11
-11
@@ -28,7 +28,7 @@ export class ChangeCommand {
|
||||
*/
|
||||
async show(changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean; noInteractive?: boolean }): Promise<void> {
|
||||
const changesPath = path.join(process.cwd(), 'openspec', 'changes');
|
||||
|
||||
|
||||
if (!changeName) {
|
||||
const canPrompt = isInteractive(options?.noInteractive);
|
||||
const changes = await this.getActiveChanges(changesPath);
|
||||
@@ -49,25 +49,25 @@ export class ChangeCommand {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const proposalPath = path.join(changesPath, changeName, 'proposal.md');
|
||||
|
||||
|
||||
try {
|
||||
await fs.access(proposalPath);
|
||||
} catch {
|
||||
throw new Error(`Change "${changeName}" not found at ${proposalPath}`);
|
||||
}
|
||||
|
||||
|
||||
if (options?.json) {
|
||||
const jsonOutput = await this.converter.convertChangeToJson(proposalPath);
|
||||
|
||||
|
||||
if (options.requirementsOnly) {
|
||||
console.error('Flag --requirements-only is deprecated; use --deltas-only instead.');
|
||||
}
|
||||
|
||||
const parsed: Change = JSON.parse(jsonOutput);
|
||||
const contentForTitle = await fs.readFile(proposalPath, 'utf-8');
|
||||
const title = this.extractTitle(contentForTitle);
|
||||
const title = this.extractTitle(contentForTitle, changeName);
|
||||
const id = parsed.name;
|
||||
const deltas = parsed.deltas || [];
|
||||
|
||||
@@ -124,7 +124,7 @@ export class ChangeCommand {
|
||||
|
||||
return {
|
||||
id: changeName,
|
||||
title: this.extractTitle(content),
|
||||
title: this.extractTitle(content, changeName),
|
||||
deltaCount: change.deltas.length,
|
||||
taskStatus,
|
||||
};
|
||||
@@ -159,7 +159,7 @@ export class ChangeCommand {
|
||||
const tasksPath = path.join(changesPath, changeName, 'tasks.md');
|
||||
try {
|
||||
const content = await fs.readFile(proposalPath, 'utf-8');
|
||||
const title = this.extractTitle(content);
|
||||
const title = this.extractTitle(content, changeName);
|
||||
let taskStatusText = '';
|
||||
try {
|
||||
const tasksContent = await fs.readFile(tasksPath, 'utf-8');
|
||||
@@ -258,9 +258,9 @@ export class ChangeCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private extractTitle(content: string): string {
|
||||
const match = content.match(/^#\s+(?:Change:\s+)?(.+)$/m);
|
||||
return match ? match[1].trim() : 'Untitled Change';
|
||||
private extractTitle(content: string, changeName: string): string {
|
||||
const match = content.match(/^#\s+(?:Change:\s+)?(.+)$/im);
|
||||
return match ? match[1].trim() : changeName;
|
||||
}
|
||||
|
||||
private countTasks(content: string): { total: number; completed: number } {
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { promises as fs } from 'fs';
|
||||
import { slugifyFactoryCommand } from '../utils/slug.js';
|
||||
|
||||
export interface FactorySlashOptions {
|
||||
description?: string;
|
||||
argumentHint?: string;
|
||||
executable?: boolean;
|
||||
personal?: boolean;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export class FactoryCommand {
|
||||
async slash(name: string, options: FactorySlashOptions = {}): Promise<void> {
|
||||
const slug = slugifyFactoryCommand(name ?? '');
|
||||
if (!slug) {
|
||||
throw new Error('Factory command names must use letters, numbers, spaces, dashes, or underscores.');
|
||||
}
|
||||
|
||||
const baseDir = options.personal
|
||||
? path.join(os.homedir(), '.factory', 'commands')
|
||||
: path.join(process.cwd(), '.factory', 'commands');
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
|
||||
const extension = options.executable ? '.sh' : '.md';
|
||||
const filePath = path.join(baseDir, `${slug}${extension}`);
|
||||
|
||||
const fileExists = await this.pathExists(filePath);
|
||||
if (fileExists && !options.force) {
|
||||
throw new Error(`Factory command "/${slug}" already exists at ${filePath}. Use --force to overwrite.`);
|
||||
}
|
||||
|
||||
const content = options.executable
|
||||
? this.buildExecutableTemplate(slug)
|
||||
: this.buildMarkdownTemplate(options.description, options.argumentHint);
|
||||
|
||||
await fs.writeFile(filePath, content, 'utf8');
|
||||
if (options.executable) {
|
||||
await fs.chmod(filePath, 0o755);
|
||||
}
|
||||
|
||||
const hint = options.argumentHint ? ` ${options.argumentHint}` : '';
|
||||
console.log(`Created Factory slash command at ${filePath}`);
|
||||
console.log(`Trigger with /${slug}${hint}`.trim());
|
||||
}
|
||||
|
||||
private async pathExists(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private buildMarkdownTemplate(description?: string, argumentHint?: string): string {
|
||||
const frontmatter = [
|
||||
'---',
|
||||
`description: ${description ?? ''}`,
|
||||
`argument-hint: ${argumentHint ?? ''}`,
|
||||
'---',
|
||||
'',
|
||||
];
|
||||
|
||||
const body = [
|
||||
'Replace this text with the instructions you want droid to follow.',
|
||||
'',
|
||||
'Arguments provided when invoking the slash command will appear as `$ARGUMENTS`.',
|
||||
'',
|
||||
'- Highlight important TODOs for the assistant.',
|
||||
'- Document any prerequisites the script should assume.',
|
||||
'',
|
||||
'Thanks!'
|
||||
];
|
||||
|
||||
return [...frontmatter, ...body, ''].join('\n');
|
||||
}
|
||||
|
||||
private buildExecutableTemplate(slug: string): string {
|
||||
const lines = [
|
||||
'#!/usr/bin/env bash',
|
||||
'',
|
||||
'set -euo pipefail',
|
||||
'',
|
||||
'command_name="' + slug + '"',
|
||||
'target="$1"',
|
||||
'',
|
||||
'echo "Running /${command_name} for ${target:-your task}"',
|
||||
'',
|
||||
'# Add your automation steps here',
|
||||
'# npm install',
|
||||
'# npm run lint',
|
||||
'',
|
||||
'echo "Finished /${command_name}"',
|
||||
'',
|
||||
];
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
+9
-4
@@ -19,7 +19,10 @@ interface SpecUpdate {
|
||||
}
|
||||
|
||||
export class ArchiveCommand {
|
||||
async execute(changeName?: string, options: { yes?: boolean; skipSpecs?: boolean; noValidate?: boolean } = {}): Promise<void> {
|
||||
async execute(
|
||||
changeName?: string,
|
||||
options: { yes?: boolean; skipSpecs?: boolean; noValidate?: boolean; validate?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const targetPath = '.';
|
||||
const changesDir = path.join(targetPath, 'openspec', 'changes');
|
||||
const archiveDir = path.join(changesDir, 'archive');
|
||||
@@ -54,8 +57,10 @@ export class ArchiveCommand {
|
||||
throw new Error(`Change '${changeName}' not found.`);
|
||||
}
|
||||
|
||||
const skipValidation = options.validate === false || options.noValidate === true;
|
||||
|
||||
// Validate specs and change before archiving
|
||||
if (!options.noValidate) {
|
||||
if (!skipValidation) {
|
||||
const validator = new Validator();
|
||||
let hasValidationErrors = false;
|
||||
|
||||
@@ -201,7 +206,7 @@ export class ArchiveCommand {
|
||||
let totals = { added: 0, modified: 0, removed: 0, renamed: 0 };
|
||||
for (const p of prepared) {
|
||||
const specName = path.basename(path.dirname(p.update.target));
|
||||
if (!options.noValidate) {
|
||||
if (!skipValidation) {
|
||||
const report = await new Validator().validateSpecContent(specName, p.rebuilt);
|
||||
if (!report.valid) {
|
||||
console.log(chalk.red(`\nValidation errors in rebuilt spec for ${specName} (will not write changes):`));
|
||||
@@ -598,4 +603,4 @@ export class ArchiveCommand {
|
||||
// Returns date in YYYY-MM-DD format
|
||||
return new Date().toISOString().split('T')[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,22 @@ export interface AIToolOption {
|
||||
}
|
||||
|
||||
export const AI_TOOLS: AIToolOption[] = [
|
||||
{ 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: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code' },
|
||||
{ name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict' },
|
||||
{ name: 'Crush', value: 'crush', available: true, successLabel: 'Crush' },
|
||||
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor' },
|
||||
{ name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid' },
|
||||
{ name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI' },
|
||||
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode' },
|
||||
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code' },
|
||||
{ name: 'Qoder (CLI)', value: 'qoder', available: true, successLabel: 'Qoder' },
|
||||
{ name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf' },
|
||||
{ name: 'Codex', value: 'codex', available: true, successLabel: 'Codex' },
|
||||
{ name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot' },
|
||||
{ name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer' },
|
||||
{ name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code' },
|
||||
{ name: 'AGENTS.md (works with Amp, VS Code, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' }
|
||||
];
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import path from 'path';
|
||||
import { ToolConfigurator } from './base.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { TemplateManager } from '../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../config.js';
|
||||
|
||||
export class ClineConfigurator implements ToolConfigurator {
|
||||
name = 'Cline';
|
||||
configFileName = 'CLINE.md';
|
||||
isAvailable = true;
|
||||
|
||||
async configure(projectPath: string, openspecDir: string): Promise<void> {
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
const content = TemplateManager.getClineTemplate();
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import path from 'path';
|
||||
import { ToolConfigurator } from './base.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { TemplateManager } from '../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../config.js';
|
||||
|
||||
export class CodeBuddyConfigurator implements ToolConfigurator {
|
||||
name = 'CodeBuddy';
|
||||
configFileName = 'CODEBUDDY.md';
|
||||
isAvailable = true;
|
||||
|
||||
async configure(projectPath: string, openspecDir: string): Promise<void> {
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
const content = TemplateManager.getClaudeTemplate();
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import path from 'path';
|
||||
import { ToolConfigurator } from './base.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { TemplateManager } from '../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../config.js';
|
||||
|
||||
export class CostrictConfigurator implements ToolConfigurator {
|
||||
name = 'CoStrict';
|
||||
configFileName = 'COSTRICT.md';
|
||||
isAvailable = true;
|
||||
|
||||
async configure(projectPath: string, openspecDir: string): Promise<void> {
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
const content = TemplateManager.getCostrictTemplate();
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import path from 'path';
|
||||
import { ToolConfigurator } from './base.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { TemplateManager } from '../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../config.js';
|
||||
|
||||
/**
|
||||
* Qoder AI Tool Configurator
|
||||
*
|
||||
* Configures OpenSpec integration for Qoder AI coding assistant.
|
||||
* Creates and manages QODER.md configuration file with OpenSpec instructions.
|
||||
*
|
||||
* @implements {ToolConfigurator}
|
||||
*/
|
||||
export class QoderConfigurator implements ToolConfigurator {
|
||||
/** Display name for the Qoder tool */
|
||||
name = 'Qoder';
|
||||
|
||||
/** Configuration file name at project root */
|
||||
configFileName = 'QODER.md';
|
||||
|
||||
/** Indicates tool is available for configuration */
|
||||
isAvailable = true;
|
||||
|
||||
/**
|
||||
* Configure Qoder integration for a project
|
||||
*
|
||||
* Creates or updates QODER.md file with OpenSpec instructions.
|
||||
* Uses Claude-compatible template for instruction content.
|
||||
* Wrapped with OpenSpec markers for future updates.
|
||||
*
|
||||
* @param {string} projectPath - Absolute path to project root directory
|
||||
* @param {string} openspecDir - Path to openspec directory (unused but required by interface)
|
||||
* @returns {Promise<void>} Resolves when configuration is complete
|
||||
*/
|
||||
async configure(projectPath: string, openspecDir: string): Promise<void> {
|
||||
// Construct full path to QODER.md at project root
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
|
||||
// Get Claude-compatible instruction template
|
||||
// This ensures Qoder receives the same high-quality OpenSpec instructions
|
||||
const content = TemplateManager.getClaudeTemplate();
|
||||
|
||||
// Write or update file with managed content between markers
|
||||
// This allows future updates to refresh instructions automatically
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Qwen Code configurator for OpenSpec integration.
|
||||
* This class handles the configuration of Qwen Code as an AI tool within OpenSpec.
|
||||
*
|
||||
* @implements {ToolConfigurator}
|
||||
*/
|
||||
import path from 'path';
|
||||
import { ToolConfigurator } from './base.js';
|
||||
import { FileSystemUtils } from '../../utils/file-system.js';
|
||||
import { TemplateManager } from '../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../config.js';
|
||||
|
||||
/**
|
||||
* QwenConfigurator class provides integration with Qwen Code
|
||||
* by creating and managing the necessary configuration files.
|
||||
* Currently configures the QWEN.md file with OpenSpec instructions.
|
||||
*/
|
||||
export class QwenConfigurator implements ToolConfigurator {
|
||||
/** Display name for the Qwen Code tool */
|
||||
name = 'Qwen Code';
|
||||
|
||||
/** Configuration file name for Qwen Code */
|
||||
configFileName = 'QWEN.md';
|
||||
|
||||
/** Availability status for the Qwen Code tool */
|
||||
isAvailable = true;
|
||||
|
||||
/**
|
||||
* Configures the Qwen Code integration by creating or updating the QWEN.md file
|
||||
* with OpenSpec instructions and markers.
|
||||
*
|
||||
* @param {string} projectPath - The path to the project root
|
||||
* @param {string} _openspecDir - The path to the openspec directory (unused)
|
||||
* @returns {Promise<void>} A promise that resolves when configuration is complete
|
||||
*/
|
||||
async configure(projectPath: string, _openspecDir: string): Promise<void> {
|
||||
const filePath = path.join(projectPath, this.configFileName);
|
||||
const content = TemplateManager.getAgentsStandardTemplate();
|
||||
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
filePath,
|
||||
content,
|
||||
OPENSPEC_MARKERS.start,
|
||||
OPENSPEC_MARKERS.end
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,31 @@
|
||||
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 { 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 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('agents', agentsConfigurator);
|
||||
this.tools.set('qwen', qwenConfigurator);
|
||||
}
|
||||
|
||||
static register(tool: ToolConfigurator): void {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.augment/commands/openspec-proposal.md',
|
||||
apply: '.augment/commands/openspec-apply.md',
|
||||
archive: '.augment/commands/openspec-archive.md'
|
||||
};
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
argument-hint: feature description or request
|
||||
---`,
|
||||
apply: `---
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
argument-hint: change-id
|
||||
---`,
|
||||
archive: `---
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
argument-hint: change-id
|
||||
---`
|
||||
};
|
||||
|
||||
export class AuggieSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'auggie';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export abstract class SlashCommandConfigurator {
|
||||
const createdOrUpdated: string[] = [];
|
||||
|
||||
for (const target of this.getTargets()) {
|
||||
const body = TemplateManager.getSlashCommandBody(target.id).trim();
|
||||
const body = this.getBody(target.id);
|
||||
const filePath = FileSystemUtils.joinPath(projectPath, target.path);
|
||||
|
||||
if (await FileSystemUtils.fileExists(filePath)) {
|
||||
@@ -54,7 +54,7 @@ export abstract class SlashCommandConfigurator {
|
||||
for (const target of this.getTargets()) {
|
||||
const filePath = FileSystemUtils.joinPath(projectPath, target.path);
|
||||
if (await FileSystemUtils.fileExists(filePath)) {
|
||||
const body = TemplateManager.getSlashCommandBody(target.id).trim();
|
||||
const body = this.getBody(target.id);
|
||||
await this.updateBody(filePath, body);
|
||||
updated.push(target.path);
|
||||
}
|
||||
@@ -66,6 +66,10 @@ export abstract class SlashCommandConfigurator {
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.clinerules/openspec-proposal.md',
|
||||
apply: '.clinerules/openspec-apply.md',
|
||||
archive: '.clinerules/openspec-archive.md'
|
||||
};
|
||||
|
||||
export class ClineSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'cline';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string | undefined {
|
||||
const descriptions: Record<SlashCommandId, string> = {
|
||||
proposal: 'Scaffold a new OpenSpec change and validate strictly.',
|
||||
apply: 'Implement an approved OpenSpec change and keep tasks in sync.',
|
||||
archive: 'Archive a deployed OpenSpec change and update specs.'
|
||||
};
|
||||
const description = descriptions[id];
|
||||
return `# OpenSpec: ${id.charAt(0).toUpperCase() + id.slice(1)}\n\n${description}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.codebuddy/commands/openspec/proposal.md',
|
||||
apply: '.codebuddy/commands/openspec/apply.md',
|
||||
archive: '.codebuddy/commands/openspec/archive.md'
|
||||
};
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
name: OpenSpec: Proposal
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
category: OpenSpec
|
||||
tags: [openspec, change]
|
||||
---`,
|
||||
apply: `---
|
||||
name: OpenSpec: Apply
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
category: OpenSpec
|
||||
tags: [openspec, apply]
|
||||
---`,
|
||||
archive: `---
|
||||
name: OpenSpec: Archive
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
category: OpenSpec
|
||||
tags: [openspec, archive]
|
||||
---`
|
||||
};
|
||||
|
||||
export class CodeBuddySlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'codebuddy';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS = {
|
||||
proposal: '.cospec/openspec/commands/openspec-proposal.md',
|
||||
apply: '.cospec/openspec/commands/openspec-apply.md',
|
||||
archive: '.cospec/openspec/commands/openspec-archive.md',
|
||||
} as const satisfies Record<SlashCommandId, string>;
|
||||
|
||||
const FRONTMATTER = {
|
||||
proposal: `---
|
||||
description: "Scaffold a new OpenSpec change and validate strictly."
|
||||
argument-hint: feature description or request
|
||||
---`,
|
||||
apply: `---
|
||||
description: "Implement an approved OpenSpec change and keep tasks in sync."
|
||||
argument-hint: change-id
|
||||
---`,
|
||||
archive: `---
|
||||
description: "Archive a deployed OpenSpec change and update specs."
|
||||
argument-hint: change-id
|
||||
---`
|
||||
} as const satisfies Record<SlashCommandId, string>;
|
||||
|
||||
export class CostrictSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'costrict';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string | undefined {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.crush/commands/openspec/proposal.md',
|
||||
apply: '.crush/commands/openspec/apply.md',
|
||||
archive: '.crush/commands/openspec/archive.md'
|
||||
};
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
name: OpenSpec: Proposal
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
category: OpenSpec
|
||||
tags: [openspec, change]
|
||||
---`,
|
||||
apply: `---
|
||||
name: OpenSpec: Apply
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
category: OpenSpec
|
||||
tags: [openspec, apply]
|
||||
---`,
|
||||
archive: `---
|
||||
name: OpenSpec: Archive
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
category: OpenSpec
|
||||
tags: [openspec, archive]
|
||||
---`
|
||||
};
|
||||
|
||||
export class CrushSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'crush';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.factory/commands/openspec-proposal.md',
|
||||
apply: '.factory/commands/openspec-apply.md',
|
||||
archive: '.factory/commands/openspec-archive.md'
|
||||
};
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
argument-hint: request or feature description
|
||||
---`,
|
||||
apply: `---
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
argument-hint: change-id
|
||||
---`,
|
||||
archive: `---
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
argument-hint: change-id
|
||||
---`
|
||||
};
|
||||
|
||||
export class FactorySlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'factory';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
|
||||
protected getBody(id: SlashCommandId): string {
|
||||
const baseBody = super.getBody(id);
|
||||
return `${baseBody}\n\n$ARGUMENTS`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { FileSystemUtils } from '../../../utils/file-system.js';
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId, TemplateManager } from '../../templates/index.js';
|
||||
import { OPENSPEC_MARKERS } from '../../config.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 SlashCommandConfigurator {
|
||||
readonly toolId = 'gemini';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(_id: SlashCommandId): string | undefined {
|
||||
// TOML doesn't use separate frontmatter - it's all in one structure
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 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 = DESCRIPTIONS[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,5 +1,7 @@
|
||||
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",
|
||||
@@ -20,11 +22,20 @@ The user has requested the following change proposal. Use the openspec instructi
|
||||
apply: `---
|
||||
agent: build
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
---`,
|
||||
---
|
||||
The user has requested to implement the following change proposal. Find the change proposal and follow the instructions below. If you're not sure or if ambiguous, ask for clarification from the user.
|
||||
<UserRequest>
|
||||
$ARGUMENTS
|
||||
</UserRequest>
|
||||
`,
|
||||
archive: `---
|
||||
agent: build
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
---`,
|
||||
---
|
||||
<ChangeId>
|
||||
$ARGUMENTS
|
||||
</ChangeId>
|
||||
`,
|
||||
};
|
||||
|
||||
export class OpenCodeSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
@@ -38,4 +49,38 @@ export class OpenCodeSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
/**
|
||||
* File paths for Qoder slash commands
|
||||
* Maps each OpenSpec workflow stage to its command file location
|
||||
* Commands are stored in .qoder/commands/openspec/ directory
|
||||
*/
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
// Create and validate new change proposals
|
||||
proposal: '.qoder/commands/openspec/proposal.md',
|
||||
|
||||
// Implement approved changes with task tracking
|
||||
apply: '.qoder/commands/openspec/apply.md',
|
||||
|
||||
// Archive completed changes and update specs
|
||||
archive: '.qoder/commands/openspec/archive.md'
|
||||
};
|
||||
|
||||
/**
|
||||
* YAML frontmatter for Qoder slash commands
|
||||
* Defines metadata displayed in Qoder's command palette
|
||||
* Each command is categorized and tagged for easy discovery
|
||||
*/
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
name: OpenSpec: Proposal
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
category: OpenSpec
|
||||
tags: [openspec, change]
|
||||
---`,
|
||||
apply: `---
|
||||
name: OpenSpec: Apply
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
category: OpenSpec
|
||||
tags: [openspec, apply]
|
||||
---`,
|
||||
archive: `---
|
||||
name: OpenSpec: Archive
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
category: OpenSpec
|
||||
tags: [openspec, archive]
|
||||
---`
|
||||
};
|
||||
|
||||
/**
|
||||
* Qoder Slash Command Configurator
|
||||
*
|
||||
* Manages OpenSpec slash commands for Qoder AI assistant.
|
||||
* Creates three workflow commands: proposal, apply, and archive.
|
||||
* Uses colon-separated command format (/openspec:proposal).
|
||||
*
|
||||
* @extends {SlashCommandConfigurator}
|
||||
*/
|
||||
export class QoderSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
/** Unique identifier for Qoder tool */
|
||||
readonly toolId = 'qoder';
|
||||
|
||||
/** Indicates slash commands are available for this tool */
|
||||
readonly isAvailable = true;
|
||||
|
||||
/**
|
||||
* Get relative file path for a slash command
|
||||
*
|
||||
* @param {SlashCommandId} id - Command identifier (proposal, apply, or archive)
|
||||
* @returns {string} Relative path from project root to command file
|
||||
*/
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get YAML frontmatter for a slash command
|
||||
*
|
||||
* Frontmatter defines how the command appears in Qoder's UI,
|
||||
* including display name, description, and categorization.
|
||||
*
|
||||
* @param {SlashCommandId} id - Command identifier (proposal, apply, or archive)
|
||||
* @returns {string} YAML frontmatter block with command metadata
|
||||
*/
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 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 { SlashCommandConfigurator } from './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.md',
|
||||
apply: '.qwen/commands/openspec-apply.md',
|
||||
archive: '.qwen/commands/openspec-archive.md'
|
||||
};
|
||||
|
||||
/**
|
||||
* YAML frontmatter definitions for Qwen command files.
|
||||
* These provide metadata for each slash command to ensure proper recognition by Qwen Code.
|
||||
* @type {Record<SlashCommandId, string>}
|
||||
*/
|
||||
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.
|
||||
---`
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 SlashCommandConfigurator {
|
||||
/** 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];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the YAML frontmatter for a given slash command ID.
|
||||
* @param {SlashCommandId} id - The slash command identifier
|
||||
* @returns {string} The YAML frontmatter string
|
||||
*/
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { ClaudeSlashCommandConfigurator } from './claude.js';
|
||||
import { CodeBuddySlashCommandConfigurator } from './codebuddy.js';
|
||||
import { QoderSlashCommandConfigurator } from './qoder.js';
|
||||
import { CursorSlashCommandConfigurator } from './cursor.js';
|
||||
import { WindsurfSlashCommandConfigurator } from './windsurf.js';
|
||||
import { KiloCodeSlashCommandConfigurator } from './kilocode.js';
|
||||
@@ -7,12 +9,21 @@ 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';
|
||||
|
||||
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();
|
||||
@@ -20,8 +31,17 @@ export class SlashCommandRegistry {
|
||||
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();
|
||||
|
||||
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);
|
||||
@@ -29,6 +49,13 @@ export class SlashCommandRegistry {
|
||||
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);
|
||||
}
|
||||
|
||||
static register(configurator: SlashCommandConfigurator): void {
|
||||
|
||||
+88
-21
@@ -21,6 +21,7 @@ import {
|
||||
AI_TOOLS,
|
||||
OPENSPEC_DIR_NAME,
|
||||
AIToolOption,
|
||||
OPENSPEC_MARKERS,
|
||||
} from './config.js';
|
||||
import { PALETTE } from './styles/palette.js';
|
||||
|
||||
@@ -388,7 +389,7 @@ export class InitCommand {
|
||||
|
||||
// Validation happens silently in the background
|
||||
const extendMode = await this.validate(projectPath, openspecPath);
|
||||
const existingToolStates = await this.getExistingToolStates(projectPath);
|
||||
const existingToolStates = await this.getExistingToolStates(projectPath, extendMode);
|
||||
|
||||
this.renderBanner(extendMode);
|
||||
|
||||
@@ -427,9 +428,11 @@ export class InitCommand {
|
||||
} else {
|
||||
ora({ stream: process.stdout }).info(
|
||||
PALETTE.midGray(
|
||||
'ℹ OpenSpec already initialized. Skipping base scaffolding.'
|
||||
'ℹ OpenSpec already initialized. Checking for missing files...'
|
||||
)
|
||||
);
|
||||
await this.createDirectoryStructure(openspecPath);
|
||||
await this.ensureTemplateFiles(openspecPath, config);
|
||||
}
|
||||
|
||||
// Step 2: Configure AI tools
|
||||
@@ -627,35 +630,78 @@ export class InitCommand {
|
||||
}
|
||||
|
||||
private async getExistingToolStates(
|
||||
projectPath: string
|
||||
projectPath: string,
|
||||
extendMode: boolean
|
||||
): Promise<Record<string, boolean>> {
|
||||
const states: Record<string, boolean> = {};
|
||||
for (const tool of AI_TOOLS) {
|
||||
states[tool.value] = await this.isToolConfigured(projectPath, tool.value);
|
||||
// Fresh initialization - no tools configured yet
|
||||
if (!extendMode) {
|
||||
return Object.fromEntries(AI_TOOLS.map(t => [t.value, false]));
|
||||
}
|
||||
return states;
|
||||
|
||||
// Extend mode - check all tools in parallel for better performance
|
||||
const entries = await Promise.all(
|
||||
AI_TOOLS.map(async (t) => [t.value, await this.isToolConfigured(projectPath, t.value)] as const)
|
||||
);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
private async isToolConfigured(
|
||||
projectPath: string,
|
||||
toolId: string
|
||||
): Promise<boolean> {
|
||||
const configFile = ToolRegistry.get(toolId)?.configFileName;
|
||||
if (
|
||||
configFile &&
|
||||
(await FileSystemUtils.fileExists(path.join(projectPath, configFile)))
|
||||
)
|
||||
return true;
|
||||
// A tool is only considered "configured by OpenSpec" if its files contain OpenSpec markers.
|
||||
// For tools with both config files and slash commands, BOTH must have markers.
|
||||
// For slash commands, at least one file with markers is sufficient (not all required).
|
||||
|
||||
const slashConfigurator = SlashCommandRegistry.get(toolId);
|
||||
if (!slashConfigurator) return false;
|
||||
for (const target of slashConfigurator.getTargets()) {
|
||||
const absolute = slashConfigurator.resolveAbsolutePath(
|
||||
projectPath,
|
||||
target.id
|
||||
);
|
||||
if (await FileSystemUtils.fileExists(absolute)) return true;
|
||||
// Helper to check if a file exists and contains OpenSpec markers
|
||||
const fileHasMarkers = async (absolutePath: string): Promise<boolean> => {
|
||||
try {
|
||||
const content = await FileSystemUtils.readFile(absolutePath);
|
||||
return content.includes(OPENSPEC_MARKERS.start) && content.includes(OPENSPEC_MARKERS.end);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let hasConfigFile = false;
|
||||
let hasSlashCommands = false;
|
||||
|
||||
// Check if the tool has a config file with OpenSpec markers
|
||||
const configFile = ToolRegistry.get(toolId)?.configFileName;
|
||||
if (configFile) {
|
||||
const configPath = path.join(projectPath, configFile);
|
||||
hasConfigFile = (await FileSystemUtils.fileExists(configPath)) && (await fileHasMarkers(configPath));
|
||||
}
|
||||
|
||||
// Check if any slash command file exists with OpenSpec markers
|
||||
const slashConfigurator = SlashCommandRegistry.get(toolId);
|
||||
if (slashConfigurator) {
|
||||
for (const target of slashConfigurator.getTargets()) {
|
||||
const absolute = slashConfigurator.resolveAbsolutePath(projectPath, target.id);
|
||||
if ((await FileSystemUtils.fileExists(absolute)) && (await fileHasMarkers(absolute))) {
|
||||
hasSlashCommands = true;
|
||||
break; // At least one file with markers is sufficient
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tool is only configured if BOTH exist with markers
|
||||
// OR if the tool has no config file requirement (slash commands only)
|
||||
// OR if the tool has no slash commands requirement (config file only)
|
||||
const hasConfigFileRequirement = configFile !== undefined;
|
||||
const hasSlashCommandRequirement = slashConfigurator !== undefined;
|
||||
|
||||
if (hasConfigFileRequirement && hasSlashCommandRequirement) {
|
||||
// Both are required - both must be present with markers
|
||||
return hasConfigFile && hasSlashCommands;
|
||||
} else if (hasConfigFileRequirement) {
|
||||
// Only config file required
|
||||
return hasConfigFile;
|
||||
} else if (hasSlashCommandRequirement) {
|
||||
// Only slash commands required
|
||||
return hasSlashCommands;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -675,6 +721,21 @@ export class InitCommand {
|
||||
private async generateFiles(
|
||||
openspecPath: string,
|
||||
config: OpenSpecConfig
|
||||
): Promise<void> {
|
||||
await this.writeTemplateFiles(openspecPath, config, false);
|
||||
}
|
||||
|
||||
private async ensureTemplateFiles(
|
||||
openspecPath: string,
|
||||
config: OpenSpecConfig
|
||||
): Promise<void> {
|
||||
await this.writeTemplateFiles(openspecPath, config, true);
|
||||
}
|
||||
|
||||
private async writeTemplateFiles(
|
||||
openspecPath: string,
|
||||
config: OpenSpecConfig,
|
||||
skipExisting: boolean
|
||||
): Promise<void> {
|
||||
const context: ProjectContext = {
|
||||
// Could be enhanced with prompts for project details
|
||||
@@ -684,6 +745,12 @@ export class InitCommand {
|
||||
|
||||
for (const template of templates) {
|
||||
const filePath = path.join(openspecPath, template.path);
|
||||
|
||||
// Skip if file exists and we're in skipExisting mode
|
||||
if (skipExisting && (await FileSystemUtils.fileExists(filePath))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content =
|
||||
typeof template.content === 'function'
|
||||
? template.content(context)
|
||||
|
||||
@@ -101,6 +101,12 @@ export interface DeltaPlan {
|
||||
modified: RequirementBlock[];
|
||||
removed: string[]; // requirement names
|
||||
renamed: Array<{ from: string; to: string }>;
|
||||
sectionPresence: {
|
||||
added: boolean;
|
||||
modified: boolean;
|
||||
removed: boolean;
|
||||
renamed: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLineEndings(content: string): string {
|
||||
@@ -113,11 +119,26 @@ function normalizeLineEndings(content: string): string {
|
||||
export function parseDeltaSpec(content: string): DeltaPlan {
|
||||
const normalized = normalizeLineEndings(content);
|
||||
const sections = splitTopLevelSections(normalized);
|
||||
const added = parseRequirementBlocksFromSection(sections['ADDED Requirements'] || '');
|
||||
const modified = parseRequirementBlocksFromSection(sections['MODIFIED Requirements'] || '');
|
||||
const removedNames = parseRemovedNames(sections['REMOVED Requirements'] || '');
|
||||
const renamedPairs = parseRenamedPairs(sections['RENAMED Requirements'] || '');
|
||||
return { added, modified, removed: removedNames, renamed: renamedPairs };
|
||||
const addedLookup = getSectionCaseInsensitive(sections, 'ADDED Requirements');
|
||||
const modifiedLookup = getSectionCaseInsensitive(sections, 'MODIFIED Requirements');
|
||||
const removedLookup = getSectionCaseInsensitive(sections, 'REMOVED Requirements');
|
||||
const renamedLookup = getSectionCaseInsensitive(sections, 'RENAMED Requirements');
|
||||
const added = parseRequirementBlocksFromSection(addedLookup.body);
|
||||
const modified = parseRequirementBlocksFromSection(modifiedLookup.body);
|
||||
const removedNames = parseRemovedNames(removedLookup.body);
|
||||
const renamedPairs = parseRenamedPairs(renamedLookup.body);
|
||||
return {
|
||||
added,
|
||||
modified,
|
||||
removed: removedNames,
|
||||
renamed: renamedPairs,
|
||||
sectionPresence: {
|
||||
added: addedLookup.found,
|
||||
modified: modifiedLookup.found,
|
||||
removed: removedLookup.found,
|
||||
renamed: renamedLookup.found,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function splitTopLevelSections(content: string): Record<string, string> {
|
||||
@@ -140,6 +161,14 @@ function splitTopLevelSections(content: string): Record<string, string> {
|
||||
return result;
|
||||
}
|
||||
|
||||
function getSectionCaseInsensitive(sections: Record<string, string>, desired: string): { body: string; found: boolean } {
|
||||
const target = desired.toLowerCase();
|
||||
for (const [title, body] of Object.entries(sections)) {
|
||||
if (title.toLowerCase() === target) return { body, found: true };
|
||||
}
|
||||
return { body: '', found: false };
|
||||
}
|
||||
|
||||
function parseRequirementBlocksFromSection(sectionBody: string): RequirementBlock[] {
|
||||
if (!sectionBody) return [];
|
||||
const lines = normalizeLineEndings(sectionBody).split('\n');
|
||||
@@ -203,5 +232,3 @@ function parseRenamedPairs(sectionBody: string): Array<{ from: string; to: strin
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ Track these steps as TODOs and complete them one by one.
|
||||
After deployment, create separate PR to:
|
||||
- Move \`changes/[name]/\` → \`changes/archive/YYYY-MM-DD-[name]/\`
|
||||
- Update \`specs/\` if capabilities changed
|
||||
- Use \`openspec archive [change] --skip-specs --yes\` for tooling-only changes
|
||||
- Use \`openspec archive <change-id> --skip-specs --yes\` for tooling-only changes (always pass the change ID explicitly)
|
||||
- Run \`openspec validate --strict\` to confirm the archived change passes checks
|
||||
|
||||
## Before Any Task
|
||||
@@ -95,9 +95,8 @@ After deployment, create separate PR to:
|
||||
openspec list # List active changes
|
||||
openspec list --specs # List specifications
|
||||
openspec show [item] # Display change or spec
|
||||
openspec diff [change] # Show spec differences
|
||||
openspec validate [item] # Validate changes or specs
|
||||
openspec archive [change] [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
|
||||
openspec archive <change-id> [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
|
||||
|
||||
# Project management
|
||||
openspec init [path] # Initialize OpenSpec
|
||||
@@ -161,6 +160,8 @@ New request?
|
||||
|
||||
2. **Write proposal.md:**
|
||||
\`\`\`markdown
|
||||
# Change: [Brief description of change]
|
||||
|
||||
## Why
|
||||
[1-2 sentences on problem/opportunity]
|
||||
|
||||
@@ -448,9 +449,8 @@ Only add complexity with:
|
||||
\`\`\`bash
|
||||
openspec list # What's in progress?
|
||||
openspec show [item] # View details
|
||||
openspec diff [change] # What's changing?
|
||||
openspec validate --strict # Is it correct?
|
||||
openspec archive [change] [--yes|-y] # Mark complete (add --yes for automation)
|
||||
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 @@
|
||||
export { agentsRootStubTemplate as clineTemplate } from './agents-root-stub.js';
|
||||
@@ -0,0 +1 @@
|
||||
export { agentsRootStubTemplate as costrictTemplate } from './agents-root-stub.js';
|
||||
@@ -1,6 +1,8 @@
|
||||
import { agentsTemplate } from './agents-template.js';
|
||||
import { projectTemplate, ProjectContext } from './project-template.js';
|
||||
import { claudeTemplate } from './claude-template.js';
|
||||
import { clineTemplate } from './cline-template.js';
|
||||
import { costrictTemplate } from './costrict-template.js';
|
||||
import { agentsRootStubTemplate } from './agents-root-stub.js';
|
||||
import { getSlashCommandBody, SlashCommandId } from './slash-command-templates.js';
|
||||
|
||||
@@ -27,6 +29,14 @@ export class TemplateManager {
|
||||
return claudeTemplate;
|
||||
}
|
||||
|
||||
static getClineTemplate(): string {
|
||||
return clineTemplate;
|
||||
}
|
||||
|
||||
static getCostrictTemplate(): string {
|
||||
return costrictTemplate;
|
||||
}
|
||||
|
||||
static getAgentsStandardTemplate(): string {
|
||||
return agentsRootStubTemplate;
|
||||
}
|
||||
|
||||
@@ -33,12 +33,18 @@ const applyReferences = `**Reference**
|
||||
- Use \`openspec show <id> --json --deltas-only\` if you need additional context from the proposal while implementing.`;
|
||||
|
||||
const archiveSteps = `**Steps**
|
||||
1. Identify the requested change ID (via the prompt or \`openspec list\`).
|
||||
2. Run \`openspec archive <id> --yes\` to let the CLI move the change and apply spec updates without prompts (use \`--skip-specs\` only for tooling-only work).
|
||||
3. Review the command output to confirm the target specs were updated and the change landed in \`changes/archive/\`.
|
||||
4. Validate with \`openspec validate --strict\` and inspect with \`openspec show <id>\` if anything looks off.`;
|
||||
1. Determine the change ID to archive:
|
||||
- If this prompt already includes a specific change ID (for example inside a \`<ChangeId>\` block populated by slash-command arguments), use that value after trimming whitespace.
|
||||
- If the conversation references a change loosely (for example by title or summary), run \`openspec list\` to surface likely IDs, share the relevant candidates, and confirm which one the user intends.
|
||||
- Otherwise, review the conversation, run \`openspec list\`, and ask the user which change to archive; wait for a confirmed change ID before proceeding.
|
||||
- If you still cannot identify a single change ID, stop and tell the user you cannot archive anything yet.
|
||||
2. Validate the change ID by running \`openspec list\` (or \`openspec show <id>\`) and stop if the change is missing, already archived, or otherwise not ready to archive.
|
||||
3. Run \`openspec archive <id> --yes\` so the CLI moves the change and applies spec updates without prompts (use \`--skip-specs\` only for tooling-only work).
|
||||
4. Review the command output to confirm the target specs were updated and the change landed in \`changes/archive/\`.
|
||||
5. Validate with \`openspec validate --strict\` and inspect with \`openspec show <id>\` if anything looks off.`;
|
||||
|
||||
const archiveReferences = `**Reference**
|
||||
- Use \`openspec list\` to confirm change IDs before archiving.
|
||||
- Inspect refreshed specs with \`openspec list --specs\` and address any validation issues before handing off.`;
|
||||
|
||||
export const slashCommandBodies: Record<SlashCommandId, string> = {
|
||||
|
||||
@@ -114,6 +114,8 @@ export class Validator {
|
||||
const issues: ValidationIssue[] = [];
|
||||
const specsDir = path.join(changeDir, 'specs');
|
||||
let totalDeltas = 0;
|
||||
const missingHeaderSpecs: string[] = [];
|
||||
const emptySectionSpecs: Array<{ path: string; sections: string[] }> = [];
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(specsDir, { withFileTypes: true });
|
||||
@@ -130,6 +132,17 @@ export class Validator {
|
||||
|
||||
const plan = parseDeltaSpec(content);
|
||||
const entryPath = `${specName}/spec.md`;
|
||||
const sectionNames: string[] = [];
|
||||
if (plan.sectionPresence.added) sectionNames.push('## ADDED Requirements');
|
||||
if (plan.sectionPresence.modified) sectionNames.push('## MODIFIED Requirements');
|
||||
if (plan.sectionPresence.removed) sectionNames.push('## REMOVED Requirements');
|
||||
if (plan.sectionPresence.renamed) sectionNames.push('## RENAMED Requirements');
|
||||
const hasSections = sectionNames.length > 0;
|
||||
const hasEntries = plan.added.length + plan.modified.length + plan.removed.length + plan.renamed.length > 0;
|
||||
if (!hasEntries) {
|
||||
if (hasSections) emptySectionSpecs.push({ path: entryPath, sections: sectionNames });
|
||||
else missingHeaderSpecs.push(entryPath);
|
||||
}
|
||||
|
||||
const addedNames = new Set<string>();
|
||||
const modifiedNames = new Set<string>();
|
||||
@@ -236,6 +249,21 @@ export class Validator {
|
||||
// If no specs dir, treat as no deltas
|
||||
}
|
||||
|
||||
for (const { path: specPath, sections } of emptySectionSpecs) {
|
||||
issues.push({
|
||||
level: 'ERROR',
|
||||
path: specPath,
|
||||
message: `Delta sections ${this.formatSectionList(sections)} were found, but no requirement entries parsed. Ensure each section includes at least one "### Requirement:" block (REMOVED may use bullet list syntax).`,
|
||||
});
|
||||
}
|
||||
for (const path of missingHeaderSpecs) {
|
||||
issues.push({
|
||||
level: 'ERROR',
|
||||
path,
|
||||
message: 'No delta sections found. Add headers such as "## ADDED Requirements" or move non-delta notes outside specs/.',
|
||||
});
|
||||
}
|
||||
|
||||
if (totalDeltas === 0) {
|
||||
issues.push({ level: 'ERROR', path: 'file', message: this.enrichTopLevelError('change', VALIDATION_MESSAGES.CHANGE_NO_DELTAS) });
|
||||
}
|
||||
@@ -375,16 +403,30 @@ export class Validator {
|
||||
|
||||
private extractRequirementText(blockRaw: string): string | undefined {
|
||||
const lines = blockRaw.split('\n');
|
||||
// Skip header
|
||||
// Skip header line (index 0)
|
||||
let i = 1;
|
||||
const bodyLines: string[] = [];
|
||||
|
||||
// Find the first substantial text line, skipping metadata and blank lines
|
||||
for (; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (/^####\s+/.test(line)) break; // scenarios start
|
||||
bodyLines.push(line);
|
||||
|
||||
// Stop at scenario headers
|
||||
if (/^####\s+/.test(line)) break;
|
||||
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Skip blank lines
|
||||
if (trimmed.length === 0) continue;
|
||||
|
||||
// Skip metadata lines (lines starting with ** like **ID**, **Priority**, etc.)
|
||||
if (/^\*\*[^*]+\*\*:/.test(trimmed)) continue;
|
||||
|
||||
// Found first non-metadata, non-blank line - this is the requirement text
|
||||
return trimmed;
|
||||
}
|
||||
const text = bodyLines.join('\n').split('\n').map(l => l.trim()).find(l => l.length > 0);
|
||||
return text;
|
||||
|
||||
// No requirement text found
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private containsShallOrMust(text: string): boolean {
|
||||
@@ -395,4 +437,12 @@ export class Validator {
|
||||
const matches = blockRaw.match(/^####\s+/gm);
|
||||
return matches ? matches.length : 0;
|
||||
}
|
||||
|
||||
private formatSectionList(sections: string[]): string {
|
||||
if (sections.length === 0) return '';
|
||||
if (sections.length === 1) return sections[0];
|
||||
const head = sections.slice(0, -1);
|
||||
const last = sections[sections.length - 1];
|
||||
return `${head.join(', ')} and ${last}`;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -1 +1,2 @@
|
||||
export { slugifyFactoryCommand } from './slug.js';
|
||||
// Shared utilities will be implemented here
|
||||
export {};
|
||||
@@ -1,19 +0,0 @@
|
||||
export function slugifyFactoryCommand(name: string): string {
|
||||
const normalized = name
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^A-Za-z0-9 _-]/g, '')
|
||||
.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const slug = normalized
|
||||
.replace(/[\s_]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.toLowerCase();
|
||||
|
||||
return slug;
|
||||
}
|
||||
@@ -84,7 +84,11 @@ describe('openspec CLI e2e basics', () => {
|
||||
const emptyProjectDir = path.join(projectDir, '..', 'empty-project');
|
||||
await fs.mkdir(emptyProjectDir, { recursive: true });
|
||||
|
||||
const result = await runCLI(['init', '--tools', 'all'], { cwd: emptyProjectDir });
|
||||
const codexHome = path.join(emptyProjectDir, '.codex');
|
||||
const result = await runCLI(['init', '--tools', 'all'], {
|
||||
cwd: emptyProjectDir,
|
||||
env: { CODEX_HOME: codexHome },
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Tool summary:');
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { ArchiveCommand } from '../../src/core/archive.js';
|
||||
import { Validator } from '../../src/core/validation/validator.js';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
@@ -215,6 +216,46 @@ Then expected result happens`;
|
||||
expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`));
|
||||
});
|
||||
|
||||
it('should skip validation when commander sets validate to false (--no-validate)', async () => {
|
||||
const changeName = 'skip-validation-flag';
|
||||
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
|
||||
const changeSpecDir = path.join(changeDir, 'specs', 'unstable-capability');
|
||||
await fs.mkdir(changeSpecDir, { recursive: true });
|
||||
|
||||
const deltaSpec = `# Unstable Capability
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Logging Feature
|
||||
**ID**: REQ-LOG-001
|
||||
|
||||
The system will log all events.
|
||||
|
||||
#### Scenario: Event recorded
|
||||
- **WHEN** an event occurs
|
||||
- **THEN** it is captured`;
|
||||
await fs.writeFile(path.join(changeSpecDir, 'spec.md'), deltaSpec);
|
||||
await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [x] Task 1\n');
|
||||
|
||||
const deltaSpy = vi.spyOn(Validator.prototype, 'validateChangeDeltaSpecs');
|
||||
const specContentSpy = vi.spyOn(Validator.prototype, 'validateSpecContent');
|
||||
|
||||
try {
|
||||
await archiveCommand.execute(changeName, { yes: true, skipSpecs: true, validate: false });
|
||||
|
||||
expect(deltaSpy).not.toHaveBeenCalled();
|
||||
expect(specContentSpy).not.toHaveBeenCalled();
|
||||
|
||||
const archiveDir = path.join(tempDir, 'openspec', 'changes', 'archive');
|
||||
const archives = await fs.readdir(archiveDir);
|
||||
expect(archives.length).toBe(1);
|
||||
expect(archives[0]).toMatch(new RegExp(`\\d{4}-\\d{2}-\\d{2}-${changeName}`));
|
||||
} finally {
|
||||
deltaSpy.mockRestore();
|
||||
specContentSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should proceed with archive when user declines spec updates', async () => {
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
const mockConfirm = confirm as unknown as ReturnType<typeof vi.fn>;
|
||||
@@ -636,4 +677,4 @@ E1 updated`);
|
||||
await expect(fs.access(changeDir)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,6 +136,39 @@ describe('InitCommand', () => {
|
||||
expect(updatedContent).toContain('Custom instructions here');
|
||||
});
|
||||
|
||||
it('should create CLINE.md when Cline is selected', async () => {
|
||||
queueSelections('cline', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const clinePath = path.join(testDir, 'CLINE.md');
|
||||
expect(await fileExists(clinePath)).toBe(true);
|
||||
|
||||
const content = await fs.readFile(clinePath, 'utf-8');
|
||||
expect(content).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(content).toContain("@/openspec/AGENTS.md");
|
||||
expect(content).toContain('openspec update');
|
||||
expect(content).toContain('<!-- OPENSPEC:END -->');
|
||||
});
|
||||
|
||||
it('should update existing CLINE.md with markers', async () => {
|
||||
queueSelections('cline', DONE);
|
||||
|
||||
const clinePath = path.join(testDir, 'CLINE.md');
|
||||
const existingContent =
|
||||
'# My Cline Rules\nCustom Cline instructions here';
|
||||
await fs.writeFile(clinePath, existingContent);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const updatedContent = await fs.readFile(clinePath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain("@/openspec/AGENTS.md");
|
||||
expect(updatedContent).toContain('openspec update');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).toContain('Custom Cline instructions here');
|
||||
});
|
||||
|
||||
it('should create Windsurf workflows when Windsurf is selected', async () => {
|
||||
queueSelections('windsurf', DONE);
|
||||
|
||||
@@ -272,6 +305,73 @@ describe('InitCommand', () => {
|
||||
expect(archiveContent).toContain('openspec list --specs');
|
||||
});
|
||||
|
||||
it('should create Gemini CLI TOML files when selected', async () => {
|
||||
queueSelections('gemini', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const geminiProposal = path.join(
|
||||
testDir,
|
||||
'.gemini/commands/openspec/proposal.toml'
|
||||
);
|
||||
const geminiApply = path.join(
|
||||
testDir,
|
||||
'.gemini/commands/openspec/apply.toml'
|
||||
);
|
||||
const geminiArchive = path.join(
|
||||
testDir,
|
||||
'.gemini/commands/openspec/archive.toml'
|
||||
);
|
||||
|
||||
expect(await fileExists(geminiProposal)).toBe(true);
|
||||
expect(await fileExists(geminiApply)).toBe(true);
|
||||
expect(await fileExists(geminiArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(geminiProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('description = "Scaffold a new OpenSpec change and validate strictly."');
|
||||
expect(proposalContent).toContain('prompt = """');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(proposalContent).toContain('**Guardrails**');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:END -->');
|
||||
|
||||
const applyContent = await fs.readFile(geminiApply, 'utf-8');
|
||||
expect(applyContent).toContain('description = "Implement an approved OpenSpec change and keep tasks in sync."');
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
|
||||
const archiveContent = await fs.readFile(geminiArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('description = "Archive a deployed OpenSpec change and update specs."');
|
||||
expect(archiveContent).toContain('openspec archive <id>');
|
||||
});
|
||||
|
||||
it('should update existing Gemini CLI TOML files with refreshed content', async () => {
|
||||
queueSelections('gemini', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const geminiProposal = path.join(
|
||||
testDir,
|
||||
'.gemini/commands/openspec/proposal.toml'
|
||||
);
|
||||
|
||||
// Modify the file to simulate user customization
|
||||
const originalContent = await fs.readFile(geminiProposal, 'utf-8');
|
||||
const modifiedContent = originalContent.replace(
|
||||
'<!-- OPENSPEC:START -->',
|
||||
'<!-- OPENSPEC:START -->\nCustom instruction added by user\n'
|
||||
);
|
||||
await fs.writeFile(geminiProposal, modifiedContent);
|
||||
|
||||
// Run init again to test update/refresh path
|
||||
queueSelections('gemini', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const updatedContent = await fs.readFile(geminiProposal, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain('**Guardrails**');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).not.toContain('Custom instruction added by user');
|
||||
});
|
||||
|
||||
it('should create OpenCode slash command files with templates', async () => {
|
||||
queueSelections('opencode', DONE);
|
||||
|
||||
@@ -316,6 +416,163 @@ describe('InitCommand', () => {
|
||||
expect(archiveContent).toContain('openspec list --specs');
|
||||
});
|
||||
|
||||
it('should create Qwen configuration and slash command files with templates', async () => {
|
||||
queueSelections('qwen', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const qwenConfigPath = path.join(testDir, 'QWEN.md');
|
||||
const proposalPath = path.join(
|
||||
testDir,
|
||||
'.qwen/commands/openspec-proposal.md'
|
||||
);
|
||||
const applyPath = path.join(
|
||||
testDir,
|
||||
'.qwen/commands/openspec-apply.md'
|
||||
);
|
||||
const archivePath = path.join(
|
||||
testDir,
|
||||
'.qwen/commands/openspec-archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(qwenConfigPath)).toBe(true);
|
||||
expect(await fileExists(proposalPath)).toBe(true);
|
||||
expect(await fileExists(applyPath)).toBe(true);
|
||||
expect(await fileExists(archivePath)).toBe(true);
|
||||
|
||||
const qwenConfigContent = await fs.readFile(qwenConfigPath, 'utf-8');
|
||||
expect(qwenConfigContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(qwenConfigContent).toContain("@/openspec/AGENTS.md");
|
||||
expect(qwenConfigContent).toContain('<!-- OPENSPEC:END -->');
|
||||
|
||||
const proposalContent = await fs.readFile(proposalPath, 'utf-8');
|
||||
expect(proposalContent).toContain('name: /openspec-proposal');
|
||||
expect(proposalContent).toContain('category: OpenSpec');
|
||||
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
|
||||
const applyContent = await fs.readFile(applyPath, 'utf-8');
|
||||
expect(applyContent).toContain('name: /openspec-apply');
|
||||
expect(applyContent).toContain('category: OpenSpec');
|
||||
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
|
||||
const archiveContent = await fs.readFile(archivePath, 'utf-8');
|
||||
expect(archiveContent).toContain('name: /openspec-archive');
|
||||
expect(archiveContent).toContain('category: OpenSpec');
|
||||
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
|
||||
expect(archiveContent).toContain('openspec archive <id>');
|
||||
});
|
||||
|
||||
it('should update existing QWEN.md with markers', async () => {
|
||||
queueSelections('qwen', DONE);
|
||||
|
||||
const qwenPath = path.join(testDir, 'QWEN.md');
|
||||
const existingContent = '# My Qwen Instructions\nCustom instructions here';
|
||||
await fs.writeFile(qwenPath, existingContent);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const updatedContent = await fs.readFile(qwenPath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain("@/openspec/AGENTS.md");
|
||||
expect(updatedContent).toContain('openspec update');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).toContain('Custom instructions here');
|
||||
});
|
||||
|
||||
it('should create Cline rule files with templates', async () => {
|
||||
queueSelections('cline', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const clineProposal = path.join(
|
||||
testDir,
|
||||
'.clinerules/openspec-proposal.md'
|
||||
);
|
||||
const clineApply = path.join(
|
||||
testDir,
|
||||
'.clinerules/openspec-apply.md'
|
||||
);
|
||||
const clineArchive = path.join(
|
||||
testDir,
|
||||
'.clinerules/openspec-archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(clineProposal)).toBe(true);
|
||||
expect(await fileExists(clineApply)).toBe(true);
|
||||
expect(await fileExists(clineArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(clineProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('# OpenSpec: Proposal');
|
||||
expect(proposalContent).toContain('Scaffold a new OpenSpec change and validate strictly.');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(proposalContent).toContain('**Guardrails**');
|
||||
|
||||
const applyContent = await fs.readFile(clineApply, 'utf-8');
|
||||
expect(applyContent).toContain('# OpenSpec: Apply');
|
||||
expect(applyContent).toContain('Implement an approved OpenSpec change and keep tasks in sync.');
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
|
||||
const archiveContent = await fs.readFile(clineArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('# OpenSpec: Archive');
|
||||
expect(archiveContent).toContain('Archive a deployed OpenSpec change and update specs.');
|
||||
expect(archiveContent).toContain('openspec archive <id>');
|
||||
});
|
||||
|
||||
it('should create Factory slash command files with templates', async () => {
|
||||
queueSelections('factory', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const factoryProposal = path.join(
|
||||
testDir,
|
||||
'.factory/commands/openspec-proposal.md'
|
||||
);
|
||||
const factoryApply = path.join(
|
||||
testDir,
|
||||
'.factory/commands/openspec-apply.md'
|
||||
);
|
||||
const factoryArchive = path.join(
|
||||
testDir,
|
||||
'.factory/commands/openspec-archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(factoryProposal)).toBe(true);
|
||||
expect(await fileExists(factoryApply)).toBe(true);
|
||||
expect(await fileExists(factoryArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(factoryProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
|
||||
expect(proposalContent).toContain('argument-hint: request or feature description');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(
|
||||
/<!-- OPENSPEC:START -->([\s\S]*?)<!-- OPENSPEC:END -->/u.exec(
|
||||
proposalContent
|
||||
)?.[1]
|
||||
).toContain('$ARGUMENTS');
|
||||
|
||||
const applyContent = await fs.readFile(factoryApply, 'utf-8');
|
||||
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
|
||||
expect(applyContent).toContain('argument-hint: change-id');
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
expect(
|
||||
/<!-- OPENSPEC:START -->([\s\S]*?)<!-- OPENSPEC:END -->/u.exec(
|
||||
applyContent
|
||||
)?.[1]
|
||||
).toContain('$ARGUMENTS');
|
||||
|
||||
const archiveContent = await fs.readFile(factoryArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
|
||||
expect(archiveContent).toContain('argument-hint: change-id');
|
||||
expect(archiveContent).toContain('openspec archive <id> --yes');
|
||||
expect(
|
||||
/<!-- OPENSPEC:START -->([\s\S]*?)<!-- OPENSPEC:END -->/u.exec(
|
||||
archiveContent
|
||||
)?.[1]
|
||||
).toContain('$ARGUMENTS');
|
||||
});
|
||||
|
||||
it('should create Codex prompts with templates and placeholders', async () => {
|
||||
queueSelections('codex', DONE);
|
||||
|
||||
@@ -454,6 +711,44 @@ describe('InitCommand', () => {
|
||||
await expect(initCommand.execute(testDir)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should recreate deleted openspec/AGENTS.md in extend mode', async () => {
|
||||
await testFileRecreationInExtendMode(
|
||||
testDir,
|
||||
initCommand,
|
||||
'openspec/AGENTS.md',
|
||||
'OpenSpec Instructions'
|
||||
);
|
||||
});
|
||||
|
||||
it('should recreate deleted openspec/project.md in extend mode', async () => {
|
||||
await testFileRecreationInExtendMode(
|
||||
testDir,
|
||||
initCommand,
|
||||
'openspec/project.md',
|
||||
'Project Context'
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve existing template files in extend mode', async () => {
|
||||
queueSelections('claude', DONE, DONE);
|
||||
|
||||
// First init
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const agentsPath = path.join(testDir, 'openspec', 'AGENTS.md');
|
||||
const customContent = '# My Custom AGENTS Content\nDo not overwrite this!';
|
||||
|
||||
// Modify the file with custom content
|
||||
await fs.writeFile(agentsPath, customContent);
|
||||
|
||||
// Run init again - should NOT overwrite
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const content = await fs.readFile(agentsPath, 'utf-8');
|
||||
expect(content).toBe(customContent);
|
||||
expect(content).not.toContain('OpenSpec Instructions');
|
||||
});
|
||||
|
||||
it('should handle non-existent target directory', async () => {
|
||||
queueSelections('claude', DONE);
|
||||
|
||||
@@ -525,6 +820,18 @@ describe('InitCommand', () => {
|
||||
expect(claudeChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should mark Qwen as already configured during extend mode', async () => {
|
||||
queueSelections('qwen', DONE, 'qwen', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const secondRunArgs = mockPrompt.mock.calls[1][0];
|
||||
const qwenChoice = secondRunArgs.choices.find(
|
||||
(choice: any) => choice.value === 'qwen'
|
||||
);
|
||||
expect(qwenChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should preselect Kilo Code when workflows already exist', async () => {
|
||||
queueSelections('kilocode', DONE, 'kilocode', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
@@ -559,6 +866,18 @@ describe('InitCommand', () => {
|
||||
expect(codexChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should mark Factory Droid as already configured during extend mode', async () => {
|
||||
queueSelections('factory', DONE, 'factory', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const secondRunArgs = mockPrompt.mock.calls[1][0];
|
||||
const factoryChoice = secondRunArgs.choices.find(
|
||||
(choice: any) => choice.value === 'factory'
|
||||
);
|
||||
expect(factoryChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should mark GitHub Copilot as already configured during extend mode', async () => {
|
||||
queueSelections('github-copilot', DONE, 'github-copilot', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
@@ -618,6 +937,381 @@ describe('InitCommand', () => {
|
||||
);
|
||||
expect(amazonQChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should create Auggie slash command files with templates', async () => {
|
||||
queueSelections('auggie', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const auggieProposal = path.join(
|
||||
testDir,
|
||||
'.augment/commands/openspec-proposal.md'
|
||||
);
|
||||
const auggieApply = path.join(
|
||||
testDir,
|
||||
'.augment/commands/openspec-apply.md'
|
||||
);
|
||||
const auggieArchive = path.join(
|
||||
testDir,
|
||||
'.augment/commands/openspec-archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(auggieProposal)).toBe(true);
|
||||
expect(await fileExists(auggieApply)).toBe(true);
|
||||
expect(await fileExists(auggieArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(auggieProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('---');
|
||||
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
|
||||
expect(proposalContent).toContain('argument-hint: feature description or request');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(proposalContent).toContain('**Guardrails**');
|
||||
|
||||
const applyContent = await fs.readFile(auggieApply, 'utf-8');
|
||||
expect(applyContent).toContain('---');
|
||||
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
|
||||
expect(applyContent).toContain('argument-hint: change-id');
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
|
||||
const archiveContent = await fs.readFile(auggieArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('---');
|
||||
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
|
||||
expect(archiveContent).toContain('argument-hint: change-id');
|
||||
expect(archiveContent).toContain('openspec archive <id> --yes');
|
||||
});
|
||||
|
||||
it('should mark Auggie as already configured during extend mode', async () => {
|
||||
queueSelections('auggie', DONE, 'auggie', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const secondRunArgs = mockPrompt.mock.calls[1][0];
|
||||
const auggieChoice = secondRunArgs.choices.find(
|
||||
(choice: any) => choice.value === 'auggie'
|
||||
);
|
||||
expect(auggieChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should create CodeBuddy slash command files with templates', async () => {
|
||||
queueSelections('codebuddy', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const codeBuddyProposal = path.join(
|
||||
testDir,
|
||||
'.codebuddy/commands/openspec/proposal.md'
|
||||
);
|
||||
const codeBuddyApply = path.join(
|
||||
testDir,
|
||||
'.codebuddy/commands/openspec/apply.md'
|
||||
);
|
||||
const codeBuddyArchive = path.join(
|
||||
testDir,
|
||||
'.codebuddy/commands/openspec/archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(codeBuddyProposal)).toBe(true);
|
||||
expect(await fileExists(codeBuddyApply)).toBe(true);
|
||||
expect(await fileExists(codeBuddyArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(codeBuddyProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('---');
|
||||
expect(proposalContent).toContain('name: OpenSpec: Proposal');
|
||||
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
|
||||
expect(proposalContent).toContain('category: OpenSpec');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(proposalContent).toContain('**Guardrails**');
|
||||
|
||||
const applyContent = await fs.readFile(codeBuddyApply, 'utf-8');
|
||||
expect(applyContent).toContain('---');
|
||||
expect(applyContent).toContain('name: OpenSpec: Apply');
|
||||
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
|
||||
const archiveContent = await fs.readFile(codeBuddyArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('---');
|
||||
expect(archiveContent).toContain('name: OpenSpec: Archive');
|
||||
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
|
||||
expect(archiveContent).toContain('openspec archive <id> --yes');
|
||||
});
|
||||
|
||||
it('should mark CodeBuddy as already configured during extend mode', async () => {
|
||||
queueSelections('codebuddy', DONE, 'codebuddy', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const secondRunArgs = mockPrompt.mock.calls[1][0];
|
||||
const codeBuddyChoice = secondRunArgs.choices.find(
|
||||
(choice: any) => choice.value === 'codebuddy'
|
||||
);
|
||||
expect(codeBuddyChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should create CODEBUDDY.md when CodeBuddy is selected', async () => {
|
||||
queueSelections('codebuddy', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const codeBuddyPath = path.join(testDir, 'CODEBUDDY.md');
|
||||
expect(await fileExists(codeBuddyPath)).toBe(true);
|
||||
|
||||
const content = await fs.readFile(codeBuddyPath, 'utf-8');
|
||||
expect(content).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(content).toContain("@/openspec/AGENTS.md");
|
||||
expect(content).toContain('openspec update');
|
||||
expect(content).toContain('<!-- OPENSPEC:END -->');
|
||||
});
|
||||
|
||||
it('should update existing CODEBUDDY.md with markers', async () => {
|
||||
queueSelections('codebuddy', DONE);
|
||||
|
||||
const codeBuddyPath = path.join(testDir, 'CODEBUDDY.md');
|
||||
const existingContent =
|
||||
'# My CodeBuddy Instructions\nCustom instructions here';
|
||||
await fs.writeFile(codeBuddyPath, existingContent);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const updatedContent = await fs.readFile(codeBuddyPath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain("@/openspec/AGENTS.md");
|
||||
expect(updatedContent).toContain('openspec update');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).toContain('Custom instructions here');
|
||||
});
|
||||
|
||||
it('should create Crush slash command files with templates', async () => {
|
||||
queueSelections('crush', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const crushProposal = path.join(
|
||||
testDir,
|
||||
'.crush/commands/openspec/proposal.md'
|
||||
);
|
||||
const crushApply = path.join(
|
||||
testDir,
|
||||
'.crush/commands/openspec/apply.md'
|
||||
);
|
||||
const crushArchive = path.join(
|
||||
testDir,
|
||||
'.crush/commands/openspec/archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(crushProposal)).toBe(true);
|
||||
expect(await fileExists(crushApply)).toBe(true);
|
||||
expect(await fileExists(crushArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(crushProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('---');
|
||||
expect(proposalContent).toContain('name: OpenSpec: Proposal');
|
||||
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
|
||||
expect(proposalContent).toContain('category: OpenSpec');
|
||||
expect(proposalContent).toContain('tags: [openspec, change]');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(proposalContent).toContain('**Guardrails**');
|
||||
|
||||
const applyContent = await fs.readFile(crushApply, 'utf-8');
|
||||
expect(applyContent).toContain('---');
|
||||
expect(applyContent).toContain('name: OpenSpec: Apply');
|
||||
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
|
||||
expect(applyContent).toContain('category: OpenSpec');
|
||||
expect(applyContent).toContain('tags: [openspec, apply]');
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
|
||||
const archiveContent = await fs.readFile(crushArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('---');
|
||||
expect(archiveContent).toContain('name: OpenSpec: Archive');
|
||||
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
|
||||
expect(archiveContent).toContain('category: OpenSpec');
|
||||
expect(archiveContent).toContain('tags: [openspec, archive]');
|
||||
expect(archiveContent).toContain('openspec archive <id> --yes');
|
||||
});
|
||||
|
||||
it('should mark Crush as already configured during extend mode', async () => {
|
||||
queueSelections('crush', DONE, 'crush', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const secondRunArgs = mockPrompt.mock.calls[1][0];
|
||||
const crushChoice = secondRunArgs.choices.find(
|
||||
(choice: any) => choice.value === 'crush'
|
||||
);
|
||||
expect(crushChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should create CoStrict slash command files with templates', async () => {
|
||||
queueSelections('costrict', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const costrictProposal = path.join(
|
||||
testDir,
|
||||
'.cospec/openspec/commands/openspec-proposal.md'
|
||||
);
|
||||
const costrictApply = path.join(
|
||||
testDir,
|
||||
'.cospec/openspec/commands/openspec-apply.md'
|
||||
);
|
||||
const costrictArchive = path.join(
|
||||
testDir,
|
||||
'.cospec/openspec/commands/openspec-archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(costrictProposal)).toBe(true);
|
||||
expect(await fileExists(costrictApply)).toBe(true);
|
||||
expect(await fileExists(costrictArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(costrictProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('---');
|
||||
expect(proposalContent).toContain('description: "Scaffold a new OpenSpec change and validate strictly."');
|
||||
expect(proposalContent).toContain('argument-hint: feature description or request');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(proposalContent).toContain('**Guardrails**');
|
||||
|
||||
const applyContent = await fs.readFile(costrictApply, 'utf-8');
|
||||
expect(applyContent).toContain('---');
|
||||
expect(applyContent).toContain('description: "Implement an approved OpenSpec change and keep tasks in sync."');
|
||||
expect(applyContent).toContain('argument-hint: change-id');
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
|
||||
const archiveContent = await fs.readFile(costrictArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('---');
|
||||
expect(archiveContent).toContain('description: "Archive a deployed OpenSpec change and update specs."');
|
||||
expect(archiveContent).toContain('argument-hint: change-id');
|
||||
expect(archiveContent).toContain('openspec archive <id> --yes');
|
||||
});
|
||||
|
||||
it('should mark CoStrict as already configured during extend mode', async () => {
|
||||
queueSelections('costrict', DONE, 'costrict', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const secondRunArgs = mockPrompt.mock.calls[1][0];
|
||||
const costrictChoice = secondRunArgs.choices.find(
|
||||
(choice: any) => choice.value === 'costrict'
|
||||
);
|
||||
expect(costrictChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should create Qoder slash command files with templates', async () => {
|
||||
queueSelections('qoder', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const qoderProposal = path.join(
|
||||
testDir,
|
||||
'.qoder/commands/openspec/proposal.md'
|
||||
);
|
||||
const qoderApply = path.join(
|
||||
testDir,
|
||||
'.qoder/commands/openspec/apply.md'
|
||||
);
|
||||
const qoderArchive = path.join(
|
||||
testDir,
|
||||
'.qoder/commands/openspec/archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(qoderProposal)).toBe(true);
|
||||
expect(await fileExists(qoderApply)).toBe(true);
|
||||
expect(await fileExists(qoderArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(qoderProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('---');
|
||||
expect(proposalContent).toContain('name: OpenSpec: Proposal');
|
||||
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
|
||||
expect(proposalContent).toContain('category: OpenSpec');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(proposalContent).toContain('**Guardrails**');
|
||||
|
||||
const applyContent = await fs.readFile(qoderApply, 'utf-8');
|
||||
expect(applyContent).toContain('---');
|
||||
expect(applyContent).toContain('name: OpenSpec: Apply');
|
||||
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
|
||||
const archiveContent = await fs.readFile(qoderArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('---');
|
||||
expect(archiveContent).toContain('name: OpenSpec: Archive');
|
||||
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
|
||||
expect(archiveContent).toContain('openspec archive <id> --yes');
|
||||
});
|
||||
|
||||
it('should mark Qoder as already configured during extend mode', async () => {
|
||||
queueSelections('qoder', DONE, 'qoder', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const secondRunArgs = mockPrompt.mock.calls[1][0];
|
||||
const qoderChoice = secondRunArgs.choices.find(
|
||||
(choice: any) => choice.value === 'qoder'
|
||||
);
|
||||
expect(qoderChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should create COSTRICT.md when CoStrict is selected', async () => {
|
||||
queueSelections('costrict', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const costrictPath = path.join(testDir, 'COSTRICT.md');
|
||||
expect(await fileExists(costrictPath)).toBe(true);
|
||||
|
||||
const content = await fs.readFile(costrictPath, 'utf-8');
|
||||
expect(content).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(content).toContain("@/openspec/AGENTS.md");
|
||||
expect(content).toContain('openspec update');
|
||||
expect(content).toContain('<!-- OPENSPEC:END -->');
|
||||
});
|
||||
|
||||
it('should create QODER.md when Qoder is selected', async () => {
|
||||
queueSelections('qoder', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const qoderPath = path.join(testDir, 'QODER.md');
|
||||
expect(await fileExists(qoderPath)).toBe(true);
|
||||
|
||||
const content = await fs.readFile(qoderPath, 'utf-8');
|
||||
expect(content).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(content).toContain("@/openspec/AGENTS.md");
|
||||
expect(content).toContain('openspec update');
|
||||
expect(content).toContain('<!-- OPENSPEC:END -->');
|
||||
});
|
||||
|
||||
it('should update existing COSTRICT.md with markers', async () => {
|
||||
queueSelections('costrict', DONE);
|
||||
|
||||
const costrictPath = path.join(testDir, 'COSTRICT.md');
|
||||
const existingContent =
|
||||
'# My CoStrict Instructions\nCustom instructions here';
|
||||
await fs.writeFile(costrictPath, existingContent);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const updatedContent = await fs.readFile(costrictPath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain('# My CoStrict Instructions');
|
||||
expect(updatedContent).toContain('Custom instructions here');
|
||||
});
|
||||
|
||||
it('should update existing QODER.md with markers', async () => {
|
||||
queueSelections('qoder', DONE);
|
||||
|
||||
const qoderPath = path.join(testDir, 'QODER.md');
|
||||
const existingContent =
|
||||
'# My Qoder Instructions\nCustom instructions here';
|
||||
await fs.writeFile(qoderPath, existingContent);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const updatedContent = await fs.readFile(qoderPath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain("@/openspec/AGENTS.md");
|
||||
expect(updatedContent).toContain('openspec update');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).toContain('Custom instructions here');
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-interactive mode', () => {
|
||||
@@ -712,6 +1406,87 @@ describe('InitCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('already configured detection', () => {
|
||||
it('should NOT show tools as already configured in fresh project with existing CLAUDE.md', async () => {
|
||||
// Simulate user having their own CLAUDE.md before running openspec init
|
||||
const claudePath = path.join(testDir, 'CLAUDE.md');
|
||||
await fs.writeFile(claudePath, '# My Custom Claude Instructions\n');
|
||||
|
||||
queueSelections('claude', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
// In the first run (non-interactive mode via queueSelections),
|
||||
// the prompt is called with configured: false for claude
|
||||
const firstCallArgs = mockPrompt.mock.calls[0][0];
|
||||
const claudeChoice = firstCallArgs.choices.find(
|
||||
(choice: any) => choice.value === 'claude'
|
||||
);
|
||||
|
||||
expect(claudeChoice.configured).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT show tools as already configured in fresh project with existing slash commands', async () => {
|
||||
// Simulate user having their own custom slash commands
|
||||
const customCommandDir = path.join(testDir, '.claude/commands/custom');
|
||||
await fs.mkdir(customCommandDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(customCommandDir, 'mycommand.md'),
|
||||
'# My Custom Command\n'
|
||||
);
|
||||
|
||||
queueSelections('claude', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const firstCallArgs = mockPrompt.mock.calls[0][0];
|
||||
const claudeChoice = firstCallArgs.choices.find(
|
||||
(choice: any) => choice.value === 'claude'
|
||||
);
|
||||
|
||||
expect(claudeChoice.configured).toBe(false);
|
||||
});
|
||||
|
||||
it('should show tools as already configured in extend mode', async () => {
|
||||
// First initialization
|
||||
queueSelections('claude', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
// Second initialization (extend mode)
|
||||
queueSelections('cursor', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const secondCallArgs = mockPrompt.mock.calls[1][0];
|
||||
const claudeChoice = secondCallArgs.choices.find(
|
||||
(choice: any) => choice.value === 'claude'
|
||||
);
|
||||
|
||||
expect(claudeChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT show already configured for Codex in fresh init even with global prompts', async () => {
|
||||
// Create global Codex prompts (simulating previous installation)
|
||||
const codexPromptsDir = path.join(testDir, '.codex/prompts');
|
||||
await fs.mkdir(codexPromptsDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(codexPromptsDir, 'openspec-proposal.md'),
|
||||
'# Existing prompt\n'
|
||||
);
|
||||
|
||||
queueSelections('claude', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const firstCallArgs = mockPrompt.mock.calls[0][0];
|
||||
const codexChoice = firstCallArgs.choices.find(
|
||||
(choice: any) => choice.value === 'codex'
|
||||
);
|
||||
|
||||
// In fresh init, even global tools should not show as configured
|
||||
expect(codexChoice.configured).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should provide helpful error for insufficient permissions', async () => {
|
||||
// This is tricky to test cross-platform, but we can test the error message
|
||||
@@ -740,6 +1515,32 @@ describe('InitCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function testFileRecreationInExtendMode(
|
||||
testDir: string,
|
||||
initCommand: InitCommand,
|
||||
relativePath: string,
|
||||
expectedContent: string
|
||||
): Promise<void> {
|
||||
queueSelections('claude', DONE, DONE);
|
||||
|
||||
// First init
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const filePath = path.join(testDir, relativePath);
|
||||
expect(await fileExists(filePath)).toBe(true);
|
||||
|
||||
// Delete the file
|
||||
await fs.unlink(filePath);
|
||||
expect(await fileExists(filePath)).toBe(false);
|
||||
|
||||
// Run init again - should recreate the file
|
||||
await initCommand.execute(testDir);
|
||||
expect(await fileExists(filePath)).toBe(true);
|
||||
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
expect(content).toContain(expectedContent);
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
|
||||
@@ -73,6 +73,41 @@ More content after.`;
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should update only existing QWEN.md file', async () => {
|
||||
const qwenPath = path.join(testDir, 'QWEN.md');
|
||||
const initialContent = `# Qwen Instructions
|
||||
|
||||
Some existing content.
|
||||
|
||||
<!-- OPENSPEC:START -->
|
||||
Old OpenSpec content
|
||||
<!-- OPENSPEC:END -->
|
||||
|
||||
More notes here.`;
|
||||
await fs.writeFile(qwenPath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updatedContent = await fs.readFile(qwenPath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).toContain("@/openspec/AGENTS.md");
|
||||
expect(updatedContent).toContain('openspec update');
|
||||
expect(updatedContent).toContain('Some existing content.');
|
||||
expect(updatedContent).toContain('More notes here.');
|
||||
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain('Updated AI tool files: QWEN.md');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should refresh existing Claude slash command files', async () => {
|
||||
const proposalPath = path.join(
|
||||
testDir,
|
||||
@@ -114,6 +149,87 @@ Old slash content
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should refresh existing Qwen slash command files', async () => {
|
||||
const applyPath = path.join(
|
||||
testDir,
|
||||
'.qwen/commands/openspec-apply.md'
|
||||
);
|
||||
await fs.mkdir(path.dirname(applyPath), { recursive: true });
|
||||
const initialContent = `---
|
||||
name: /openspec-apply
|
||||
id: openspec-apply
|
||||
category: OpenSpec
|
||||
description: Old description
|
||||
---
|
||||
|
||||
<!-- OPENSPEC:START -->
|
||||
Old body
|
||||
<!-- OPENSPEC:END -->`;
|
||||
await fs.writeFile(applyPath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updated = await fs.readFile(applyPath, 'utf-8');
|
||||
expect(updated).toContain('name: /openspec-apply');
|
||||
expect(updated).toContain('category: OpenSpec');
|
||||
expect(updated).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updated).toContain('Work through tasks sequentially');
|
||||
expect(updated).not.toContain('Old body');
|
||||
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain(
|
||||
'Updated slash commands: .qwen/commands/openspec-apply.md'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not create missing Qwen slash command files on update', async () => {
|
||||
const applyPath = path.join(
|
||||
testDir,
|
||||
'.qwen/commands/openspec-apply.md'
|
||||
);
|
||||
|
||||
await fs.mkdir(path.dirname(applyPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
applyPath,
|
||||
`---
|
||||
name: /openspec-apply
|
||||
id: openspec-apply
|
||||
category: OpenSpec
|
||||
description: Old description
|
||||
---
|
||||
|
||||
<!-- OPENSPEC:START -->
|
||||
Old content
|
||||
<!-- OPENSPEC:END -->`
|
||||
);
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updatedApply = await fs.readFile(applyPath, 'utf-8');
|
||||
expect(updatedApply).toContain('Work through tasks sequentially');
|
||||
expect(updatedApply).not.toContain('Old content');
|
||||
|
||||
const proposalPath = path.join(
|
||||
testDir,
|
||||
'.qwen/commands/openspec-proposal.md'
|
||||
);
|
||||
const archivePath = path.join(
|
||||
testDir,
|
||||
'.qwen/commands/openspec-archive.md'
|
||||
);
|
||||
|
||||
await expect(FileSystemUtils.fileExists(proposalPath)).resolves.toBe(false);
|
||||
await expect(FileSystemUtils.fileExists(archivePath)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should not create CLAUDE.md if it does not exist', async () => {
|
||||
// Ensure CLAUDE.md does not exist
|
||||
const claudePath = path.join(testDir, 'CLAUDE.md');
|
||||
@@ -126,6 +242,101 @@ Old slash content
|
||||
expect(fileExists).toBe(false);
|
||||
});
|
||||
|
||||
it('should not create QWEN.md if it does not exist', async () => {
|
||||
const qwenPath = path.join(testDir, 'QWEN.md');
|
||||
await updateCommand.execute(testDir);
|
||||
await expect(FileSystemUtils.fileExists(qwenPath)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should update only existing CLINE.md file', async () => {
|
||||
// Create CLINE.md file with initial content
|
||||
const clinePath = path.join(testDir, 'CLINE.md');
|
||||
const initialContent = `# Cline Rules
|
||||
|
||||
Some existing Cline rules here.
|
||||
|
||||
<!-- OPENSPEC:START -->
|
||||
Old OpenSpec content
|
||||
<!-- OPENSPEC:END -->
|
||||
|
||||
More rules after.`;
|
||||
await fs.writeFile(clinePath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
// Execute update command
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
// Check that CLINE.md was updated
|
||||
const updatedContent = await fs.readFile(clinePath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).toContain("@/openspec/AGENTS.md");
|
||||
expect(updatedContent).toContain('openspec update');
|
||||
expect(updatedContent).toContain('Some existing Cline rules here');
|
||||
expect(updatedContent).toContain('More rules after');
|
||||
|
||||
// Check console output
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain('Updated AI tool files: CLINE.md');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not create CLINE.md if it does not exist', async () => {
|
||||
// Ensure CLINE.md does not exist
|
||||
const clinePath = path.join(testDir, 'CLINE.md');
|
||||
|
||||
// Execute update command
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
// Check that CLINE.md was not created
|
||||
const fileExists = await FileSystemUtils.fileExists(clinePath);
|
||||
expect(fileExists).toBe(false);
|
||||
});
|
||||
|
||||
it('should refresh existing Cline rule files', async () => {
|
||||
const proposalPath = path.join(
|
||||
testDir,
|
||||
'.clinerules/openspec-proposal.md'
|
||||
);
|
||||
await fs.mkdir(path.dirname(proposalPath), { recursive: true });
|
||||
const initialContent = `# OpenSpec: Proposal
|
||||
|
||||
Scaffold a new OpenSpec change and validate strictly.
|
||||
|
||||
<!-- OPENSPEC:START -->
|
||||
Old slash content
|
||||
<!-- OPENSPEC:END -->`;
|
||||
await fs.writeFile(proposalPath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updated = await fs.readFile(proposalPath, 'utf-8');
|
||||
expect(updated).toContain('# OpenSpec: Proposal');
|
||||
expect(updated).toContain('**Guardrails**');
|
||||
expect(updated).toContain(
|
||||
'Validate with `openspec validate <id> --strict`'
|
||||
);
|
||||
expect(updated).not.toContain('Old slash content');
|
||||
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain(
|
||||
'Updated slash commands: .clinerules/openspec-proposal.md'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should refresh existing Cursor slash command files', async () => {
|
||||
const cursorPath = path.join(testDir, '.cursor/commands/openspec-apply.md');
|
||||
await fs.mkdir(path.dirname(cursorPath), { recursive: true });
|
||||
@@ -377,6 +588,124 @@ Old body
|
||||
await expect(FileSystemUtils.fileExists(ghArchive)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should refresh existing Gemini CLI TOML files without creating new ones', async () => {
|
||||
const geminiProposal = path.join(
|
||||
testDir,
|
||||
'.gemini/commands/openspec/proposal.toml'
|
||||
);
|
||||
await fs.mkdir(path.dirname(geminiProposal), { recursive: true });
|
||||
const initialContent = `description = "Scaffold a new OpenSpec change and validate strictly."
|
||||
|
||||
prompt = """
|
||||
<!-- OPENSPEC:START -->
|
||||
Old Gemini body
|
||||
<!-- OPENSPEC:END -->
|
||||
"""
|
||||
`;
|
||||
await fs.writeFile(geminiProposal, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updated = await fs.readFile(geminiProposal, 'utf-8');
|
||||
expect(updated).toContain('description = "Scaffold a new OpenSpec change and validate strictly."');
|
||||
expect(updated).toContain('prompt = """');
|
||||
expect(updated).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updated).toContain('**Guardrails**');
|
||||
expect(updated).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updated).not.toContain('Old Gemini body');
|
||||
|
||||
const geminiApply = path.join(
|
||||
testDir,
|
||||
'.gemini/commands/openspec/apply.toml'
|
||||
);
|
||||
const geminiArchive = path.join(
|
||||
testDir,
|
||||
'.gemini/commands/openspec/archive.toml'
|
||||
);
|
||||
|
||||
await expect(FileSystemUtils.fileExists(geminiApply)).resolves.toBe(false);
|
||||
await expect(FileSystemUtils.fileExists(geminiArchive)).resolves.toBe(false);
|
||||
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated slash commands: .gemini/commands/openspec/proposal.toml'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should refresh existing Factory slash commands', async () => {
|
||||
const factoryPath = path.join(
|
||||
testDir,
|
||||
'.factory/commands/openspec-proposal.md'
|
||||
);
|
||||
await fs.mkdir(path.dirname(factoryPath), { recursive: true });
|
||||
const initialContent = `---
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
argument-hint: request or feature description
|
||||
---
|
||||
|
||||
<!-- OPENSPEC:START -->
|
||||
Old body
|
||||
<!-- OPENSPEC:END -->`;
|
||||
await fs.writeFile(factoryPath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updated = await fs.readFile(factoryPath, 'utf-8');
|
||||
expect(updated).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
|
||||
expect(updated).toContain('argument-hint: request or feature description');
|
||||
expect(
|
||||
/<!-- OPENSPEC:START -->([\s\S]*?)<!-- OPENSPEC:END -->/u.exec(updated)?.[1]
|
||||
).toContain('$ARGUMENTS');
|
||||
expect(updated).toContain('**Guardrails**');
|
||||
expect(updated).not.toContain('Old body');
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('.factory/commands/openspec-proposal.md')
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not create missing Factory slash command files on update', async () => {
|
||||
const factoryApply = path.join(
|
||||
testDir,
|
||||
'.factory/commands/openspec-apply.md'
|
||||
);
|
||||
|
||||
await fs.mkdir(path.dirname(factoryApply), { recursive: true });
|
||||
await fs.writeFile(
|
||||
factoryApply,
|
||||
`---
|
||||
description: Old
|
||||
argument-hint: old
|
||||
---
|
||||
|
||||
<!-- OPENSPEC:START -->
|
||||
Old body
|
||||
<!-- OPENSPEC:END -->`
|
||||
);
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const factoryProposal = path.join(
|
||||
testDir,
|
||||
'.factory/commands/openspec-proposal.md'
|
||||
);
|
||||
const factoryArchive = path.join(
|
||||
testDir,
|
||||
'.factory/commands/openspec-archive.md'
|
||||
);
|
||||
|
||||
await expect(FileSystemUtils.fileExists(factoryProposal)).resolves.toBe(false);
|
||||
await expect(FileSystemUtils.fileExists(factoryArchive)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should refresh existing Amazon Q Developer prompts', async () => {
|
||||
const aqPath = path.join(
|
||||
testDir,
|
||||
@@ -443,6 +772,483 @@ Old body
|
||||
await expect(FileSystemUtils.fileExists(aqArchive)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should refresh existing Auggie slash command files', async () => {
|
||||
const auggiePath = path.join(
|
||||
testDir,
|
||||
'.augment/commands/openspec-apply.md'
|
||||
);
|
||||
await fs.mkdir(path.dirname(auggiePath), { recursive: true });
|
||||
const initialContent = `---
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
argument-hint: change-id
|
||||
---
|
||||
<!-- OPENSPEC:START -->
|
||||
Old body
|
||||
<!-- OPENSPEC:END -->`;
|
||||
await fs.writeFile(auggiePath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updatedContent = await fs.readFile(auggiePath, 'utf-8');
|
||||
expect(updatedContent).toContain('**Guardrails**');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).not.toContain('Old body');
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('.augment/commands/openspec-apply.md')
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not create missing Auggie slash command files on update', async () => {
|
||||
const auggieApply = path.join(
|
||||
testDir,
|
||||
'.augment/commands/openspec-apply.md'
|
||||
);
|
||||
|
||||
// Only create apply; leave proposal and archive missing
|
||||
await fs.mkdir(path.dirname(auggieApply), { recursive: true });
|
||||
await fs.writeFile(
|
||||
auggieApply,
|
||||
'---\ndescription: Old\nargument-hint: old\n---\n<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->'
|
||||
);
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const auggieProposal = path.join(
|
||||
testDir,
|
||||
'.augment/commands/openspec-proposal.md'
|
||||
);
|
||||
const auggieArchive = path.join(
|
||||
testDir,
|
||||
'.augment/commands/openspec-archive.md'
|
||||
);
|
||||
|
||||
// Confirm they weren't created by update
|
||||
await expect(FileSystemUtils.fileExists(auggieProposal)).resolves.toBe(false);
|
||||
await expect(FileSystemUtils.fileExists(auggieArchive)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should refresh existing CodeBuddy slash command files', async () => {
|
||||
const codeBuddyPath = path.join(
|
||||
testDir,
|
||||
'.codebuddy/commands/openspec/proposal.md'
|
||||
);
|
||||
await fs.mkdir(path.dirname(codeBuddyPath), { recursive: true });
|
||||
const initialContent = `---
|
||||
name: OpenSpec: Proposal
|
||||
description: Old description
|
||||
category: OpenSpec
|
||||
tags: [openspec, change]
|
||||
---
|
||||
<!-- OPENSPEC:START -->
|
||||
Old slash content
|
||||
<!-- OPENSPEC:END -->`;
|
||||
await fs.writeFile(codeBuddyPath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updated = await fs.readFile(codeBuddyPath, 'utf-8');
|
||||
expect(updated).toContain('name: OpenSpec: Proposal');
|
||||
expect(updated).toContain('**Guardrails**');
|
||||
expect(updated).toContain(
|
||||
'Validate with `openspec validate <id> --strict`'
|
||||
);
|
||||
expect(updated).not.toContain('Old slash content');
|
||||
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain(
|
||||
'Updated slash commands: .codebuddy/commands/openspec/proposal.md'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not create missing CodeBuddy slash command files on update', async () => {
|
||||
const codeBuddyApply = path.join(
|
||||
testDir,
|
||||
'.codebuddy/commands/openspec/apply.md'
|
||||
);
|
||||
|
||||
// Only create apply; leave proposal and archive missing
|
||||
await fs.mkdir(path.dirname(codeBuddyApply), { recursive: true });
|
||||
await fs.writeFile(
|
||||
codeBuddyApply,
|
||||
`---
|
||||
name: OpenSpec: Apply
|
||||
description: Old description
|
||||
category: OpenSpec
|
||||
tags: [openspec, apply]
|
||||
---
|
||||
<!-- OPENSPEC:START -->
|
||||
Old body
|
||||
<!-- OPENSPEC:END -->`
|
||||
);
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const codeBuddyProposal = path.join(
|
||||
testDir,
|
||||
'.codebuddy/commands/openspec/proposal.md'
|
||||
);
|
||||
const codeBuddyArchive = path.join(
|
||||
testDir,
|
||||
'.codebuddy/commands/openspec/archive.md'
|
||||
);
|
||||
|
||||
// Confirm they weren't created by update
|
||||
await expect(FileSystemUtils.fileExists(codeBuddyProposal)).resolves.toBe(false);
|
||||
await expect(FileSystemUtils.fileExists(codeBuddyArchive)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should refresh existing Crush slash command files', async () => {
|
||||
const crushPath = path.join(
|
||||
testDir,
|
||||
'.crush/commands/openspec/proposal.md'
|
||||
);
|
||||
await fs.mkdir(path.dirname(crushPath), { recursive: true });
|
||||
const initialContent = `---
|
||||
name: OpenSpec: Proposal
|
||||
description: Old description
|
||||
category: OpenSpec
|
||||
tags: [openspec, change]
|
||||
---
|
||||
<!-- OPENSPEC:START -->
|
||||
Old slash content
|
||||
<!-- OPENSPEC:END -->`;
|
||||
await fs.writeFile(crushPath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updated = await fs.readFile(crushPath, 'utf-8');
|
||||
expect(updated).toContain('name: OpenSpec: Proposal');
|
||||
expect(updated).toContain('**Guardrails**');
|
||||
expect(updated).toContain(
|
||||
'Validate with `openspec validate <id> --strict`'
|
||||
);
|
||||
expect(updated).not.toContain('Old slash content');
|
||||
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain(
|
||||
'Updated slash commands: .crush/commands/openspec/proposal.md'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not create missing Crush slash command files on update', async () => {
|
||||
const crushApply = path.join(
|
||||
testDir,
|
||||
'.crush/commands/openspec-apply.md'
|
||||
);
|
||||
|
||||
// Only create apply; leave proposal and archive missing
|
||||
await fs.mkdir(path.dirname(crushApply), { recursive: true });
|
||||
await fs.writeFile(
|
||||
crushApply,
|
||||
`---
|
||||
name: OpenSpec: Apply
|
||||
description: Old description
|
||||
category: OpenSpec
|
||||
tags: [openspec, apply]
|
||||
---
|
||||
<!-- OPENSPEC:START -->
|
||||
Old body
|
||||
<!-- OPENSPEC:END -->`
|
||||
);
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const crushProposal = path.join(
|
||||
testDir,
|
||||
'.crush/commands/openspec-proposal.md'
|
||||
);
|
||||
const crushArchive = path.join(
|
||||
testDir,
|
||||
'.crush/commands/openspec-archive.md'
|
||||
);
|
||||
|
||||
// Confirm they weren't created by update
|
||||
await expect(FileSystemUtils.fileExists(crushProposal)).resolves.toBe(false);
|
||||
await expect(FileSystemUtils.fileExists(crushArchive)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should refresh existing CoStrict slash command files', async () => {
|
||||
const costrictPath = path.join(
|
||||
testDir,
|
||||
'.cospec/openspec/commands/openspec-proposal.md'
|
||||
);
|
||||
await fs.mkdir(path.dirname(costrictPath), { recursive: true });
|
||||
const initialContent = `---
|
||||
description: "Old description"
|
||||
argument-hint: old-hint
|
||||
---
|
||||
<!-- OPENSPEC:START -->
|
||||
Old body
|
||||
<!-- OPENSPEC:END -->`;
|
||||
await fs.writeFile(costrictPath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updated = await fs.readFile(costrictPath, 'utf-8');
|
||||
// For slash commands, only the content between OpenSpec markers is updated
|
||||
expect(updated).toContain('description: "Old description"');
|
||||
expect(updated).toContain('argument-hint: old-hint');
|
||||
expect(updated).toContain('**Guardrails**');
|
||||
expect(updated).toContain(
|
||||
'Validate with `openspec validate <id> --strict`'
|
||||
);
|
||||
expect(updated).not.toContain('Old body');
|
||||
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain(
|
||||
'Updated slash commands: .cospec/openspec/commands/openspec-proposal.md'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should refresh existing Qoder slash command files', async () => {
|
||||
const qoderPath = path.join(
|
||||
testDir,
|
||||
'.qoder/commands/openspec/proposal.md'
|
||||
);
|
||||
await fs.mkdir(path.dirname(qoderPath), { recursive: true });
|
||||
const initialContent = `---
|
||||
name: OpenSpec: Proposal
|
||||
description: Old description
|
||||
category: OpenSpec
|
||||
tags: [openspec, change]
|
||||
---
|
||||
<!-- OPENSPEC:START -->
|
||||
Old slash content
|
||||
<!-- OPENSPEC:END -->`;
|
||||
await fs.writeFile(qoderPath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updated = await fs.readFile(qoderPath, 'utf-8');
|
||||
expect(updated).toContain('name: OpenSpec: Proposal');
|
||||
expect(updated).toContain('**Guardrails**');
|
||||
expect(updated).toContain(
|
||||
'Validate with `openspec validate <id> --strict`'
|
||||
);
|
||||
expect(updated).not.toContain('Old slash content');
|
||||
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain(
|
||||
'Updated slash commands: .qoder/commands/openspec/proposal.md'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not create missing CoStrict slash command files on update', async () => {
|
||||
const costrictApply = path.join(
|
||||
testDir,
|
||||
'.cospec/openspec/commands/openspec-apply.md'
|
||||
);
|
||||
|
||||
// Only create apply; leave proposal and archive missing
|
||||
await fs.mkdir(path.dirname(costrictApply), { recursive: true });
|
||||
await fs.writeFile(
|
||||
costrictApply,
|
||||
`---
|
||||
description: "Old"
|
||||
argument-hint: old
|
||||
---
|
||||
<!-- OPENSPEC:START -->
|
||||
Old
|
||||
<!-- OPENSPEC:END -->`
|
||||
);
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const costrictProposal = path.join(
|
||||
testDir,
|
||||
'.cospec/openspec/commands/openspec-proposal.md'
|
||||
);
|
||||
const costrictArchive = path.join(
|
||||
testDir,
|
||||
'.cospec/openspec/commands/openspec-archive.md'
|
||||
);
|
||||
|
||||
// Confirm they weren't created by update
|
||||
await expect(FileSystemUtils.fileExists(costrictProposal)).resolves.toBe(false);
|
||||
await expect(FileSystemUtils.fileExists(costrictArchive)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should not create missing Qoder slash command files on update', async () => {
|
||||
const qoderApply = path.join(
|
||||
testDir,
|
||||
'.qoder/commands/openspec/apply.md'
|
||||
);
|
||||
|
||||
// Only create apply; leave proposal and archive missing
|
||||
await fs.mkdir(path.dirname(qoderApply), { recursive: true });
|
||||
await fs.writeFile(
|
||||
qoderApply,
|
||||
`---
|
||||
name: OpenSpec: Apply
|
||||
description: Old description
|
||||
category: OpenSpec
|
||||
tags: [openspec, apply]
|
||||
---
|
||||
<!-- OPENSPEC:START -->
|
||||
Old body
|
||||
<!-- OPENSPEC:END -->`
|
||||
);
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const qoderProposal = path.join(
|
||||
testDir,
|
||||
'.qoder/commands/openspec/proposal.md'
|
||||
);
|
||||
const qoderArchive = path.join(
|
||||
testDir,
|
||||
'.qoder/commands/openspec/archive.md'
|
||||
);
|
||||
|
||||
// Confirm they weren't created by update
|
||||
await expect(FileSystemUtils.fileExists(qoderProposal)).resolves.toBe(false);
|
||||
await expect(FileSystemUtils.fileExists(qoderArchive)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should update only existing COSTRICT.md file', async () => {
|
||||
// Create COSTRICT.md file with initial content
|
||||
const costrictPath = path.join(testDir, 'COSTRICT.md');
|
||||
const initialContent = `# CoStrict Instructions
|
||||
|
||||
Some existing CoStrict instructions here.
|
||||
|
||||
<!-- OPENSPEC:START -->
|
||||
Old OpenSpec content
|
||||
<!-- OPENSPEC:END -->
|
||||
|
||||
More instructions after.`;
|
||||
await fs.writeFile(costrictPath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
// Execute update command
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
// Check that COSTRICT.md was updated
|
||||
const updatedContent = await fs.readFile(costrictPath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:END -->');
|
||||
expect(updatedContent).toContain("@/openspec/AGENTS.md");
|
||||
expect(updatedContent).toContain('openspec update');
|
||||
expect(updatedContent).toContain('Some existing CoStrict instructions here');
|
||||
expect(updatedContent).toContain('More instructions after');
|
||||
|
||||
// Check console output
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain('Updated AI tool files: COSTRICT.md');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not create COSTRICT.md if it does not exist', async () => {
|
||||
// Ensure COSTRICT.md does not exist
|
||||
const costrictPath = path.join(testDir, 'COSTRICT.md');
|
||||
|
||||
// Execute update command
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
// Check that COSTRICT.md was not created
|
||||
const fileExists = await FileSystemUtils.fileExists(costrictPath);
|
||||
expect(fileExists).toBe(false);
|
||||
});
|
||||
|
||||
it('should preserve CoStrict content outside markers during update', async () => {
|
||||
const costrictPath = path.join(
|
||||
testDir,
|
||||
'.cospec/openspec/commands/openspec-proposal.md'
|
||||
);
|
||||
await fs.mkdir(path.dirname(costrictPath), { recursive: true });
|
||||
const initialContent = `## Custom Intro Title\nSome intro text\n<!-- OPENSPEC:START -->\nOld body\n<!-- OPENSPEC:END -->\n\nFooter stays`;
|
||||
await fs.writeFile(costrictPath, initialContent);
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updated = await fs.readFile(costrictPath, 'utf-8');
|
||||
expect(updated).toContain('## Custom Intro Title');
|
||||
expect(updated).toContain('Footer stays');
|
||||
expect(updated).not.toContain('Old body');
|
||||
expect(updated).toContain('Validate with `openspec validate <id> --strict`');
|
||||
});
|
||||
|
||||
it('should handle configurator errors gracefully for CoStrict', async () => {
|
||||
// Create COSTRICT.md file but make it read-only to cause an error
|
||||
const costrictPath = path.join(testDir, 'COSTRICT.md');
|
||||
await fs.writeFile(
|
||||
costrictPath,
|
||||
'<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->'
|
||||
);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
const errorSpy = vi.spyOn(console, 'error');
|
||||
const originalWriteFile = FileSystemUtils.writeFile.bind(FileSystemUtils);
|
||||
const writeSpy = vi
|
||||
.spyOn(FileSystemUtils, 'writeFile')
|
||||
.mockImplementation(async (filePath, content) => {
|
||||
if (filePath.endsWith('COSTRICT.md')) {
|
||||
throw new Error('EACCES: permission denied, open');
|
||||
}
|
||||
|
||||
return originalWriteFile(filePath, content);
|
||||
});
|
||||
|
||||
// Execute update command - should not throw
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
// Should report the failure
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain('Failed to update: COSTRICT.md');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
writeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should preserve Windsurf content outside markers during update', async () => {
|
||||
const wsPath = path.join(
|
||||
testDir,
|
||||
|
||||
@@ -306,10 +306,10 @@ Then result`;
|
||||
|
||||
const specPath = path.join(testDir, 'spec.md');
|
||||
await fs.writeFile(specPath, specContent);
|
||||
|
||||
|
||||
const validator = new Validator(true); // strict mode
|
||||
const report = await validator.validateSpec(specPath);
|
||||
|
||||
|
||||
expect(report.valid).toBe(false); // Should fail due to brief overview warning
|
||||
});
|
||||
|
||||
@@ -330,12 +330,160 @@ Then result`;
|
||||
|
||||
const specPath = path.join(testDir, 'spec.md');
|
||||
await fs.writeFile(specPath, specContent);
|
||||
|
||||
|
||||
const validator = new Validator(false); // non-strict mode
|
||||
const report = await validator.validateSpec(specPath);
|
||||
|
||||
|
||||
expect(report.valid).toBe(true); // Should pass despite warnings
|
||||
expect(report.summary.warnings).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateChangeDeltaSpecs with metadata', () => {
|
||||
it('should validate requirement with metadata before SHALL/MUST text', async () => {
|
||||
const changeDir = path.join(testDir, 'test-change');
|
||||
const specsDir = path.join(changeDir, 'specs', 'test-spec');
|
||||
await fs.mkdir(specsDir, { recursive: true });
|
||||
|
||||
const deltaSpec = `# Test Spec
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Circuit Breaker State Management SHALL be implemented
|
||||
**ID**: REQ-CB-001
|
||||
**Priority**: P1 (High)
|
||||
|
||||
The system MUST implement a circuit breaker with three states.
|
||||
|
||||
#### Scenario: Normal operation
|
||||
**Given** the circuit breaker is in CLOSED state
|
||||
**When** a request is made
|
||||
**Then** the request is executed normally`;
|
||||
|
||||
const specPath = path.join(specsDir, 'spec.md');
|
||||
await fs.writeFile(specPath, deltaSpec);
|
||||
|
||||
const validator = new Validator(true);
|
||||
const report = await validator.validateChangeDeltaSpecs(changeDir);
|
||||
|
||||
expect(report.valid).toBe(true);
|
||||
expect(report.summary.errors).toBe(0);
|
||||
});
|
||||
|
||||
it('should validate requirement with SHALL in text but not in header', async () => {
|
||||
const changeDir = path.join(testDir, 'test-change-2');
|
||||
const specsDir = path.join(changeDir, 'specs', 'test-spec');
|
||||
await fs.mkdir(specsDir, { recursive: true });
|
||||
|
||||
const deltaSpec = `# Test Spec
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Error Handling
|
||||
**ID**: REQ-ERR-001
|
||||
**Priority**: P2
|
||||
|
||||
The system SHALL handle all errors gracefully.
|
||||
|
||||
#### Scenario: Error occurs
|
||||
**Given** an error condition
|
||||
**When** an error occurs
|
||||
**Then** the error is logged and user is notified`;
|
||||
|
||||
const specPath = path.join(specsDir, 'spec.md');
|
||||
await fs.writeFile(specPath, deltaSpec);
|
||||
|
||||
const validator = new Validator(true);
|
||||
const report = await validator.validateChangeDeltaSpecs(changeDir);
|
||||
|
||||
expect(report.valid).toBe(true);
|
||||
expect(report.summary.errors).toBe(0);
|
||||
});
|
||||
|
||||
it('should fail when requirement text lacks SHALL/MUST', async () => {
|
||||
const changeDir = path.join(testDir, 'test-change-3');
|
||||
const specsDir = path.join(changeDir, 'specs', 'test-spec');
|
||||
await fs.mkdir(specsDir, { recursive: true });
|
||||
|
||||
const deltaSpec = `# Test Spec
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Logging Feature
|
||||
**ID**: REQ-LOG-001
|
||||
|
||||
The system will log all events.
|
||||
|
||||
#### Scenario: Event occurs
|
||||
**Given** an event
|
||||
**When** it occurs
|
||||
**Then** it is logged`;
|
||||
|
||||
const specPath = path.join(specsDir, 'spec.md');
|
||||
await fs.writeFile(specPath, deltaSpec);
|
||||
|
||||
const validator = new Validator(true);
|
||||
const report = await validator.validateChangeDeltaSpecs(changeDir);
|
||||
|
||||
expect(report.valid).toBe(false);
|
||||
expect(report.summary.errors).toBeGreaterThan(0);
|
||||
expect(report.issues.some(i => i.message.includes('must contain SHALL or MUST'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle requirements without metadata fields', async () => {
|
||||
const changeDir = path.join(testDir, 'test-change-4');
|
||||
const specsDir = path.join(changeDir, 'specs', 'test-spec');
|
||||
await fs.mkdir(specsDir, { recursive: true });
|
||||
|
||||
const deltaSpec = `# Test Spec
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Simple Feature
|
||||
The system SHALL implement this feature.
|
||||
|
||||
#### Scenario: Basic usage
|
||||
**Given** a condition
|
||||
**When** an action occurs
|
||||
**Then** a result happens`;
|
||||
|
||||
const specPath = path.join(specsDir, 'spec.md');
|
||||
await fs.writeFile(specPath, deltaSpec);
|
||||
|
||||
const validator = new Validator(true);
|
||||
const report = await validator.validateChangeDeltaSpecs(changeDir);
|
||||
|
||||
expect(report.valid).toBe(true);
|
||||
expect(report.summary.errors).toBe(0);
|
||||
});
|
||||
|
||||
it('should treat delta headers case-insensitively', async () => {
|
||||
const changeDir = path.join(testDir, 'test-change-mixed-case');
|
||||
const specsDir = path.join(changeDir, 'specs', 'test-spec');
|
||||
await fs.mkdir(specsDir, { recursive: true });
|
||||
|
||||
const deltaSpec = `# Test Spec
|
||||
|
||||
## Added Requirements
|
||||
|
||||
### Requirement: Mixed Case Handling
|
||||
The system MUST support mixed case delta headers.
|
||||
|
||||
#### Scenario: Case insensitive parsing
|
||||
**Given** a delta file with mixed case headers
|
||||
**When** validation runs
|
||||
**Then** the delta is detected`;
|
||||
|
||||
const specPath = path.join(specsDir, 'spec.md');
|
||||
await fs.writeFile(specPath, deltaSpec);
|
||||
|
||||
const validator = new Validator(true);
|
||||
const report = await validator.validateChangeDeltaSpecs(changeDir);
|
||||
|
||||
expect(report.valid).toBe(true);
|
||||
expect(report.summary.errors).toBe(0);
|
||||
expect(report.summary.warnings).toBe(0);
|
||||
expect(report.summary.info).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user