mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-04 14:38:54 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc25ca288d |
@@ -15,12 +15,10 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test_pr:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -50,68 +48,7 @@ jobs:
|
||||
- name: Upload test coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report-pr
|
||||
path: coverage/
|
||||
retention-days: 7
|
||||
|
||||
test_matrix:
|
||||
name: Test (${{ matrix.label }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
if: github.event_name != 'pull_request'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
shell: bash
|
||||
label: linux-bash
|
||||
- os: macos-latest
|
||||
shell: bash
|
||||
label: macos-bash
|
||||
- os: windows-latest
|
||||
shell: pwsh
|
||||
label: windows-pwsh
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: ${{ matrix.shell }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Print environment diagnostics
|
||||
run: |
|
||||
node -p "JSON.stringify({ platform: process.platform, arch: process.arch, shell: process.env.SHELL || process.env.ComSpec || '' })"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build project
|
||||
run: pnpm run build
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test
|
||||
|
||||
- name: Upload test coverage
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report-main
|
||||
name: coverage-report
|
||||
path: coverage/
|
||||
retention-days: 7
|
||||
|
||||
@@ -185,15 +122,15 @@ jobs:
|
||||
echo "Changesets not configured, skipping validation"
|
||||
fi
|
||||
|
||||
required-checks-pr:
|
||||
required-checks:
|
||||
name: All checks passed
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test_pr, lint]
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
needs: [test, lint]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Verify all checks passed
|
||||
run: |
|
||||
if [[ "${{ needs.test_pr.result }}" != "success" ]]; then
|
||||
if [[ "${{ needs.test.result }}" != "success" ]]; then
|
||||
echo "Test job failed"
|
||||
exit 1
|
||||
fi
|
||||
@@ -201,22 +138,4 @@ jobs:
|
||||
echo "Lint job failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "All required checks passed!"
|
||||
|
||||
required-checks-main:
|
||||
name: All checks passed
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test_matrix, lint]
|
||||
if: always() && github.event_name != 'pull_request'
|
||||
steps:
|
||||
- name: Verify all checks passed
|
||||
run: |
|
||||
if [[ "${{ needs.test_matrix.result }}" != "success" ]]; then
|
||||
echo "Matrix test job failed"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${{ needs.lint.result }}" != "success" ]]; then
|
||||
echo "Lint job failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "All required checks passed!"
|
||||
echo "All required checks passed!"
|
||||
@@ -1,19 +1,40 @@
|
||||
# Repository Guidelines
|
||||
<!-- OPENSPEC:START -->
|
||||
# OpenSpec Project
|
||||
|
||||
## Project Structure & Module Organization
|
||||
OpenSpec ships as a TypeScript-first CLI. Source code lives in `src`, with feature logic in `core`, interactive flows in `cli`, reusable helpers in `utils`, and command wiring in `commands`. After `pnpm run build`, deliverables land in `dist` and feed the published entry point `bin/openspec.js`. Specs and change proposals reside in `openspec/specs` and `openspec/changes`; update them whenever behavior shifts so automation stays aligned. Shared assets live in `assets`, and Vitest suites in `test` mirror the source layout for easy cross-reference.
|
||||
This document provides instructions for AI coding assistants on how to use OpenSpec conventions for spec-driven development. Follow these rules precisely when working on OpenSpec-enabled projects.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
Run `pnpm install` to sync dependencies. `pnpm run build` compiles TypeScript to `dist` and must stay green before release. Use `pnpm run dev` for a `tsc --watch` loop and `pnpm run dev:cli` to rebuild then execute the local CLI. `pnpm test` runs the Vitest suite once, `pnpm run test:watch` keeps it hot while iterating, and `pnpm run test:coverage` verifies instrumentation thresholds. Use `pnpm run changeset` when preparing a release entry.
|
||||
This project uses OpenSpec for spec-driven development. Specifications are the source of truth.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
We follow idiomatic TypeScript with ES modules, 2-space indentation, and semicolons. Prefer named exports from index barrels and keep filenames kebab-cased (e.g., `list-command.ts`). Classes use `PascalCase`, functions and variables use `camelCase`, and constants representing flags may use `SCREAMING_SNAKE_CASE`. Keep modules small, colocate helpers under `src/utils`, and avoid new dependencies without spec-backed justification.
|
||||
See @openspec/AGENTS.md for detailed conventions and guidelines.
|
||||
<!-- OPENSPEC:END -->
|
||||
|
||||
## Testing Guidelines
|
||||
Every behavior change needs Vitest coverage under `test`, co-located by feature (e.g., `test/core/update.test.ts`). Name suites after the module under test and lean on `vitest.setup.ts` for shared configuration. Run `pnpm test` before pushing and add regression cases for each bug fix or spec requirement.
|
||||
## Complexity Management
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
Commits follow Conventional Commits (`type(scope): subject`) and stay single-line. Reference the touched module in the scope when practical. Each PR should summarize the spec or issue it fulfills, list manual verification steps, and note updates to any `openspec/` assets. Include CLI output snippets or screenshots when the UX changes, and ensure CI and coverage checks pass before requesting review.
|
||||
**Default to minimal solutions:**
|
||||
- Propose <100 lines of new code for features
|
||||
- Prefer single-file implementations until proven insufficient
|
||||
- Avoid frameworks, abstractions, and optimizations without clear justification
|
||||
- Choose boring, well-understood patterns over novel approaches
|
||||
|
||||
## OpenSpec Workflow Tips
|
||||
Treat specs as the contract: update `openspec/project.md` or the relevant `openspec/specs/*.md` before coding, then run `pnpm run dev:cli` to validate the CLI against the revised artifacts. `openspec list --specs` confirms the catalog, and `openspec change` drafts proposals—commit these alongside code so reviewers can trace rationale to implementation.
|
||||
**Question requests for complexity:**
|
||||
- Caching? → Ask for performance data and targets
|
||||
- New framework? → Suggest plain code first
|
||||
- Extra layers? → Start with the thinnest viable design
|
||||
|
||||
**Justify complexity with data:**
|
||||
- Performance metrics showing current solution is too slow
|
||||
- Concrete scale requirements (e.g., >1000 users, >100MB data)
|
||||
- Multiple proven use cases requiring an abstraction
|
||||
|
||||
## Package Manager
|
||||
Always use pnpm (NOT npm or yarn) for all Node.js package management:
|
||||
- Install dependencies: `pnpm install`
|
||||
- Add packages: `pnpm add [package]`
|
||||
- Run scripts: `pnpm run [script]`
|
||||
|
||||
## Git Commits
|
||||
Use conventional commits with these rules:
|
||||
- Format: `type(scope): subject` (e.g., `fix: resolve auth error`, `feat(api): add user endpoint`)
|
||||
- Keep commit messages to ONE line only - no body or footer
|
||||
- Common types: feat, fix, docs, style, refactor, test, chore
|
||||
- Never add co-authorship lines or attribution
|
||||
@@ -1,41 +1,5 @@
|
||||
# @fission-ai/openspec
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- feat: implement Phase 1 E2E testing with cross-platform CI matrix
|
||||
|
||||
- Add shared runCLI helper in test/helpers/run-cli.ts for spawn testing
|
||||
- Create test/cli-e2e/basic.test.ts covering help, version, validate flows
|
||||
- Migrate existing CLI exec tests to use runCLI helper
|
||||
- Extend CI matrix to bash (Linux/macOS) and pwsh (Windows)
|
||||
- Split PR and main workflows for optimized feedback
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Make apply instructions more specific
|
||||
|
||||
Improve agent templates and slash command templates with more specific and actionable apply instructions.
|
||||
|
||||
- docs: improve documentation and cleanup
|
||||
|
||||
- Document non-interactive flag for archive command
|
||||
- Replace discord badge in README
|
||||
- Archive completed changes for better organization
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add OpenSpec change proposals for CLI improvements and enhanced user experience
|
||||
- Add Opencode slash commands support for AI-driven development workflows
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add documentation improvements including --yes flag for archive command template and Discord badge
|
||||
- Fix normalize line endings in markdown parser to handle CRLF files properly
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<a href="https://nodejs.org/"><img alt="node version" src="https://img.shields.io/node/v/@fission-ai/openspec?style=flat-square" /></a>
|
||||
<a href="./LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square" /></a>
|
||||
<a href="https://conventionalcommits.org"><img alt="Conventional Commits" src="https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg?style=flat-square" /></a>
|
||||
<a href="https://discord.gg/saTQQGQZ"><img alt="Discord" src="https://img.shields.io/badge/Discord-Join%20the%20community-5865F2?logo=discord&logoColor=white&style=flat-square" /></a>
|
||||
<a href="https://discord.gg/saTQQGQZ"><img alt="Discord" src="https://img.shields.io/discord/1411657095639601154?logo=discord&logoColor=white&style=flat-square" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -83,14 +83,13 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe
|
||||
|------|----------|
|
||||
| **Claude Code** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` |
|
||||
| **Cursor** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
|
||||
| **OpenCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
|
||||
|
||||
#### AGENTS.md Compatible
|
||||
These tools automatically read workflow instructions from `openspec/AGENTS.md`. Ask them to follow the OpenSpec workflow if they need a reminder. Learn more about the [AGENTS.md convention](https://agents.md/).
|
||||
|
||||
| Tools |
|
||||
|-------|
|
||||
| Codex • Amp • Jules • Gemini CLI • GitHub Copilot • Others |
|
||||
| Codex • Amp • Jules • OpenCode • Gemini CLI • GitHub Copilot • Others |
|
||||
|
||||
### Install & Initialize
|
||||
|
||||
@@ -185,13 +184,13 @@ You: Please archive the change
|
||||
(Shortcut for tools with slash commands: /openspec:archive add-profile-filters)
|
||||
|
||||
AI: I'll archive the add-profile-filters change.
|
||||
*Runs: openspec archive add-profile-filters --yes*
|
||||
*Runs: openspec archive add-profile-filters*
|
||||
✓ Change archived successfully. Specs updated. Ready for the next feature!
|
||||
```
|
||||
|
||||
Or run the command yourself in terminal:
|
||||
```bash
|
||||
$ openspec archive add-profile-filters --yes # Archive the completed change without prompts
|
||||
$ openspec archive add-profile-filters # Archive the completed change
|
||||
```
|
||||
|
||||
**Note:** Tools with native slash commands (Claude Code, Cursor) 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".
|
||||
@@ -203,7 +202,7 @@ openspec list # View active change folders
|
||||
openspec view # Interactive dashboard of specs and changes
|
||||
openspec show <change> # Display change details (proposal, tasks, spec updates)
|
||||
openspec validate <change> # Check spec formatting and structure
|
||||
openspec archive <change> [--yes|-y] # Move a completed change into archive/ (non-interactive with --yes)
|
||||
openspec archive <change> # Move a completed change into archive/
|
||||
```
|
||||
|
||||
## Example: How AI Creates OpenSpec Files
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from 'child_process';
|
||||
import { execSync } from 'child_process';
|
||||
import { existsSync, rmSync } from 'fs';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const runTsc = (args = []) => {
|
||||
const tscPath = require.resolve('typescript/bin/tsc');
|
||||
execFileSync(process.execPath, [tscPath, ...args], { stdio: 'inherit' });
|
||||
};
|
||||
|
||||
console.log('🔨 Building OpenSpec...\n');
|
||||
|
||||
@@ -22,10 +14,10 @@ if (existsSync('dist')) {
|
||||
// Run TypeScript compiler (use local version explicitly)
|
||||
console.log('Compiling TypeScript...');
|
||||
try {
|
||||
runTsc(['--version']);
|
||||
runTsc();
|
||||
execSync('./node_modules/.bin/tsc -v', { stdio: 'inherit' });
|
||||
execSync('./node_modules/.bin/tsc', { stdio: 'inherit' });
|
||||
console.log('\n✅ Build completed successfully!');
|
||||
} catch (error) {
|
||||
console.error('\n❌ Build failed!');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
+5
-8
@@ -47,20 +47,18 @@ Skip proposal for:
|
||||
4. Run `openspec validate <id> --strict` and resolve any issues before sharing the proposal.
|
||||
|
||||
### Stage 2: Implementing Changes
|
||||
Track these steps as TODOs and complete them one by one.
|
||||
1. **Read proposal.md** - Understand what's being built
|
||||
2. **Read design.md** (if exists) - Review technical decisions
|
||||
3. **Read tasks.md** - Get implementation checklist
|
||||
4. **Implement tasks sequentially** - Complete in order
|
||||
5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses
|
||||
6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality
|
||||
7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
|
||||
5. **Mark complete immediately** - Update `- [x]` after each task
|
||||
6. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
|
||||
|
||||
### Stage 3: Archiving Changes
|
||||
After deployment, create separate PR to:
|
||||
- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/`
|
||||
- Update `specs/` if capabilities changed
|
||||
- Use `openspec archive [change] --skip-specs --yes` for tooling-only changes
|
||||
- Use `openspec archive [change] --skip-specs` for tooling-only changes
|
||||
- Run `openspec validate --strict` to confirm the archived change passes checks
|
||||
|
||||
## Before Any Task
|
||||
@@ -97,7 +95,7 @@ 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] # Archive after deployment
|
||||
|
||||
# Project management
|
||||
openspec init [path] # Initialize OpenSpec
|
||||
@@ -119,7 +117,6 @@ openspec validate [change] --strict
|
||||
- `--strict` - Comprehensive validation
|
||||
- `--no-interactive` - Disable prompts
|
||||
- `--skip-specs` - Archive without spec updates
|
||||
- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive)
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -450,7 +447,7 @@ 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] # Mark complete
|
||||
```
|
||||
|
||||
Remember: Specs are truth. Changes are proposals. Keep them in sync.
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
## Why
|
||||
A recent feature request highlighted that users struggle to scope proposals when all clarifications have to be typed manually. They want a guided interview that surfaces missing assumptions, recommends best-practice defaults, and produces a sharper brief before handing off to `/openspec/proposal`. Delivering an interactive Q&A flow keeps OpenSpec competitive with other planning assistants and shortens the loop between idea and actionable change specification.
|
||||
|
||||
## What Changes
|
||||
- Add a dedicated `/openspec/proposal-qa` slash command that runs a structured discovery interview before drafting a change proposal.
|
||||
- Teach the agent to analyse the initial request, label what is already explicit versus ambiguous, and derive a small set of high-impact clarifying questions.
|
||||
- Require every question to include rationale, 2–4 recommended options with a default, and clear guidance on when to pick each option.
|
||||
- Capture the conversation outcome in a structured summary (problem, clarified decisions, open risks) that the user can accept or tweak before invoking `/openspec/proposal`.
|
||||
- Update onboarding (`init`/`update`) and slash command templates so the new command ships everywhere OpenSpec currently provisions proposal/apply/archive helpers.
|
||||
|
||||
## Impact
|
||||
- Affected specs: assistant-proposal-qa (new), cli-init, cli-update, slash-commands-template (if modelled separately)
|
||||
- Affected code: `src/core/templates/slash-command-templates.ts`, `src/core/configurators/slash/*`, command scaffolding that writes `.claude/.cursor/.opencode` files, and associated tests.
|
||||
@@ -1,63 +0,0 @@
|
||||
# assistant-proposal-qa Specification
|
||||
|
||||
## ADDED Requirements
|
||||
### Requirement: Interactive Proposal Q&A Command
|
||||
|
||||
The system SHALL provide a `/openspec/proposal-qa` slash command that prepares users for `/openspec/proposal` by running a structured discovery interview.
|
||||
|
||||
#### Scenario: Launching the proposal interview
|
||||
- **WHEN** the user runs `/openspec/proposal-qa Build a notifications digest`
|
||||
- **THEN** acknowledge the request and restate the draft problem statement
|
||||
- **AND** highlight what parts of the request are already concrete versus ambiguous (e.g., "Strong signals" and "Needs clarity")
|
||||
- **AND** outline the upcoming steps: targeted questions followed by a summary hand-off.
|
||||
|
||||
#### Scenario: Honour non-interactive environments
|
||||
- **GIVEN** slash commands are invoked in a non-interactive environment (e.g., automation requesting defaults)
|
||||
- **WHEN** `/openspec/proposal-qa` is triggered with `--no-interactive`
|
||||
- **THEN** skip the question loop
|
||||
- **AND** produce a summary that records the request, recommended defaults, and instructions for editing manually before running `/openspec/proposal`.
|
||||
|
||||
### Requirement: Targeted Clarifying Questions
|
||||
|
||||
The interview SHALL adaptively surface 3–6 high-leverage questions that eliminate ambiguity in the proposal brief.
|
||||
|
||||
#### Scenario: Ask one question at a time with rationale
|
||||
- **WHEN** the interview begins gathering answers
|
||||
- **THEN** select the next most risky/vague aspect of the feature
|
||||
- **AND** present a single question that includes:
|
||||
- A short rationale explaining why the question matters for the proposal
|
||||
- 2–4 recommended options formatted as a bulleted list with `**Default**` clearly marked
|
||||
- Guidance for when to choose each option (one sentence per option)
|
||||
- **AND** wait for the user response (or `default`/`skip`) before showing another question.
|
||||
|
||||
#### Scenario: Provide fallbacks when users defer
|
||||
- **WHEN** the user replies with `idk`, `default`, or leaves the answer empty
|
||||
- **THEN** accept the default option for that question
|
||||
- **AND** note in the transcript that the default was applied.
|
||||
|
||||
#### Scenario: Capture bespoke answers
|
||||
- **WHEN** the user supplies an answer that does not match any recommended option
|
||||
- **THEN** accept the custom answer
|
||||
- **AND** record a short interpretation describing how it will shape the proposal.
|
||||
|
||||
### Requirement: Synthesis and Handoff
|
||||
|
||||
The interview SHALL produce an actionable summary that readies the agent to draft the formal change proposal.
|
||||
|
||||
#### Scenario: Summarise discoveries before exit
|
||||
- **WHEN** the question loop completes (or is skipped)
|
||||
- **THEN** output a structured summary containing:
|
||||
- Problem statement and scope recap
|
||||
- Table or bullet list of decisions (question → final answer → reasoning/default flag)
|
||||
- Noted risks, open questions, and assumptions to confirm in the proposal
|
||||
- **AND** recommend next actions: either ask for revisions, run `/openspec/proposal` with this summary, or request further research.
|
||||
|
||||
#### Scenario: Provide reusable prompt snippet
|
||||
- **WHEN** the summary is generated
|
||||
- **THEN** include a copyable prompt block that the user can paste into `/openspec/proposal`
|
||||
- **AND** ensure the prompt references the summary decisions and flags any open items for follow-up.
|
||||
|
||||
#### Scenario: Allow re-entry for more questions
|
||||
- **WHEN** the user indicates they want to refine further (e.g., "ask more" or "another pass")
|
||||
- **THEN** identify remaining ambiguous areas not yet questioned
|
||||
- **AND** continue with additional questions (up to the 6-question cap) before regenerating the summary.
|
||||
@@ -1,21 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Slash Command Configuration
|
||||
The init command SHALL generate slash command files for supported editors using shared templates, including the interactive proposal interview instructions.
|
||||
|
||||
#### 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/proposal-qa.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** include guidance that the `proposal-qa` command runs the interactive discovery interview before `/openspec/proposal`.
|
||||
|
||||
#### Scenario: Generating slash commands for Cursor
|
||||
- **WHEN** the user selects Cursor during initialization
|
||||
- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-proposal-qa.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** include guidance that the `proposal-qa` command runs the interactive discovery interview before `/openspec/proposal`.
|
||||
|
||||
#### Scenario: Generating slash commands for OpenCode
|
||||
- **WHEN** the user selects OpenCode during initialization
|
||||
- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-proposal-qa.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** include guidance that the `proposal-qa` command runs the interactive discovery interview before `/openspec/proposal`.
|
||||
@@ -1,18 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Slash Command Updates
|
||||
The update command SHALL refresh existing slash command files for configured tools, including the interactive proposal interview template, without creating new ones.
|
||||
|
||||
#### Scenario: Updating slash commands for Claude Code
|
||||
- **WHEN** `.claude/commands/openspec/` contains `proposal.md`, `proposal-qa.md`, `apply.md`, and `archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** ensure the `proposal-qa` template contains the interactive discovery instructions aligned with the latest assistant guidance.
|
||||
|
||||
#### Scenario: Updating slash commands for Cursor
|
||||
- **WHEN** `.cursor/commands/` contains `openspec-proposal.md`, `openspec-proposal-qa.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** ensure the `proposal-qa` template contains the interactive discovery instructions aligned with the latest assistant guidance.
|
||||
|
||||
#### Scenario: Updating slash commands for OpenCode
|
||||
- **WHEN** `.opencode/commands/` contains `openspec-proposal.md`, `openspec-proposal-qa.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** ensure the `proposal-qa` template contains the interactive discovery instructions aligned with the latest assistant guidance.
|
||||
@@ -1,19 +0,0 @@
|
||||
## 1. Discovery & Design
|
||||
- [ ] 1.1 Audit existing slash command files (`src/core/templates/slash-command-templates.ts`, configurators, tests) to mirror tone/format.
|
||||
- [ ] 1.2 Draft conversational flow map covering request analysis, question generation heuristics, question formatting, and post-qa summary/next-steps.
|
||||
- [ ] 1.3 Validate the flow against the example repo in issue #85 to ensure parity with proven UX patterns (e.g., defaults, recommended answers).
|
||||
|
||||
## 2. Specification Updates
|
||||
- [ ] 2.1 Add `assistant-proposal-qa` capability spec capturing requirements for analysis, questioning, summary, and hand-off UX.
|
||||
- [ ] 2.2 Update `cli-init` and `cli-update` specs so generated slash command files include the new `proposal-qa` command for all supported assistants.
|
||||
|
||||
## 3. Implementation
|
||||
- [ ] 3.1 Extend `SlashCommandId` union, templates, and file writers to emit `/openspec/proposal-qa` with the new instructions body.
|
||||
- [ ] 3.2 Implement helper(s) that build recommended answer options from agent analysis (list of option label, description, default marker).
|
||||
- [ ] 3.3 Ensure question loop enforces 3–6 prompts, each with rationale and recommended default, and gracefully handles user-supplied alternatives.
|
||||
- [ ] 3.4 Update onboarding/update flows to write `.claude/.cursor/.opencode` command markdown for `proposal-qa` alongside existing commands.
|
||||
|
||||
## 4. Validation & QA
|
||||
- [ ] 4.1 Add unit tests covering slash template rendering for the new command and regression tests for init/update scaffolding.
|
||||
- [ ] 4.2 Update documentation and examples (README, CHANGELOG as needed) showcasing how to use `/openspec/proposal-qa` and the resulting summary output.
|
||||
- [ ] 4.3 Run `openspec validate add-interactive-proposal-qa --strict` and full test suite (`pnpm test`) to confirm specs and tooling stay green.
|
||||
-6
@@ -13,9 +13,3 @@ The init command SHALL generate slash command files for supported editors using
|
||||
- **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
|
||||
-5
@@ -12,11 +12,6 @@ The update command SHALL refresh existing slash command files for configured too
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Updating slash commands for OpenCode
|
||||
- **WHEN** `.opencode/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: Missing slash command file
|
||||
- **WHEN** a tool lacks a slash command file
|
||||
- **THEN** do not create a new file during update
|
||||
-4
@@ -14,7 +14,3 @@
|
||||
|
||||
## 4. Verification
|
||||
- [x] 4.1 Add tests verifying slash command files are created and updated correctly.
|
||||
|
||||
## 5. OpenCode Integration
|
||||
- [x] 5.1 Generate `.opencode/commands/{openspec-proposal,openspec-apply,openspec-archive}.md` during `openspec init` using shared templates.
|
||||
- [x] 5.2 Update existing `.opencode/commands/*` files during `openspec update`.
|
||||
@@ -1,19 +0,0 @@
|
||||
## Why
|
||||
Recent cross-shell regressions for `openspec` commands revealed that our existing unit/integration tests do not exercise the packaged CLI or shell-specific behavior. The prior attempt at Vitest spawn tests stalled because it coupled e2e coverage with `pnpm pack` installs, which fail in network-restricted environments. With those findings incorporated, we now need an approved plan to realign the work.
|
||||
|
||||
## What Changes
|
||||
- Adopt a phased strategy that first stabilizes direct spawn testing of the built CLI (`node dist/cli/index.js`) using lightweight fixtures and a shared `runCLI` helper.
|
||||
- Expand coverage once the spawn harness is stable, keeping the initial matrix focused on bash jobs for Linux/macOS and `pwsh` on Windows while exercising both the direct `node dist/cli/index.js` invocation and the bin shim with non-TTY defaults and captured diagnostics.
|
||||
- Treat packaging/install validation as an optional CI safeguard: when a runner has registry access, run a simple pnpm-based pack→install→smoke-test flow; otherwise document it as out of scope while closing remaining hardening items.
|
||||
- Close out the remaining cross-shell hardening items: ensure `.gitattributes` covers packaged assets, enforce executable bits for CLI shims during CI, and finish the pending SIGINT handling improvements.
|
||||
|
||||
## Impact
|
||||
- Tests: add `test/cli-e2e` spawn suite, create the shared `runCLI` helper, and adjust `vitest.setup.ts` as needed.
|
||||
- Tooling: update GitHub Actions workflows with the lightweight matrix above and (optionally) a packaging install check where network is available.
|
||||
- Docs: note phase progress and any limitations inline in this proposal (or the relevant spec) so future phases have clear context.
|
||||
|
||||
### Phase 1 Status
|
||||
- Shared `test/helpers/run-cli.ts` guarantees the CLI bundle exists before spawning and enforces non-TTY defaults for every invocation.
|
||||
- New `test/cli-e2e/basic.test.ts` covers `--help`, `--version`, a successful `validate --all --json`, and an unknown-item error path against the `tmp-init` fixture copy.
|
||||
- Legacy top-level `validate` exec tests now rely on `runCLI`, avoiding manual `execSync` usage while keeping their fixture authoring intact.
|
||||
- CI matrix groundwork is in place (bash on Linux/macOS, pwsh on Windows) so the spawn suite runs the same way the helper does across supported shells.
|
||||
@@ -1,9 +0,0 @@
|
||||
## 1. Phase 1 – Stabilize Local Spawn Coverage
|
||||
- [x] 1.1 Add `test/helpers/run-cli.ts` that ensures the build runs once and executes `node dist/cli/index.js` with non-TTY defaults; update `vitest.setup.ts` to reuse the shared build step.
|
||||
- [x] 1.2 Seed `test/cli-e2e` using the minimal fixture set (`tmp-init` or copy) to cover help/version, a happy-path `validate`, and a representative error flow via the new helper.
|
||||
- [x] 1.3 Migrate the highest-value existing CLI exec tests (e.g., validate) onto `runCLI` and summarize Phase 1 coverage in this proposal for the next phase.
|
||||
|
||||
## 2. Phase 2 – Expand Cross-Shell Validation
|
||||
- [x] 2.1 Exercise both entry points (`node dist/cli/index.js`, `bin/openspec.js`) in the spawn suite and add diagnostics for shell/OS context.
|
||||
- [x] 2.2 Extend GitHub Actions to run the spawn suite on bash jobs for Linux/macOS and a `pwsh` job on Windows; capture shell/OS diagnostics and note follow-ups for additional shells.
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
## 1. Planning & Spec Updates
|
||||
- [x] 1.1 Confirm overlap with `add-multi-agent-init` and coordinate extend-mode flow
|
||||
- [x] 1.2 Update `openspec/specs/cli-init/spec.md` to capture multi-select onboarding requirements
|
||||
|
||||
## 2. Implementation
|
||||
- [x] 2.1 Add multi-select support to the `openspec init` prompt, including indicators for existing tool configs
|
||||
- [x] 2.2 Enhance success messaging to summarize created/refreshed assets per tool
|
||||
- [x] 2.3 Ensure shared instruction template is applied consistently (CLAUDE.md, AGENTS.md, slash commands)
|
||||
|
||||
## 3. Quality
|
||||
- [x] 3.1 Expand unit tests for init/update flows covering multi-select and summaries
|
||||
- [x] 3.2 Perform `openspec init` smoke test in a temp directory (document output)
|
||||
@@ -0,0 +1,12 @@
|
||||
## Why
|
||||
Recent cross-shell regressions for `openspec` commands revealed that our existing unit/integration tests do not exercise the packaged CLI or shell-specific behavior. The prior attempt at Vitest spawn tests stalled because it coupled e2e coverage with `pnpm pack` installs, which fail in network-restricted environments. With those findings incorporated, we now need an approved plan to realign the work.
|
||||
|
||||
## What Changes
|
||||
- Adopt a phased strategy that first stabilizes direct spawn testing of the built CLI (`node dist/cli/index.js`) using lightweight fixtures and shared helpers.
|
||||
- Expand coverage to cross-shell/OS matrices once the spawn harness is stable, ensuring both the direct `node dist/cli/index.js` invocation and the bin shim are exercised with non-TTY defaults and captured diagnostics.
|
||||
- Treat packaging/install validation as an optional CI safeguard: when a runner has registry access, run a simple pnpm-based pack→install→smoke-test flow; otherwise document it as out of scope while closing remaining hardening items.
|
||||
|
||||
## Impact
|
||||
- Tests: add `test/cli-e2e` spawn suite, helpers, and fixture usage updates; adjust `vitest.setup.ts` as needed.
|
||||
- Tooling: update GitHub Actions workflows to add shell/OS matrices and (optionally) a packaging install check where network is available.
|
||||
- Docs: keep `CROSS-SHELL-PLAN.md` aligned with the phased rollout and record any limitations called out during execution.
|
||||
@@ -0,0 +1,13 @@
|
||||
## 1. Phase 1 – Stabilize Local Spawn Coverage
|
||||
- [ ] 1.1 Update `vitest.setup.ts` and helpers so the CLI build runs once and `runCLI` executes `node dist/cli.js` with non-TTY defaults.
|
||||
- [ ] 1.2 Reuse the minimal fixture set (`tmp-init` or copy) to seed initial spawn tests for help/version, a happy-path `validate`, and a representative error flow.
|
||||
- [ ] 1.3 Document the Phase 1 coverage details in `CROSS-SHELL-PLAN.md`, noting any outstanding gaps.
|
||||
|
||||
## 2. Phase 2 – Expand Cross-Shell Validation
|
||||
- [ ] 2.1 Exercise both entry points (`node dist/cli.js`, `bin/openspec.js`) in the spawn suite and add diagnostics for shell/OS context.
|
||||
- [ ] 2.2 Extend GitHub Actions to run the spawn suite across a matrix of shells (bash, zsh, fish, pwsh, cmd) on macOS, Linux, and Windows runners.
|
||||
|
||||
## 3. Phase 3 – Package Validation (Optional)
|
||||
- [ ] 3.1 Add a simple CI job on runners with registry access that runs `pnpm pack`, installs the tarball into a temp workspace (e.g., `pnpm add --no-save`), and executes `pnpm exec openspec --version`.
|
||||
- [ ] 3.2 If network-restricted environments can’t exercise installs, document the limitation in `CROSS-SHELL-PLAN.md` and skip the job there.
|
||||
- [ ] 3.3 Close out remaining hardening items from the original cross-shell plan (e.g., `.gitattributes`, chmod enforcement, SIGINT follow-ups) and update the plan accordingly.
|
||||
+1
-1
@@ -21,5 +21,5 @@
|
||||
|
||||
## 5. Optional (Not Needed Now)
|
||||
- [x] 5.1 Add optional root param to discovery helpers (default process.cwd())
|
||||
|
||||
- [ ] 5.2 Consider threading root through command constructors if ever required
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
## 1. Planning & Spec Updates
|
||||
- [ ] 1.1 Confirm overlap with `add-multi-agent-init` and coordinate extend-mode flow
|
||||
- [ ] 1.2 Update `openspec/specs/cli-init/spec.md` to capture multi-select onboarding requirements
|
||||
|
||||
## 2. Implementation
|
||||
- [ ] 2.1 Add multi-select support to the `openspec init` prompt, including indicators for existing tool configs
|
||||
- [ ] 2.2 Enhance success messaging to summarize created/refreshed assets per tool
|
||||
- [ ] 2.3 Ensure shared instruction template is applied consistently (CLAUDE.md, AGENTS.md, slash commands)
|
||||
|
||||
## 3. Quality
|
||||
- [ ] 3.1 Expand unit tests for init/update flows covering multi-select and summaries
|
||||
- [ ] 3.2 Perform `openspec init` smoke test in a temp directory (document output)
|
||||
+4
@@ -26,6 +26,10 @@
|
||||
- Archive documentation
|
||||
- Change proposals
|
||||
|
||||
## 6. Add Deprecation Notice (Optional Phase)
|
||||
- [ ] Consider adding a deprecation warning before full removal
|
||||
- [ ] Provide helpful message directing users to `openspec show` command
|
||||
|
||||
## 7. Testing
|
||||
- [x] Ensure all tests pass after removal
|
||||
- [x] Verify CLI help text no longer shows diff command
|
||||
-4
@@ -25,16 +25,12 @@ The command SHALL generate required template files with appropriate content for
|
||||
|
||||
### Requirement: AI Tool Configuration Details
|
||||
|
||||
The command SHALL properly configure selected AI tools with OpenSpec-specific instructions using a marker system.
|
||||
|
||||
#### Scenario: Creating new CLAUDE.md
|
||||
- **WHEN** CLAUDE.md does not exist
|
||||
- **THEN** create new file with OpenSpec content wrapped in markers including reference to `@openspec/AGENTS.md`
|
||||
|
||||
### Requirement: Success Output
|
||||
|
||||
The command SHALL provide clear, actionable next steps upon successful initialization.
|
||||
|
||||
#### Scenario: Displaying success message
|
||||
- **WHEN** initialization completes successfully
|
||||
- **THEN** include prompt: "Please explain the OpenSpec workflow from openspec/AGENTS.md and how I should work with you on this project"
|
||||
@@ -3,7 +3,9 @@
|
||||
## Purpose
|
||||
|
||||
The `openspec init` command SHALL create a complete OpenSpec directory structure in any project, enabling immediate adoption of OpenSpec conventions with support for multiple AI coding assistants.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Progress Indicators
|
||||
|
||||
The command SHALL display progress indicators during initialization to provide clear feedback about each step.
|
||||
@@ -19,9 +21,11 @@ The command SHALL display progress indicators during initialization to provide c
|
||||
- Then success: "✔ AI tools configured"
|
||||
|
||||
### Requirement: Directory Creation
|
||||
|
||||
The command SHALL create the complete OpenSpec directory structure with all required directories and files.
|
||||
|
||||
#### Scenario: Creating OpenSpec structure
|
||||
|
||||
- **WHEN** `openspec init` is executed
|
||||
- **THEN** create the following directory structure:
|
||||
```
|
||||
@@ -34,9 +38,11 @@ openspec/
|
||||
```
|
||||
|
||||
### Requirement: File Generation
|
||||
|
||||
The command SHALL generate required template files with appropriate content for immediate use.
|
||||
|
||||
#### Scenario: Generating template files
|
||||
|
||||
- **WHEN** initializing OpenSpec
|
||||
- **THEN** generate `AGENTS.md` containing complete OpenSpec instructions for AI assistants
|
||||
- **AND** generate `project.md` with project context template
|
||||
@@ -48,14 +54,10 @@ The command SHALL configure AI coding assistants with OpenSpec instructions base
|
||||
#### Scenario: Prompting for AI tool selection
|
||||
|
||||
- **WHEN** run interactively
|
||||
- **THEN** prompt the user with "Which AI tools do you use?" using a multi-select menu
|
||||
- **AND** list every available tool with a checkbox:
|
||||
- Claude Code (creates or refreshes CLAUDE.md and slash commands)
|
||||
- Cursor (creates or refreshes `.cursor/commands/*` slash commands)
|
||||
- AGENTS.md standard (creates or refreshes AGENTS.md with OpenSpec markers)
|
||||
- **AND** show "(already configured)" beside tools whose managed files exist so users understand selections will refresh content
|
||||
- **AND** treat disabled tools as "coming soon" and keep them unselectable
|
||||
- **AND** allow confirming with Enter after selecting one or more tools
|
||||
- **THEN** prompt user to select AI tools to configure:
|
||||
- Claude Code (updates/creates CLAUDE.md with OpenSpec markers)
|
||||
- Cursor (future)
|
||||
- Aider (future)
|
||||
|
||||
### Requirement: AI Tool Configuration Details
|
||||
|
||||
@@ -72,36 +74,105 @@ The command SHALL properly configure selected AI tools with OpenSpec-specific in
|
||||
- **THEN** create new file with OpenSpec content wrapped in markers:
|
||||
```markdown
|
||||
<!-- OPENSPEC:START -->
|
||||
# OpenSpec Instructions
|
||||
# OpenSpec Project
|
||||
|
||||
Instructions for AI coding assistants using OpenSpec for spec-driven development.
|
||||
This document provides instructions for AI coding assistants on how to use OpenSpec conventions for spec-driven development. Follow these rules precisely when working on OpenSpec-enabled projects.
|
||||
|
||||
This project uses OpenSpec for spec-driven development. Specifications are the source of truth.
|
||||
|
||||
See @openspec/AGENTS.md for detailed conventions and guidelines.
|
||||
<!-- OPENSPEC:END -->
|
||||
```
|
||||
|
||||
#### Scenario: Updating existing CLAUDE.md
|
||||
|
||||
- **WHEN** CLAUDE.md already exists
|
||||
- **THEN** preserve all existing content
|
||||
- **AND** insert OpenSpec content at the beginning of the file using markers
|
||||
- **AND** ensure markers don't duplicate if they already exist
|
||||
|
||||
#### Scenario: Managing content with markers
|
||||
|
||||
- **WHEN** using the marker system
|
||||
- **THEN** use `<!-- OPENSPEC:START -->` to mark the beginning of managed content
|
||||
- **AND** use `<!-- OPENSPEC:END -->` to mark the end of managed content
|
||||
- **AND** allow OpenSpec to update its content without affecting user customizations
|
||||
- **AND** preserve all content outside the markers intact
|
||||
|
||||
WHY use markers:
|
||||
- Users may have existing CLAUDE.md instructions they want to keep
|
||||
- OpenSpec can update its instructions in future versions
|
||||
- Clear boundary between OpenSpec-managed and user-managed content
|
||||
|
||||
### Requirement: Interactive Mode
|
||||
|
||||
The command SHALL provide an interactive menu for AI tool selection with clear navigation instructions.
|
||||
|
||||
#### Scenario: Displaying interactive menu
|
||||
- **WHEN** run in fresh or extend mode
|
||||
- **THEN** present a looping select menu that lets users toggle tools with Enter and finish via a "Done" option
|
||||
- **AND** label already configured tools with "(already configured)" while keeping disabled options marked "coming soon"
|
||||
- **AND** change the prompt copy in extend mode to "Which AI tools would you like to add or refresh?"
|
||||
- **AND** display inline instructions clarifying that Enter toggles a tool and selecting "Done" confirms the list
|
||||
|
||||
- **WHEN** run
|
||||
- **THEN** prompt user with: "Which AI tool do you use?"
|
||||
- **AND** show single-select menu with available tools:
|
||||
- Claude Code
|
||||
- **AND** show disabled options as "coming soon" (not selectable):
|
||||
- Cursor (coming soon)
|
||||
- Aider (coming soon)
|
||||
- Continue (coming soon)
|
||||
|
||||
#### Scenario: Navigating the menu
|
||||
|
||||
- **WHEN** user is in the menu
|
||||
- **THEN** allow arrow keys to move between options
|
||||
- **AND** allow Enter key to select the highlighted option
|
||||
|
||||
### Requirement: Safety Checks
|
||||
|
||||
The command SHALL perform safety checks to prevent overwriting existing structures and ensure proper permissions.
|
||||
|
||||
#### Scenario: Detecting existing initialization
|
||||
- **WHEN** the `openspec/` directory already exists
|
||||
- **THEN** inform the user that OpenSpec is already initialized, skip recreating the base structure, and enter an extend mode
|
||||
- **AND** continue to the AI tool selection step so additional tools can be configured
|
||||
- **AND** display the existing-initialization error message only when the user declines to add any AI tools
|
||||
|
||||
- **WHEN** `openspec/` directory already exists
|
||||
- **THEN** display error with ora fail indicator:
|
||||
- "✖ Error: OpenSpec seems to already be initialized. Use 'openspec update' to update the structure."
|
||||
|
||||
#### Scenario: Checking write permissions
|
||||
|
||||
- **WHEN** checking initialization feasibility
|
||||
- **THEN** verify write permissions in the target directory silently
|
||||
- **AND** only display error if permissions are insufficient
|
||||
|
||||
### Requirement: Success Output
|
||||
|
||||
The command SHALL provide clear, actionable next steps upon successful initialization.
|
||||
|
||||
#### Scenario: Displaying success message
|
||||
|
||||
- **WHEN** initialization completes successfully
|
||||
- **THEN** include prompt: "Please explain the OpenSpec workflow from openspec/AGENTS.md and how I should work with you on this project"
|
||||
- **THEN** display actionable prompts for AI-driven workflow:
|
||||
```
|
||||
✔ OpenSpec initialized successfully!
|
||||
|
||||
Next steps - Copy these prompts to Claude:
|
||||
|
||||
────────────────────────────────────────────────────────────
|
||||
1. Populate your project context:
|
||||
"Please read openspec/project.md and help me fill it out
|
||||
with details about my project, tech stack, and conventions"
|
||||
|
||||
2. Create your first change proposal:
|
||||
"I want to add [YOUR FEATURE HERE]. Please create an
|
||||
OpenSpec change proposal for this feature"
|
||||
|
||||
3. Learn the OpenSpec workflow:
|
||||
"Please explain the OpenSpec workflow from openspec/AGENTS.md
|
||||
and how I should work with you on this project"
|
||||
────────────────────────────────────────────────────────────
|
||||
```
|
||||
|
||||
The prompts SHALL:
|
||||
- Be copy-pasteable for immediate use with AI tools
|
||||
- Guide users through the AI-driven workflow
|
||||
- Replace placeholder text ([YOUR FEATURE HERE]) with actual features
|
||||
|
||||
### Requirement: Exit Codes
|
||||
|
||||
@@ -116,52 +187,6 @@ The command SHALL use consistent exit codes to indicate different failure modes.
|
||||
- 2: Insufficient permissions (reserved for future use)
|
||||
- 3: User cancelled operation (reserved for future use)
|
||||
|
||||
### Requirement: Additional AI Tool Initialization
|
||||
`openspec init` SHALL allow users to add configuration files for new AI coding assistants after the initial setup.
|
||||
|
||||
#### Scenario: Configuring an extra tool after initial setup
|
||||
- **GIVEN** an `openspec/` directory already exists and at least one AI tool file is present
|
||||
- **WHEN** the user runs `openspec init` and selects a different supported AI tool
|
||||
- **THEN** generate that tool's configuration files with OpenSpec markers the same way as during first-time initialization
|
||||
- **AND** leave existing tool configuration files unchanged except for managed sections that need refreshing
|
||||
- **AND** exit with code 0 and display a success summary highlighting the newly added tool files
|
||||
|
||||
### Requirement: Success Output Enhancements
|
||||
`openspec init` SHALL summarize tool actions when initialization or extend mode completes.
|
||||
|
||||
#### Scenario: Showing tool summary
|
||||
- **WHEN** the command completes successfully
|
||||
- **THEN** display a categorized summary of tools that were created, refreshed, or skipped (including already-configured skips)
|
||||
- **AND** personalize the "Next steps" header using the names of the selected tools, defaulting to a generic label when none remain
|
||||
|
||||
### Requirement: Exit Code Adjustments
|
||||
`openspec init` SHALL treat extend mode with no selected tools as a guarded error.
|
||||
|
||||
#### Scenario: Preventing empty extend runs
|
||||
- **WHEN** OpenSpec is already initialized and the user selects no additional tools
|
||||
- **THEN** exit with code 1 after showing the existing-initialization guidance message
|
||||
|
||||
### 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 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
|
||||
|
||||
## Why
|
||||
|
||||
Manual creation of OpenSpec structure is error-prone and creates adoption friction. A standardized init command ensures:
|
||||
|
||||
@@ -5,11 +5,22 @@
|
||||
As a developer using OpenSpec, I want to update the OpenSpec instructions in my project when new versions are released, so that I can benefit from improvements to AI agent instructions.
|
||||
## Requirements
|
||||
### Requirement: Update Behavior
|
||||
|
||||
The update command SHALL update OpenSpec instruction files to the latest templates in a team-friendly manner.
|
||||
|
||||
#### Scenario: Running update command
|
||||
|
||||
- **WHEN** a user runs `openspec update`
|
||||
- **THEN** replace `openspec/AGENTS.md` with the latest template
|
||||
- **THEN** the command SHALL:
|
||||
- Check if the `openspec` directory exists
|
||||
- Replace `openspec/AGENTS.md` with the latest template (complete replacement)
|
||||
- Update **only existing** AI tool configuration files (e.g., CLAUDE.md)
|
||||
- Check each registered AI tool configurator
|
||||
- For each configurator, check if its file exists
|
||||
- Update only files that already exist using their markers
|
||||
- Preserve user content outside markers
|
||||
- **Never create new AI tool configuration files**
|
||||
- Display success message listing updated files
|
||||
|
||||
### Requirement: Prerequisites
|
||||
|
||||
@@ -23,53 +34,39 @@ The command SHALL require an existing OpenSpec structure before allowing updates
|
||||
- **AND** exit with code 1
|
||||
|
||||
### Requirement: File Handling
|
||||
|
||||
The update command SHALL handle file updates in a predictable and safe manner.
|
||||
|
||||
#### Scenario: Updating files
|
||||
- **WHEN** updating files
|
||||
- **THEN** completely replace `openspec/AGENTS.md` with the latest template
|
||||
|
||||
### Requirement: Tool-Agnostic Updates
|
||||
The update command SHALL handle file updates in a predictable and safe manner while respecting team tool choices.
|
||||
|
||||
#### Scenario: Updating files
|
||||
|
||||
- **WHEN** updating files
|
||||
- **THEN** completely replace `openspec/AGENTS.md` with the latest template
|
||||
- **AND** create or update the root-level `AGENTS.md` using the OpenSpec markers
|
||||
- **AND** update only the OpenSpec-managed blocks in **existing** AI tool files using markers
|
||||
- **AND** use the default directory name `openspec`
|
||||
- **AND** be idempotent (repeated runs have no additional effect)
|
||||
- **AND** respect team members' AI tool choices by not creating additional tool files beyond the root `AGENTS.md`
|
||||
- **AND** respect team members' AI tool choices by not creating unwanted files
|
||||
|
||||
### Requirement: Tool-Agnostic Updates
|
||||
|
||||
The update command SHALL update only existing AI tool configuration files and SHALL NOT create new ones.
|
||||
|
||||
#### Scenario: Updating existing tool files
|
||||
|
||||
- **WHEN** a user runs `openspec update`
|
||||
- **THEN** update each AI tool configuration file that exists (e.g., CLAUDE.md, COPILOT.md)
|
||||
- **AND** do not create missing tool configuration files
|
||||
- **AND** preserve user content outside OpenSpec markers
|
||||
|
||||
### Requirement: Core Files Always Updated
|
||||
|
||||
The update command SHALL always update the core OpenSpec files and display an ASCII-safe success message.
|
||||
|
||||
#### Scenario: Successful update
|
||||
|
||||
- **WHEN** the update completes successfully
|
||||
- **THEN** replace `openspec/AGENTS.md` with the latest template
|
||||
|
||||
### 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 OpenCode
|
||||
- **WHEN** `.opencode/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: Missing slash command file
|
||||
- **WHEN** a tool lacks a slash command file
|
||||
- **THEN** do not create a new file during update
|
||||
- **AND** update existing AI tool configuration files within markers
|
||||
- **AND** display the message: "Updated OpenSpec instructions"
|
||||
|
||||
## Edge Cases
|
||||
|
||||
|
||||
@@ -199,12 +199,3 @@ The validate command SHALL handle ambiguous names and explicit type overrides to
|
||||
- **THEN** the CLI SHALL not display interactive prompts
|
||||
- **AND** SHALL print non-interactive hints or chosen outputs as appropriate
|
||||
|
||||
### Requirement: Parser SHALL handle cross-platform line endings
|
||||
The markdown parser SHALL correctly identify sections regardless of line ending format (LF, CRLF, CR).
|
||||
|
||||
#### Scenario: Required sections parsed with CRLF line endings
|
||||
- **GIVEN** a change proposal markdown saved with CRLF line endings
|
||||
- **AND** the document contains `## Why` and `## What Changes`
|
||||
- **WHEN** running `openspec validate <change-id>`
|
||||
- **THEN** validation SHALL recognize the sections and NOT raise parsing errors
|
||||
|
||||
|
||||
@@ -36,14 +36,28 @@ The dashboard SHALL display a summary section with key project metrics.
|
||||
- **THEN** summary shows zero counts for all metrics
|
||||
|
||||
### Requirement: Active Changes Display
|
||||
|
||||
The dashboard SHALL show active changes with visual progress indicators.
|
||||
|
||||
#### Scenario: Active changes with progress bars
|
||||
|
||||
- **WHEN** there are in-progress changes with tasks
|
||||
- **THEN** system displays each change with change name left-aligned
|
||||
- **AND** visual progress bar using Unicode characters
|
||||
- **AND** percentage completion on the right
|
||||
|
||||
#### Scenario: Active changes ordered by completion percentage
|
||||
|
||||
- **WHEN** multiple active changes are displayed with progress information
|
||||
- **THEN** list them sorted by completion percentage ascending so 0% items appear first
|
||||
- **AND** treat missing progress values as 0% for ordering
|
||||
- **AND** break ties by change identifier in ascending alphabetical order to keep output deterministic
|
||||
|
||||
#### Scenario: No active changes
|
||||
|
||||
- **WHEN** all changes are completed or no changes exist
|
||||
- **THEN** active changes section is omitted from display
|
||||
|
||||
### Requirement: Completed Changes Display
|
||||
|
||||
The dashboard SHALL list completed changes in a separate section.
|
||||
|
||||
@@ -14,9 +14,11 @@ OpenSpec conventions SHALL mandate a structured spec format with clear requireme
|
||||
- **THEN** authors SHALL use `### Requirement: ...` followed by at least one `#### Scenario: ...` section
|
||||
|
||||
### Requirement: Project Structure
|
||||
|
||||
An OpenSpec project SHALL maintain a consistent directory structure for specifications and changes.
|
||||
|
||||
#### Scenario: Initializing project structure
|
||||
|
||||
- **WHEN** an OpenSpec project is initialized
|
||||
- **THEN** it SHALL have this structure:
|
||||
```
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fission-ai/openspec",
|
||||
"version": "0.5.0",
|
||||
"version": "0.3.0",
|
||||
"description": "AI-native system for spec-driven development",
|
||||
"keywords": [
|
||||
"openspec",
|
||||
|
||||
@@ -19,6 +19,5 @@ export interface AIToolOption {
|
||||
export const AI_TOOLS: AIToolOption[] = [
|
||||
{ name: 'Claude Code (✅ OpenSpec custom slash commands available)', value: 'claude', available: true, successLabel: 'Claude Code' },
|
||||
{ name: 'Cursor (✅ OpenSpec custom slash commands available)', value: 'cursor', available: true, successLabel: 'Cursor' },
|
||||
{ name: 'OpenCode (✅ OpenSpec custom slash commands available)', value: 'opencode', available: true, successLabel: 'OpenCode' },
|
||||
{ name: 'AGENTS.md (works with Codex, Amp, Copilot, …)', value: 'agents', available: true, successLabel: 'your AGENTS.md-compatible assistant' }
|
||||
];
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { SlashCommandConfigurator } from "./base.js";
|
||||
import { SlashCommandId } from "../../templates/index.js";
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: ".opencode/command/openspec-proposal.md",
|
||||
apply: ".opencode/command/openspec-apply.md",
|
||||
archive: ".opencode/command/openspec-archive.md",
|
||||
};
|
||||
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
agent: build
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
---
|
||||
The user has requested the following change proposal. Use the openspec instructions to create their change proposal.
|
||||
<UserRequest>
|
||||
$ARGUMENTS
|
||||
</UserRequest>
|
||||
`,
|
||||
apply: `---
|
||||
agent: build
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
---`,
|
||||
archive: `---
|
||||
agent: build
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
---`,
|
||||
};
|
||||
|
||||
export class OpenCodeSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = "opencode";
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string | undefined {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { ClaudeSlashCommandConfigurator } from './claude.js';
|
||||
import { CursorSlashCommandConfigurator } from './cursor.js';
|
||||
import { OpenCodeSlashCommandConfigurator } from './opencode.js';
|
||||
|
||||
export class SlashCommandRegistry {
|
||||
private static configurators: Map<string, SlashCommandConfigurator> = new Map();
|
||||
@@ -9,11 +8,9 @@ export class SlashCommandRegistry {
|
||||
static {
|
||||
const claude = new ClaudeSlashCommandConfigurator();
|
||||
const cursor = new CursorSlashCommandConfigurator();
|
||||
const opencode = new OpenCodeSlashCommandConfigurator();
|
||||
|
||||
this.configurators.set(claude.toolId, claude);
|
||||
this.configurators.set(cursor.toolId, cursor);
|
||||
this.configurators.set(opencode.toolId, opencode);
|
||||
}
|
||||
|
||||
static register(configurator: SlashCommandConfigurator): void {
|
||||
|
||||
@@ -43,8 +43,7 @@ export class JsonConverter {
|
||||
}
|
||||
|
||||
private extractNameFromPath(filePath: string): string {
|
||||
const normalizedPath = filePath.replaceAll('\\', '/');
|
||||
const parts = normalizedPath.split('/');
|
||||
const parts = filePath.split('/');
|
||||
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
if (parts[i] === 'specs' || parts[i] === 'changes') {
|
||||
@@ -54,8 +53,7 @@ export class JsonConverter {
|
||||
}
|
||||
}
|
||||
|
||||
const fileName = parts[parts.length - 1] ?? '';
|
||||
const dotIndex = fileName.lastIndexOf('.');
|
||||
return dotIndex > 0 ? fileName.slice(0, dotIndex) : fileName;
|
||||
const fileName = parts[parts.length - 1];
|
||||
return fileName.replace('.md', '');
|
||||
}
|
||||
}
|
||||
}
|
||||
+243
-334
@@ -8,7 +8,7 @@ import {
|
||||
isUpKey,
|
||||
useKeypress,
|
||||
usePagination,
|
||||
useState,
|
||||
useState
|
||||
} from '@inquirer/core';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
@@ -16,33 +16,70 @@ import { FileSystemUtils } from '../utils/file-system.js';
|
||||
import { TemplateManager, ProjectContext } from './templates/index.js';
|
||||
import { ToolRegistry } from './configurators/registry.js';
|
||||
import { SlashCommandRegistry } from './configurators/slash/registry.js';
|
||||
import {
|
||||
OpenSpecConfig,
|
||||
AI_TOOLS,
|
||||
OPENSPEC_DIR_NAME,
|
||||
AIToolOption,
|
||||
} from './config.js';
|
||||
import { OpenSpecConfig, AI_TOOLS, OPENSPEC_DIR_NAME, AIToolOption } from './config.js';
|
||||
|
||||
const PROGRESS_SPINNER = {
|
||||
interval: 80,
|
||||
frames: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓'],
|
||||
frames: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓']
|
||||
};
|
||||
|
||||
const PALETTE = {
|
||||
white: chalk.hex('#f4f4f4'),
|
||||
lightGray: chalk.hex('#c8c8c8'),
|
||||
midGray: chalk.hex('#8a8a8a'),
|
||||
darkGray: chalk.hex('#4a4a4a'),
|
||||
darkGray: chalk.hex('#4a4a4a')
|
||||
};
|
||||
|
||||
const LETTER_MAP: Record<string, string[]> = {
|
||||
O: [' ████ ', '██ ██', '██ ██', '██ ██', ' ████ '],
|
||||
P: ['█████ ', '██ ██', '█████ ', '██ ', '██ '],
|
||||
E: ['██████', '██ ', '█████ ', '██ ', '██████'],
|
||||
N: ['██ ██', '███ ██', '██ ███', '██ ██', '██ ██'],
|
||||
S: [' █████', '██ ', ' ████ ', ' ██', '█████ '],
|
||||
C: [' █████', '██ ', '██ ', '██ ', ' █████'],
|
||||
' ': [' ', ' ', ' ', ' ', ' '],
|
||||
O: [
|
||||
' ████ ',
|
||||
'██ ██',
|
||||
'██ ██',
|
||||
'██ ██',
|
||||
' ████ '
|
||||
],
|
||||
P: [
|
||||
'█████ ',
|
||||
'██ ██',
|
||||
'█████ ',
|
||||
'██ ',
|
||||
'██ '
|
||||
],
|
||||
E: [
|
||||
'██████',
|
||||
'██ ',
|
||||
'█████ ',
|
||||
'██ ',
|
||||
'██████'
|
||||
],
|
||||
N: [
|
||||
'██ ██',
|
||||
'███ ██',
|
||||
'██ ███',
|
||||
'██ ██',
|
||||
'██ ██'
|
||||
],
|
||||
S: [
|
||||
' █████',
|
||||
'██ ',
|
||||
' ████ ',
|
||||
' ██',
|
||||
'█████ '
|
||||
],
|
||||
C: [
|
||||
' █████',
|
||||
'██ ',
|
||||
'██ ',
|
||||
'██ ',
|
||||
' █████'
|
||||
],
|
||||
' ': [
|
||||
' ',
|
||||
' ',
|
||||
' ',
|
||||
' ',
|
||||
' '
|
||||
]
|
||||
};
|
||||
|
||||
type ToolLabel = {
|
||||
@@ -50,8 +87,7 @@ type ToolLabel = {
|
||||
annotation?: string;
|
||||
};
|
||||
|
||||
const sanitizeToolLabel = (raw: string): string =>
|
||||
raw.replace(/✅/gu, '✔').trim();
|
||||
const sanitizeToolLabel = (raw: string): string => raw.replace(/✅/gu, '✔').trim();
|
||||
|
||||
const parseToolLabel = (raw: string): ToolLabel => {
|
||||
const sanitized = sanitizeToolLabel(raw);
|
||||
@@ -61,7 +97,7 @@ const parseToolLabel = (raw: string): ToolLabel => {
|
||||
}
|
||||
return {
|
||||
primary: match[1].trim(),
|
||||
annotation: match[2].trim(),
|
||||
annotation: match[2].trim()
|
||||
};
|
||||
};
|
||||
|
||||
@@ -82,189 +118,165 @@ type WizardStep = 'intro' | 'select' | 'review';
|
||||
|
||||
type ToolSelectionPrompt = (config: ToolWizardConfig) => Promise<string[]>;
|
||||
|
||||
const toolSelectionWizard = createPrompt<string[], ToolWizardConfig>(
|
||||
(config, done) => {
|
||||
const totalSteps = 3;
|
||||
const [step, setStep] = useState<WizardStep>('intro');
|
||||
const [cursor, setCursor] = useState<number>(0);
|
||||
const [selected, setSelected] = useState<string[]>(
|
||||
() => config.initialSelected ?? []
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const toolSelectionWizard = createPrompt<string[], ToolWizardConfig>((config, done) => {
|
||||
const totalSteps = 3;
|
||||
const [step, setStep] = useState<WizardStep>('intro');
|
||||
const [cursor, setCursor] = useState<number>(0);
|
||||
const [selected, setSelected] = useState<string[]>(() => config.initialSelected ?? []);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selectedSet = new Set(selected);
|
||||
const pageSize = Math.max(Math.min(config.choices.length, 7), 1);
|
||||
const selectedSet = new Set(selected);
|
||||
const pageSize = Math.max(Math.min(config.choices.length, 7), 1);
|
||||
|
||||
const updateSelected = (next: Set<string>) => {
|
||||
const ordered = config.choices
|
||||
.map((choice) => choice.value)
|
||||
.filter((value) => next.has(value));
|
||||
setSelected(ordered);
|
||||
};
|
||||
const updateSelected = (next: Set<string>) => {
|
||||
const ordered = config.choices
|
||||
.map((choice) => choice.value)
|
||||
.filter((value) => next.has(value));
|
||||
setSelected(ordered);
|
||||
};
|
||||
|
||||
const page = usePagination({
|
||||
items: config.choices,
|
||||
active: cursor,
|
||||
pageSize,
|
||||
loop: config.choices.length > 1,
|
||||
renderItem: ({ item, isActive }) => {
|
||||
const isSelected = selectedSet.has(item.value);
|
||||
const cursorSymbol = isActive
|
||||
? PALETTE.white('›')
|
||||
: PALETTE.midGray(' ');
|
||||
const indicator = isSelected
|
||||
? PALETTE.white('◉')
|
||||
: PALETTE.midGray('○');
|
||||
const nameColor = isActive ? PALETTE.white : PALETTE.midGray;
|
||||
const label = `${nameColor(item.label.primary)}${
|
||||
item.configured ? PALETTE.midGray(' (already configured)') : ''
|
||||
}`;
|
||||
return `${cursorSymbol} ${indicator} ${label}`;
|
||||
},
|
||||
});
|
||||
const page = usePagination({
|
||||
items: config.choices,
|
||||
active: cursor,
|
||||
pageSize,
|
||||
loop: config.choices.length > 1,
|
||||
renderItem: ({ item, isActive }) => {
|
||||
const isSelected = selectedSet.has(item.value);
|
||||
const cursorSymbol = isActive ? PALETTE.white('›') : PALETTE.midGray(' ');
|
||||
const indicator = isSelected ? PALETTE.white('◉') : PALETTE.midGray('○');
|
||||
const nameColor = isActive ? PALETTE.white : PALETTE.midGray;
|
||||
const label = `${nameColor(item.label.primary)}${item.configured ? PALETTE.midGray(' (already configured)') : ''}`;
|
||||
return `${cursorSymbol} ${indicator} ${label}`;
|
||||
}
|
||||
});
|
||||
|
||||
useKeypress((key) => {
|
||||
if (step === 'intro') {
|
||||
if (isEnterKey(key)) {
|
||||
setStep('select');
|
||||
}
|
||||
useKeypress((key) => {
|
||||
if (step === 'intro') {
|
||||
if (isEnterKey(key)) {
|
||||
setStep('select');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (step === 'select') {
|
||||
if (isUpKey(key)) {
|
||||
const previousIndex = cursor <= 0 ? config.choices.length - 1 : cursor - 1;
|
||||
setCursor(previousIndex);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (step === 'select') {
|
||||
if (isUpKey(key)) {
|
||||
const previousIndex =
|
||||
cursor <= 0 ? config.choices.length - 1 : cursor - 1;
|
||||
setCursor(previousIndex);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDownKey(key)) {
|
||||
const nextIndex =
|
||||
cursor >= config.choices.length - 1 ? 0 : cursor + 1;
|
||||
setCursor(nextIndex);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSpaceKey(key)) {
|
||||
const current = config.choices[cursor];
|
||||
if (!current) return;
|
||||
|
||||
const next = new Set(selected);
|
||||
if (next.has(current.value)) {
|
||||
next.delete(current.value);
|
||||
} else {
|
||||
next.add(current.value);
|
||||
}
|
||||
|
||||
updateSelected(next);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEnterKey(key)) {
|
||||
if (selected.length === 0) {
|
||||
setError('Select at least one AI tool to continue.');
|
||||
return;
|
||||
}
|
||||
setStep('review');
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === 'escape') {
|
||||
setSelected([]);
|
||||
setError(null);
|
||||
}
|
||||
if (isDownKey(key)) {
|
||||
const nextIndex = cursor >= config.choices.length - 1 ? 0 : cursor + 1;
|
||||
setCursor(nextIndex);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (step === 'review') {
|
||||
if (isEnterKey(key)) {
|
||||
const finalSelection = config.choices
|
||||
.map((choice) => choice.value)
|
||||
.filter((value) => selectedSet.has(value));
|
||||
done(finalSelection);
|
||||
if (isSpaceKey(key)) {
|
||||
const current = config.choices[cursor];
|
||||
if (!current) return;
|
||||
|
||||
const next = new Set(selected);
|
||||
if (next.has(current.value)) {
|
||||
next.delete(current.value);
|
||||
} else {
|
||||
next.add(current.value);
|
||||
}
|
||||
|
||||
updateSelected(next);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEnterKey(key)) {
|
||||
if (selected.length === 0) {
|
||||
setError('Select at least one AI tool to continue.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBackspaceKey(key) || key.name === 'escape') {
|
||||
setStep('select');
|
||||
setError(null);
|
||||
}
|
||||
setStep('review');
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const selectedNames = config.choices
|
||||
.filter((choice) => selectedSet.has(choice.value))
|
||||
.map((choice) => choice.label.primary);
|
||||
if (key.name === 'escape') {
|
||||
setSelected([]);
|
||||
setError(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const stepIndex = step === 'intro' ? 1 : step === 'select' ? 2 : 3;
|
||||
const lines: string[] = [];
|
||||
lines.push(PALETTE.midGray(`Step ${stepIndex}/${totalSteps}`));
|
||||
if (step === 'review') {
|
||||
if (isEnterKey(key)) {
|
||||
const finalSelection = config.choices
|
||||
.map((choice) => choice.value)
|
||||
.filter((value) => selectedSet.has(value));
|
||||
done(finalSelection);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBackspaceKey(key) || key.name === 'escape') {
|
||||
setStep('select');
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const selectedNames = config.choices
|
||||
.filter((choice) => selectedSet.has(choice.value))
|
||||
.map((choice) => choice.label.primary);
|
||||
|
||||
const stepIndex = step === 'intro' ? 1 : step === 'select' ? 2 : 3;
|
||||
const lines: string[] = [];
|
||||
lines.push(PALETTE.midGray(`Step ${stepIndex}/${totalSteps}`));
|
||||
lines.push('');
|
||||
|
||||
if (step === 'intro') {
|
||||
const introHeadline = config.extendMode
|
||||
? 'Extend your OpenSpec tooling'
|
||||
: 'Configure your OpenSpec tooling';
|
||||
const introBody = config.extendMode
|
||||
? 'We detected an existing setup. We will help you refresh or add integrations.'
|
||||
: "Let's get your AI assistants connected so they understand OpenSpec.";
|
||||
|
||||
lines.push(PALETTE.white(introHeadline));
|
||||
lines.push(PALETTE.midGray(introBody));
|
||||
lines.push('');
|
||||
lines.push(PALETTE.midGray('Press Enter to continue.'));
|
||||
} else if (step === 'select') {
|
||||
lines.push(PALETTE.white(config.baseMessage));
|
||||
lines.push(PALETTE.midGray('Use ↑/↓ to move · Space to toggle · Enter to review selections.'));
|
||||
lines.push('');
|
||||
lines.push(page);
|
||||
lines.push('');
|
||||
if (selectedNames.length === 0) {
|
||||
lines.push(`${PALETTE.midGray('Selected')}: ${PALETTE.midGray('None selected yet')}`);
|
||||
} else {
|
||||
lines.push(PALETTE.midGray('Selected:'));
|
||||
selectedNames.forEach((name) => {
|
||||
lines.push(` ${PALETTE.white('-')} ${PALETTE.white(name)}`);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
lines.push(PALETTE.white('Review selections'));
|
||||
lines.push(PALETTE.midGray('Press Enter to confirm or Backspace to adjust.'));
|
||||
lines.push('');
|
||||
|
||||
if (step === 'intro') {
|
||||
const introHeadline = config.extendMode
|
||||
? 'Extend your OpenSpec tooling'
|
||||
: 'Configure your OpenSpec tooling';
|
||||
const introBody = config.extendMode
|
||||
? 'We detected an existing setup. We will help you refresh or add integrations.'
|
||||
: "Let's get your AI assistants connected so they understand OpenSpec.";
|
||||
|
||||
lines.push(PALETTE.white(introHeadline));
|
||||
lines.push(PALETTE.midGray(introBody));
|
||||
lines.push('');
|
||||
lines.push(PALETTE.midGray('Press Enter to continue.'));
|
||||
} else if (step === 'select') {
|
||||
lines.push(PALETTE.white(config.baseMessage));
|
||||
lines.push(
|
||||
PALETTE.midGray(
|
||||
'Use ↑/↓ to move · Space to toggle · Enter to review selections.'
|
||||
)
|
||||
);
|
||||
lines.push('');
|
||||
lines.push(page);
|
||||
lines.push('');
|
||||
if (selectedNames.length === 0) {
|
||||
lines.push(
|
||||
`${PALETTE.midGray('Selected')}: ${PALETTE.midGray(
|
||||
'None selected yet'
|
||||
)}`
|
||||
);
|
||||
} else {
|
||||
lines.push(PALETTE.midGray('Selected:'));
|
||||
selectedNames.forEach((name) => {
|
||||
lines.push(` ${PALETTE.white('-')} ${PALETTE.white(name)}`);
|
||||
});
|
||||
}
|
||||
if (selectedNames.length === 0) {
|
||||
lines.push(PALETTE.midGray('No tools selected. Press Backspace to return.'));
|
||||
} else {
|
||||
lines.push(PALETTE.white('Review selections'));
|
||||
lines.push(
|
||||
PALETTE.midGray('Press Enter to confirm or Backspace to adjust.')
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
if (selectedNames.length === 0) {
|
||||
lines.push(
|
||||
PALETTE.midGray('No tools selected. Press Backspace to return.')
|
||||
);
|
||||
} else {
|
||||
selectedNames.forEach((name) => {
|
||||
lines.push(`${PALETTE.white('▌')} ${PALETTE.white(name)}`);
|
||||
});
|
||||
}
|
||||
selectedNames.forEach((name) => {
|
||||
lines.push(`${PALETTE.white('▌')} ${PALETTE.white(name)}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return [lines.join('\n'), chalk.red(error)];
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return [lines.join('\n'), chalk.red(error)];
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
});
|
||||
|
||||
type InitCommandOptions = {
|
||||
prompt?: ToolSelectionPrompt;
|
||||
@@ -295,48 +307,32 @@ export class InitCommand {
|
||||
if (extendMode) {
|
||||
throw new Error(
|
||||
`OpenSpec seems to already be initialized at ${openspecPath}.\n` +
|
||||
`Use 'openspec update' to update the structure.`
|
||||
`Use 'openspec update' to update the structure.`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error('You must select at least one AI tool to configure.');
|
||||
}
|
||||
|
||||
const availableTools = AI_TOOLS.filter((tool) => tool.available);
|
||||
const availableTools = AI_TOOLS.filter(tool => tool.available);
|
||||
const selectedIds = new Set(config.aiTools);
|
||||
const selectedTools = availableTools.filter((tool) =>
|
||||
selectedIds.has(tool.value)
|
||||
);
|
||||
const created = selectedTools.filter(
|
||||
(tool) => !existingToolStates[tool.value]
|
||||
);
|
||||
const refreshed = selectedTools.filter(
|
||||
(tool) => existingToolStates[tool.value]
|
||||
);
|
||||
const skippedExisting = availableTools.filter(
|
||||
(tool) => !selectedIds.has(tool.value) && existingToolStates[tool.value]
|
||||
);
|
||||
const skipped = availableTools.filter(
|
||||
(tool) => !selectedIds.has(tool.value) && !existingToolStates[tool.value]
|
||||
);
|
||||
const selectedTools = availableTools.filter(tool => selectedIds.has(tool.value));
|
||||
const created = selectedTools.filter(tool => !existingToolStates[tool.value]);
|
||||
const refreshed = selectedTools.filter(tool => existingToolStates[tool.value]);
|
||||
const skippedExisting = availableTools.filter(tool => !selectedIds.has(tool.value) && existingToolStates[tool.value]);
|
||||
const skipped = availableTools.filter(tool => !selectedIds.has(tool.value) && !existingToolStates[tool.value]);
|
||||
|
||||
// Step 1: Create directory structure
|
||||
if (!extendMode) {
|
||||
const structureSpinner = this.startSpinner(
|
||||
'Creating OpenSpec structure...'
|
||||
);
|
||||
const structureSpinner = this.startSpinner('Creating OpenSpec structure...');
|
||||
await this.createDirectoryStructure(openspecPath);
|
||||
await this.generateFiles(openspecPath, config);
|
||||
structureSpinner.stopAndPersist({
|
||||
symbol: PALETTE.white('▌'),
|
||||
text: PALETTE.white('OpenSpec structure created'),
|
||||
text: PALETTE.white('OpenSpec structure created')
|
||||
});
|
||||
} else {
|
||||
ora({ stream: process.stdout }).info(
|
||||
PALETTE.midGray(
|
||||
'ℹ OpenSpec already initialized. Skipping base scaffolding.'
|
||||
)
|
||||
);
|
||||
ora({ stream: process.stdout }).info(PALETTE.midGray('ℹ OpenSpec already initialized. Skipping base scaffolding.'));
|
||||
}
|
||||
|
||||
// Step 2: Configure AI tools
|
||||
@@ -344,49 +340,30 @@ export class InitCommand {
|
||||
await this.configureAITools(projectPath, openspecDir, config.aiTools);
|
||||
toolSpinner.stopAndPersist({
|
||||
symbol: PALETTE.white('▌'),
|
||||
text: PALETTE.white('AI tools configured'),
|
||||
text: PALETTE.white('AI tools configured')
|
||||
});
|
||||
|
||||
// Success message
|
||||
this.displaySuccessMessage(
|
||||
selectedTools,
|
||||
created,
|
||||
refreshed,
|
||||
skippedExisting,
|
||||
skipped,
|
||||
extendMode
|
||||
);
|
||||
this.displaySuccessMessage(selectedTools, created, refreshed, skippedExisting, skipped, extendMode);
|
||||
}
|
||||
|
||||
private async validate(
|
||||
projectPath: string,
|
||||
_openspecPath: string
|
||||
): Promise<boolean> {
|
||||
private async validate(projectPath: string, _openspecPath: string): Promise<boolean> {
|
||||
const extendMode = await FileSystemUtils.directoryExists(_openspecPath);
|
||||
|
||||
// Check write permissions
|
||||
if (!(await FileSystemUtils.ensureWritePermissions(projectPath))) {
|
||||
if (!await FileSystemUtils.ensureWritePermissions(projectPath)) {
|
||||
throw new Error(`Insufficient permissions to write to ${projectPath}`);
|
||||
}
|
||||
return extendMode;
|
||||
}
|
||||
|
||||
private async getConfiguration(
|
||||
existingTools: Record<string, boolean>,
|
||||
extendMode: boolean
|
||||
): Promise<OpenSpecConfig> {
|
||||
const selectedTools = await this.promptForAITools(
|
||||
existingTools,
|
||||
extendMode
|
||||
);
|
||||
private async getConfiguration(existingTools: Record<string, boolean>, extendMode: boolean): Promise<OpenSpecConfig> {
|
||||
const selectedTools = await this.promptForAITools(existingTools, extendMode);
|
||||
return { aiTools: selectedTools };
|
||||
}
|
||||
|
||||
private async promptForAITools(
|
||||
existingTools: Record<string, boolean>,
|
||||
extendMode: boolean
|
||||
): Promise<string[]> {
|
||||
const availableTools = AI_TOOLS.filter((tool) => tool.available);
|
||||
private async promptForAITools(existingTools: Record<string, boolean>, extendMode: boolean): Promise<string[]> {
|
||||
const availableTools = AI_TOOLS.filter(tool => tool.available);
|
||||
|
||||
if (availableTools.length === 0) {
|
||||
return [];
|
||||
@@ -396,9 +373,7 @@ export class InitCommand {
|
||||
? 'Which AI tools would you like to add or refresh?'
|
||||
: 'Which AI tools do you use?';
|
||||
const initialSelected = extendMode
|
||||
? availableTools
|
||||
.filter((tool) => existingTools[tool.value])
|
||||
.map((tool) => tool.value)
|
||||
? availableTools.filter(tool => existingTools[tool.value]).map(tool => tool.value)
|
||||
: [];
|
||||
|
||||
return this.prompt({
|
||||
@@ -407,15 +382,13 @@ export class InitCommand {
|
||||
choices: availableTools.map((tool) => ({
|
||||
value: tool.value,
|
||||
label: parseToolLabel(tool.name),
|
||||
configured: Boolean(existingTools[tool.value]),
|
||||
configured: Boolean(existingTools[tool.value])
|
||||
})),
|
||||
initialSelected,
|
||||
initialSelected
|
||||
});
|
||||
}
|
||||
|
||||
private async getExistingToolStates(
|
||||
projectPath: string
|
||||
): Promise<Record<string, boolean>> {
|
||||
private async getExistingToolStates(projectPath: string): Promise<Record<string, boolean>> {
|
||||
const states: Record<string, boolean> = {};
|
||||
for (const tool of AI_TOOLS) {
|
||||
states[tool.value] = await this.isToolConfigured(projectPath, tool.value);
|
||||
@@ -423,22 +396,14 @@ export class InitCommand {
|
||||
return states;
|
||||
}
|
||||
|
||||
private async isToolConfigured(
|
||||
projectPath: string,
|
||||
toolId: string
|
||||
): Promise<boolean> {
|
||||
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;
|
||||
if (configFile && await FileSystemUtils.fileExists(path.join(projectPath, configFile))) return true;
|
||||
|
||||
const slashConfigurator = SlashCommandRegistry.get(toolId);
|
||||
if (!slashConfigurator) return false;
|
||||
for (const target of slashConfigurator.getTargets()) {
|
||||
if (await FileSystemUtils.fileExists(path.join(projectPath, target.path)))
|
||||
return true;
|
||||
if (await FileSystemUtils.fileExists(path.join(projectPath, target.path))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -448,7 +413,7 @@ export class InitCommand {
|
||||
openspecPath,
|
||||
path.join(openspecPath, 'specs'),
|
||||
path.join(openspecPath, 'changes'),
|
||||
path.join(openspecPath, 'changes', 'archive'),
|
||||
path.join(openspecPath, 'changes', 'archive')
|
||||
];
|
||||
|
||||
for (const dir of directories) {
|
||||
@@ -456,32 +421,24 @@ export class InitCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private async generateFiles(
|
||||
openspecPath: string,
|
||||
config: OpenSpecConfig
|
||||
): Promise<void> {
|
||||
private async generateFiles(openspecPath: string, config: OpenSpecConfig): Promise<void> {
|
||||
const context: ProjectContext = {
|
||||
// Could be enhanced with prompts for project details
|
||||
};
|
||||
|
||||
const templates = TemplateManager.getTemplates(context);
|
||||
|
||||
|
||||
for (const template of templates) {
|
||||
const filePath = path.join(openspecPath, template.path);
|
||||
const content =
|
||||
typeof template.content === 'function'
|
||||
? template.content(context)
|
||||
: template.content;
|
||||
|
||||
const content = typeof template.content === 'function'
|
||||
? template.content(context)
|
||||
: template.content;
|
||||
|
||||
await FileSystemUtils.writeFile(filePath, content);
|
||||
}
|
||||
}
|
||||
|
||||
private async configureAITools(
|
||||
projectPath: string,
|
||||
openspecDir: string,
|
||||
toolIds: string[]
|
||||
): Promise<void> {
|
||||
private async configureAITools(projectPath: string, openspecDir: string, toolIds: string[]): Promise<void> {
|
||||
for (const toolId of toolIds) {
|
||||
const configurator = ToolRegistry.get(toolId);
|
||||
if (configurator && configurator.isAvailable) {
|
||||
@@ -512,80 +469,34 @@ export class InitCommand {
|
||||
console.log();
|
||||
console.log(PALETTE.lightGray('Tool summary:'));
|
||||
const summaryLines = [
|
||||
created.length
|
||||
? `${PALETTE.white('▌')} ${PALETTE.white(
|
||||
'Created:'
|
||||
)} ${this.formatToolNames(created)}`
|
||||
: null,
|
||||
refreshed.length
|
||||
? `${PALETTE.lightGray('▌')} ${PALETTE.lightGray(
|
||||
'Refreshed:'
|
||||
)} ${this.formatToolNames(refreshed)}`
|
||||
: null,
|
||||
skippedExisting.length
|
||||
? `${PALETTE.midGray('▌')} ${PALETTE.midGray(
|
||||
'Skipped (already configured):'
|
||||
)} ${this.formatToolNames(skippedExisting)}`
|
||||
: null,
|
||||
skipped.length
|
||||
? `${PALETTE.darkGray('▌')} ${PALETTE.darkGray(
|
||||
'Skipped:'
|
||||
)} ${this.formatToolNames(skipped)}`
|
||||
: null,
|
||||
created.length ? `${PALETTE.white('▌')} ${PALETTE.white('Created:')} ${this.formatToolNames(created)}` : null,
|
||||
refreshed.length ? `${PALETTE.lightGray('▌')} ${PALETTE.lightGray('Refreshed:')} ${this.formatToolNames(refreshed)}` : null,
|
||||
skippedExisting.length ? `${PALETTE.midGray('▌')} ${PALETTE.midGray('Skipped (already configured):')} ${this.formatToolNames(skippedExisting)}` : null,
|
||||
skipped.length ? `${PALETTE.darkGray('▌')} ${PALETTE.darkGray('Skipped:')} ${this.formatToolNames(skipped)}` : null
|
||||
].filter((line): line is string => Boolean(line));
|
||||
for (const line of summaryLines) {
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(
|
||||
PALETTE.midGray(
|
||||
'Use `openspec update` to refresh shared OpenSpec instructions in the future.'
|
||||
)
|
||||
);
|
||||
console.log(PALETTE.midGray('Use `openspec update` to refresh shared OpenSpec instructions in the future.'));
|
||||
|
||||
// Get the selected tool name(s) for display
|
||||
const toolName = this.formatToolNames(selectedTools);
|
||||
|
||||
console.log();
|
||||
console.log(`Next steps - Copy these prompts to ${toolName}:`);
|
||||
console.log(
|
||||
chalk.gray('────────────────────────────────────────────────────────────')
|
||||
);
|
||||
console.log(chalk.gray('────────────────────────────────────────────────────────────'));
|
||||
console.log(PALETTE.white('1. Populate your project context:'));
|
||||
console.log(
|
||||
PALETTE.lightGray(
|
||||
' "Please read openspec/project.md and help me fill it out'
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
PALETTE.lightGray(
|
||||
' with details about my project, tech stack, and conventions"\n'
|
||||
)
|
||||
);
|
||||
console.log(PALETTE.lightGray(' "Please read openspec/project.md and help me fill it out'));
|
||||
console.log(PALETTE.lightGray(' with details about my project, tech stack, and conventions"\n'));
|
||||
console.log(PALETTE.white('2. Create your first change proposal:'));
|
||||
console.log(
|
||||
PALETTE.lightGray(
|
||||
' "I want to add [YOUR FEATURE HERE]. Please create an'
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
PALETTE.lightGray(' OpenSpec change proposal for this feature"\n')
|
||||
);
|
||||
console.log(PALETTE.lightGray(' "I want to add [YOUR FEATURE HERE]. Please create an'));
|
||||
console.log(PALETTE.lightGray(' OpenSpec change proposal for this feature"\n'));
|
||||
console.log(PALETTE.white('3. Learn the OpenSpec workflow:'));
|
||||
console.log(
|
||||
PALETTE.lightGray(
|
||||
' "Please explain the OpenSpec workflow from openspec/AGENTS.md'
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
PALETTE.lightGray(' and how I should work with you on this project"')
|
||||
);
|
||||
console.log(
|
||||
PALETTE.darkGray(
|
||||
'────────────────────────────────────────────────────────────\n'
|
||||
)
|
||||
);
|
||||
console.log(PALETTE.lightGray(' "Please explain the OpenSpec workflow from openspec/AGENTS.md'));
|
||||
console.log(PALETTE.lightGray(' and how I should work with you on this project"'));
|
||||
console.log(PALETTE.darkGray('────────────────────────────────────────────────────────────\n'));
|
||||
}
|
||||
|
||||
private formatToolNames(tools: AIToolOption[]): string {
|
||||
@@ -599,9 +510,7 @@ export class InitCommand {
|
||||
const base = names.slice(0, -1).map((name) => PALETTE.white(name));
|
||||
const last = PALETTE.white(names[names.length - 1]);
|
||||
|
||||
return `${base.join(PALETTE.midGray(', '))}${
|
||||
base.length ? PALETTE.midGray(', and ') : ''
|
||||
}${last}`;
|
||||
return `${base.join(PALETTE.midGray(', '))}${base.length ? PALETTE.midGray(', and ') : ''}${last}`;
|
||||
}
|
||||
|
||||
private renderBanner(_extendMode: boolean): void {
|
||||
@@ -618,7 +527,7 @@ export class InitCommand {
|
||||
PALETTE.lightGray,
|
||||
PALETTE.midGray,
|
||||
PALETTE.lightGray,
|
||||
PALETTE.white,
|
||||
PALETTE.white
|
||||
];
|
||||
|
||||
console.log();
|
||||
@@ -635,7 +544,7 @@ export class InitCommand {
|
||||
text,
|
||||
stream: process.stdout,
|
||||
color: 'gray',
|
||||
spinner: PROGRESS_SPINNER,
|
||||
spinner: PROGRESS_SPINNER
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,20 +47,18 @@ Skip proposal for:
|
||||
4. Run \`openspec validate <id> --strict\` and resolve any issues before sharing the proposal.
|
||||
|
||||
### Stage 2: Implementing Changes
|
||||
Track these steps as TODOs and complete them one by one.
|
||||
1. **Read proposal.md** - Understand what's being built
|
||||
2. **Read design.md** (if exists) - Review technical decisions
|
||||
3. **Read tasks.md** - Get implementation checklist
|
||||
4. **Implement tasks sequentially** - Complete in order
|
||||
5. **Confirm completion** - Ensure every item in \`tasks.md\` is finished before updating statuses
|
||||
6. **Update checklist** - After all work is done, set every task to \`- [x]\` so the list reflects reality
|
||||
7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
|
||||
5. **Mark complete immediately** - Update \`- [x]\` after each task
|
||||
6. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
|
||||
|
||||
### Stage 3: Archiving Changes
|
||||
After deployment, create separate PR to:
|
||||
- Move \`changes/[name]/\` → \`changes/archive/YYYY-MM-DD-[name]/\`
|
||||
- Update \`specs/\` if capabilities changed
|
||||
- Use \`openspec archive [change] --skip-specs --yes\` for tooling-only changes
|
||||
- Use \`openspec archive [change] --skip-specs\` for tooling-only changes
|
||||
- Run \`openspec validate --strict\` to confirm the archived change passes checks
|
||||
|
||||
## Before Any Task
|
||||
@@ -97,7 +95,7 @@ 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] # Archive after deployment
|
||||
|
||||
# Project management
|
||||
openspec init [path] # Initialize OpenSpec
|
||||
@@ -119,7 +117,6 @@ openspec validate [change] --strict
|
||||
- \`--strict\` - Comprehensive validation
|
||||
- \`--no-interactive\` - Disable prompts
|
||||
- \`--skip-specs\` - Archive without spec updates
|
||||
- \`--yes\`/\`-y\` - Skip confirmation prompts (non-interactive archive)
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -450,7 +447,7 @@ 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] # Mark complete
|
||||
\`\`\`
|
||||
|
||||
Remember: Specs are truth. Changes are proposals. Keep them in sync.
|
||||
|
||||
@@ -22,19 +22,17 @@ const proposalReferences = `**Reference**
|
||||
- Explore the codebase with \`rg <keyword>\`, \`ls\`, or direct file reads so proposals align with current implementation realities.`;
|
||||
|
||||
const applySteps = `**Steps**
|
||||
Track these steps as TODOs and complete them one by one.
|
||||
1. Read \`changes/<id>/proposal.md\`, \`design.md\` (if present), and \`tasks.md\` to confirm scope and acceptance criteria.
|
||||
2. Work through tasks sequentially, keeping edits minimal and focused on the requested change.
|
||||
3. Confirm completion before updating statuses—make sure every item in \`tasks.md\` is finished.
|
||||
4. Update the checklist after all work is done so each task is marked \`- [x]\` and reflects reality.
|
||||
5. Reference \`openspec list\` or \`openspec show <item>\` when additional context is required.`;
|
||||
3. Mark each task \`- [x]\` immediately after completing it to keep the checklist in sync.
|
||||
4. Reference \`openspec list\` or \`openspec show <item>\` when additional context is required.`;
|
||||
|
||||
const applyReferences = `**Reference**
|
||||
- Use \`openspec show <id> --json --deltas-only\` if you need additional context from the proposal while implementing.`;
|
||||
|
||||
const archiveSteps = `**Steps**
|
||||
1. 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).
|
||||
2. Run \`openspec archive <id>\` to let the CLI move the change and apply spec updates (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.`;
|
||||
|
||||
|
||||
@@ -331,8 +331,7 @@ export class Validator {
|
||||
}
|
||||
|
||||
private extractNameFromPath(filePath: string): string {
|
||||
const normalizedPath = filePath.replaceAll('\\', '/');
|
||||
const parts = normalizedPath.split('/');
|
||||
const parts = filePath.split('/');
|
||||
|
||||
// Look for the directory name after 'specs' or 'changes'
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
@@ -344,9 +343,8 @@ export class Validator {
|
||||
}
|
||||
|
||||
// Fallback to filename without extension if not in expected structure
|
||||
const fileName = parts[parts.length - 1] ?? '';
|
||||
const dotIndex = fileName.lastIndexOf('.');
|
||||
return dotIndex > 0 ? fileName.slice(0, dotIndex) : fileName;
|
||||
const fileName = parts[parts.length - 1];
|
||||
return fileName.replace('.md', '');
|
||||
}
|
||||
|
||||
private createReport(issues: ValidationIssue[]): ValidationReport {
|
||||
@@ -395,4 +393,4 @@ export class Validator {
|
||||
const matches = blockRaw.match(/^####\s+/gm);
|
||||
return matches ? matches.length : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { afterAll, describe, it, expect } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { runCLI, cliProjectRoot } from '../helpers/run-cli.js';
|
||||
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
async function prepareFixture(fixtureName: string): Promise<string> {
|
||||
const base = await fs.mkdtemp(path.join(tmpdir(), 'openspec-cli-e2e-'));
|
||||
tempRoots.push(base);
|
||||
const projectDir = path.join(base, 'project');
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
const fixtureDir = path.join(cliProjectRoot, 'test', 'fixtures', fixtureName);
|
||||
await fs.cp(fixtureDir, projectDir, { recursive: true });
|
||||
return projectDir;
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(tempRoots.map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe('openspec CLI e2e basics', () => {
|
||||
it('shows help output', async () => {
|
||||
const result = await runCLI(['--help']);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain('Usage: openspec');
|
||||
expect(result.stderr).toBe('');
|
||||
});
|
||||
|
||||
it('reports the package version', async () => {
|
||||
const pkgRaw = await fs.readFile(path.join(cliProjectRoot, 'package.json'), 'utf-8');
|
||||
const pkg = JSON.parse(pkgRaw);
|
||||
const result = await runCLI(['--version']);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout.trim()).toBe(pkg.version);
|
||||
});
|
||||
|
||||
it('validates the tmp-init fixture with --all --json', async () => {
|
||||
const projectDir = await prepareFixture('tmp-init');
|
||||
const result = await runCLI(['validate', '--all', '--json'], { cwd: projectDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
const output = result.stdout.trim();
|
||||
expect(output).not.toBe('');
|
||||
const json = JSON.parse(output);
|
||||
expect(json.summary?.totals?.failed).toBe(0);
|
||||
expect(json.items.some((item: any) => item.id === 'c1' && item.type === 'change')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns an error for unknown items in the fixture', async () => {
|
||||
const projectDir = await prepareFixture('tmp-init');
|
||||
const result = await runCLI(['validate', 'does-not-exist'], { cwd: projectDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain("Unknown item 'does-not-exist'");
|
||||
});
|
||||
});
|
||||
@@ -1,33 +1,31 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { runCLI } from '../helpers/run-cli.js';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
describe('top-level validate command', () => {
|
||||
const projectRoot = process.cwd();
|
||||
const testDir = path.join(projectRoot, 'test-validate-command-tmp');
|
||||
const changesDir = path.join(testDir, 'openspec', 'changes');
|
||||
const specsDir = path.join(testDir, 'openspec', 'specs');
|
||||
const bin = path.join(projectRoot, 'bin', 'openspec.js');
|
||||
|
||||
|
||||
beforeEach(async () => {
|
||||
await fs.mkdir(changesDir, { recursive: true });
|
||||
await fs.mkdir(specsDir, { recursive: true });
|
||||
|
||||
// Create a valid spec
|
||||
const specContent = [
|
||||
'## Purpose',
|
||||
'This spec ensures the validation harness exercises a deterministic alpha module for automated tests.',
|
||||
'',
|
||||
'## Requirements',
|
||||
'',
|
||||
'### Requirement: Alpha module SHALL produce deterministic output',
|
||||
'The alpha module SHALL produce a deterministic response for validation.',
|
||||
'',
|
||||
'#### Scenario: Deterministic alpha run',
|
||||
'- **GIVEN** a configured alpha module',
|
||||
'- **WHEN** the module runs the default flow',
|
||||
'- **THEN** the output matches the expected fixture result',
|
||||
].join('\n');
|
||||
const specContent = `## Purpose
|
||||
Valid spec for testing.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Foo
|
||||
Text
|
||||
|
||||
#### Scenario: Bar
|
||||
Given A\nWhen B\nThen C`;
|
||||
await fs.mkdir(path.join(specsDir, 'alpha'), { recursive: true });
|
||||
await fs.writeFile(path.join(specsDir, 'alpha', 'spec.md'), specContent, 'utf-8');
|
||||
|
||||
@@ -35,26 +33,10 @@ describe('top-level validate command', () => {
|
||||
const changeContent = `# Test Change\n\n## Why\nBecause reasons that are sufficiently long for validation.\n\n## What Changes\n- **alpha:** Add something`;
|
||||
await fs.mkdir(path.join(changesDir, 'c1'), { recursive: true });
|
||||
await fs.writeFile(path.join(changesDir, 'c1', 'proposal.md'), changeContent, 'utf-8');
|
||||
const deltaContent = [
|
||||
'## ADDED Requirements',
|
||||
'### Requirement: Validator SHALL support alpha change deltas',
|
||||
'The validator SHALL accept deltas provided by the test harness.',
|
||||
'',
|
||||
'#### Scenario: Apply alpha delta',
|
||||
'- **GIVEN** the test change delta',
|
||||
'- **WHEN** openspec validate runs',
|
||||
'- **THEN** the validator reports the change as valid',
|
||||
].join('\n');
|
||||
const c1DeltaDir = path.join(changesDir, 'c1', 'specs', 'alpha');
|
||||
await fs.mkdir(c1DeltaDir, { recursive: true });
|
||||
await fs.writeFile(path.join(c1DeltaDir, 'spec.md'), deltaContent, 'utf-8');
|
||||
|
||||
// Duplicate name for ambiguity test
|
||||
await fs.mkdir(path.join(changesDir, 'dup'), { recursive: true });
|
||||
await fs.writeFile(path.join(changesDir, 'dup', 'proposal.md'), changeContent, 'utf-8');
|
||||
const dupDeltaDir = path.join(changesDir, 'dup', 'specs', 'dup');
|
||||
await fs.mkdir(dupDeltaDir, { recursive: true });
|
||||
await fs.writeFile(path.join(dupDeltaDir, 'spec.md'), deltaContent, 'utf-8');
|
||||
await fs.mkdir(path.join(specsDir, 'dup'), { recursive: true });
|
||||
await fs.writeFile(path.join(specsDir, 'dup', 'spec.md'), specContent, 'utf-8');
|
||||
});
|
||||
@@ -63,36 +45,77 @@ describe('top-level validate command', () => {
|
||||
await fs.rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('prints a helpful hint when no args in non-interactive mode', async () => {
|
||||
const result = await runCLI(['validate'], { cwd: testDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain('Nothing to validate. Try one of:');
|
||||
it('prints a helpful hint when no args in non-interactive mode', () => {
|
||||
const originalCwd = process.cwd();
|
||||
const originalEnv = { ...process.env };
|
||||
try {
|
||||
process.chdir(testDir);
|
||||
process.env.OPEN_SPEC_INTERACTIVE = '0';
|
||||
let err: any;
|
||||
try {
|
||||
execSync(`node ${bin} validate`, { encoding: 'utf-8' });
|
||||
} catch (e) { err = e; }
|
||||
expect(err).toBeDefined();
|
||||
expect(err.status).not.toBe(0);
|
||||
expect(err.stderr.toString()).toContain('Nothing to validate. Try one of:');
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
process.env = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it('validates all with --all and outputs JSON summary', async () => {
|
||||
const result = await runCLI(['validate', '--all', '--json'], { cwd: testDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
const output = result.stdout.trim();
|
||||
expect(output).not.toBe('');
|
||||
const json = JSON.parse(output);
|
||||
expect(Array.isArray(json.items)).toBe(true);
|
||||
expect(json.summary?.totals?.items).toBeDefined();
|
||||
expect(json.version).toBe('1.0');
|
||||
it('validates all with --all and outputs JSON summary', () => {
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(testDir);
|
||||
let outStr = '';
|
||||
try {
|
||||
outStr = execSync(`node ${bin} validate --all --json`, { encoding: 'utf-8' });
|
||||
} catch (e: any) {
|
||||
// If exit code is non-zero (e.g., on failures), still parse stdout JSON
|
||||
outStr = e.stdout?.toString?.() ?? '';
|
||||
}
|
||||
const json = JSON.parse(outStr);
|
||||
expect(Array.isArray(json.items)).toBe(true);
|
||||
expect(json.summary?.totals?.items).toBeDefined();
|
||||
expect(json.version).toBe('1.0');
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
|
||||
it('validates only specs with --specs and respects --concurrency', async () => {
|
||||
const result = await runCLI(['validate', '--specs', '--json', '--concurrency', '1'], { cwd: testDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
const output = result.stdout.trim();
|
||||
expect(output).not.toBe('');
|
||||
const json = JSON.parse(output);
|
||||
expect(json.items.every((i: any) => i.type === 'spec')).toBe(true);
|
||||
it('validates only specs with --specs and respects --concurrency', () => {
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(testDir);
|
||||
let outStr = '';
|
||||
try {
|
||||
outStr = execSync(`node ${bin} validate --specs --json --concurrency 1`, { encoding: 'utf-8' });
|
||||
} catch (e: any) {
|
||||
outStr = e.stdout?.toString?.() ?? '';
|
||||
}
|
||||
const json = JSON.parse(outStr);
|
||||
// All items should be specs
|
||||
expect(json.items.every((i: any) => i.type === 'spec')).toBe(true);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
|
||||
it('errors on ambiguous item names and suggests type override', async () => {
|
||||
const result = await runCLI(['validate', 'dup'], { cwd: testDir });
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain('Ambiguous item');
|
||||
it('errors on ambiguous item names and suggests type override', () => {
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(testDir);
|
||||
let err: any;
|
||||
try {
|
||||
execSync(`node ${bin} validate dup`, { encoding: 'utf-8' });
|
||||
} catch (e) { err = e; }
|
||||
expect(err).toBeDefined();
|
||||
expect(err.stderr.toString()).toContain('Ambiguous item');
|
||||
expect(err.status).not.toBe(0);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts change proposals saved with CRLF line endings', async () => {
|
||||
@@ -118,7 +141,6 @@ describe('top-level validate command', () => {
|
||||
'The parser SHALL accept CRLF change proposals without manual edits.',
|
||||
'',
|
||||
'#### Scenario: Validate CRLF change',
|
||||
'- **GIVEN** a change proposal saved with CRLF line endings',
|
||||
'- **WHEN** a developer runs openspec validate on the proposal',
|
||||
'- **THEN** validation succeeds without section errors',
|
||||
]);
|
||||
@@ -127,7 +149,14 @@ describe('top-level validate command', () => {
|
||||
await fs.mkdir(deltaDir, { recursive: true });
|
||||
await fs.writeFile(path.join(deltaDir, 'spec.md'), deltaContent, 'utf-8');
|
||||
|
||||
const result = await runCLI(['validate', changeId], { cwd: testDir });
|
||||
expect(result.exitCode).toBe(0);
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(testDir);
|
||||
expect(() => execSync(`node ${bin} validate ${changeId}`, { encoding: 'utf-8' })).not.toThrow();
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
+41
-134
@@ -43,7 +43,7 @@ describe('InitCommand', () => {
|
||||
selectionQueue = [];
|
||||
mockPrompt.mockReset();
|
||||
initCommand = new InitCommand({ prompt: mockPrompt });
|
||||
|
||||
|
||||
// Mock console.log to suppress output during tests
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
@@ -56,20 +56,14 @@ describe('InitCommand', () => {
|
||||
describe('execute', () => {
|
||||
it('should create OpenSpec directory structure', async () => {
|
||||
queueSelections('claude', DONE);
|
||||
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
|
||||
const openspecPath = path.join(testDir, 'openspec');
|
||||
expect(await directoryExists(openspecPath)).toBe(true);
|
||||
expect(await directoryExists(path.join(openspecPath, 'specs'))).toBe(
|
||||
true
|
||||
);
|
||||
expect(await directoryExists(path.join(openspecPath, 'changes'))).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
await directoryExists(path.join(openspecPath, 'changes', 'archive'))
|
||||
).toBe(true);
|
||||
expect(await directoryExists(path.join(openspecPath, 'specs'))).toBe(true);
|
||||
expect(await directoryExists(path.join(openspecPath, 'changes'))).toBe(true);
|
||||
expect(await directoryExists(path.join(openspecPath, 'changes', 'archive'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should create AGENTS.md and project.md', async () => {
|
||||
@@ -79,31 +73,23 @@ describe('InitCommand', () => {
|
||||
|
||||
const openspecPath = path.join(testDir, 'openspec');
|
||||
expect(await fileExists(path.join(openspecPath, 'AGENTS.md'))).toBe(true);
|
||||
expect(await fileExists(path.join(openspecPath, 'project.md'))).toBe(
|
||||
true
|
||||
);
|
||||
expect(await fileExists(path.join(openspecPath, 'project.md'))).toBe(true);
|
||||
|
||||
const agentsContent = await fs.readFile(
|
||||
path.join(openspecPath, 'AGENTS.md'),
|
||||
'utf-8'
|
||||
);
|
||||
const agentsContent = await fs.readFile(path.join(openspecPath, 'AGENTS.md'), 'utf-8');
|
||||
expect(agentsContent).toContain('OpenSpec Instructions');
|
||||
|
||||
const projectContent = await fs.readFile(
|
||||
path.join(openspecPath, 'project.md'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const projectContent = await fs.readFile(path.join(openspecPath, 'project.md'), 'utf-8');
|
||||
expect(projectContent).toContain('Project Context');
|
||||
});
|
||||
|
||||
it('should create CLAUDE.md when Claude Code is selected', async () => {
|
||||
queueSelections('claude', DONE);
|
||||
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
|
||||
const claudePath = path.join(testDir, 'CLAUDE.md');
|
||||
expect(await fileExists(claudePath)).toBe(true);
|
||||
|
||||
|
||||
const content = await fs.readFile(claudePath, 'utf-8');
|
||||
expect(content).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(content).toContain('OpenSpec Instructions');
|
||||
@@ -112,14 +98,13 @@ describe('InitCommand', () => {
|
||||
|
||||
it('should update existing CLAUDE.md with markers', async () => {
|
||||
queueSelections('claude', DONE);
|
||||
|
||||
|
||||
const claudePath = path.join(testDir, 'CLAUDE.md');
|
||||
const existingContent =
|
||||
'# My Project Instructions\nCustom instructions here';
|
||||
const existingContent = '# My Project Instructions\nCustom instructions here';
|
||||
await fs.writeFile(claudePath, existingContent);
|
||||
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
|
||||
const updatedContent = await fs.readFile(claudePath, 'utf-8');
|
||||
expect(updatedContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(updatedContent).toContain('OpenSpec Instructions');
|
||||
@@ -149,18 +134,9 @@ describe('InitCommand', () => {
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const claudeProposal = path.join(
|
||||
testDir,
|
||||
'.claude/commands/openspec/proposal.md'
|
||||
);
|
||||
const claudeApply = path.join(
|
||||
testDir,
|
||||
'.claude/commands/openspec/apply.md'
|
||||
);
|
||||
const claudeArchive = path.join(
|
||||
testDir,
|
||||
'.claude/commands/openspec/archive.md'
|
||||
);
|
||||
const claudeProposal = path.join(testDir, '.claude/commands/openspec/proposal.md');
|
||||
const claudeApply = path.join(testDir, '.claude/commands/openspec/apply.md');
|
||||
const claudeArchive = path.join(testDir, '.claude/commands/openspec/archive.md');
|
||||
|
||||
expect(await fileExists(claudeProposal)).toBe(true);
|
||||
expect(await fileExists(claudeApply)).toBe(true);
|
||||
@@ -178,9 +154,7 @@ describe('InitCommand', () => {
|
||||
const archiveContent = await fs.readFile(claudeArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('name: OpenSpec: Archive');
|
||||
expect(archiveContent).toContain('openspec archive <id>');
|
||||
expect(archiveContent).toContain(
|
||||
'`--skip-specs` only for tooling-only work'
|
||||
);
|
||||
expect(archiveContent).toContain('`--skip-specs` only for tooling-only work');
|
||||
});
|
||||
|
||||
it('should create Cursor slash command files with templates', async () => {
|
||||
@@ -188,18 +162,9 @@ describe('InitCommand', () => {
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const cursorProposal = path.join(
|
||||
testDir,
|
||||
'.cursor/commands/openspec-proposal.md'
|
||||
);
|
||||
const cursorApply = path.join(
|
||||
testDir,
|
||||
'.cursor/commands/openspec-apply.md'
|
||||
);
|
||||
const cursorArchive = path.join(
|
||||
testDir,
|
||||
'.cursor/commands/openspec-archive.md'
|
||||
);
|
||||
const cursorProposal = path.join(testDir, '.cursor/commands/openspec-proposal.md');
|
||||
const cursorApply = path.join(testDir, '.cursor/commands/openspec-apply.md');
|
||||
const cursorArchive = path.join(testDir, '.cursor/commands/openspec-archive.md');
|
||||
|
||||
expect(await fileExists(cursorProposal)).toBe(true);
|
||||
expect(await fileExists(cursorApply)).toBe(true);
|
||||
@@ -218,76 +183,27 @@ describe('InitCommand', () => {
|
||||
expect(archiveContent).toContain('openspec list --specs');
|
||||
});
|
||||
|
||||
it('should create OpenCode slash command files with templates', async () => {
|
||||
queueSelections('opencode', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const openCodeProposal = path.join(
|
||||
testDir,
|
||||
'.opencode/command/openspec-proposal.md'
|
||||
);
|
||||
const openCodeApply = path.join(
|
||||
testDir,
|
||||
'.opencode/command/openspec-apply.md'
|
||||
);
|
||||
const openCodeArchive = path.join(
|
||||
testDir,
|
||||
'.opencode/command/openspec-archive.md'
|
||||
);
|
||||
|
||||
expect(await fileExists(openCodeProposal)).toBe(true);
|
||||
expect(await fileExists(openCodeApply)).toBe(true);
|
||||
expect(await fileExists(openCodeArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(openCodeProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('agent: build');
|
||||
expect(proposalContent).toContain(
|
||||
'description: Scaffold a new OpenSpec change and validate strictly.'
|
||||
);
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
|
||||
const applyContent = await fs.readFile(openCodeApply, 'utf-8');
|
||||
expect(applyContent).toContain('agent: build');
|
||||
expect(applyContent).toContain(
|
||||
'description: Implement an approved OpenSpec change and keep tasks in sync.'
|
||||
);
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
|
||||
const archiveContent = await fs.readFile(openCodeArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('agent: build');
|
||||
expect(archiveContent).toContain(
|
||||
'description: Archive a deployed OpenSpec change and update specs.'
|
||||
);
|
||||
expect(archiveContent).toContain('openspec list --specs');
|
||||
});
|
||||
|
||||
it('should add new tool when OpenSpec already exists', async () => {
|
||||
queueSelections('claude', DONE, 'cursor', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const cursorProposal = path.join(
|
||||
testDir,
|
||||
'.cursor/commands/openspec-proposal.md'
|
||||
);
|
||||
const cursorProposal = path.join(testDir, '.cursor/commands/openspec-proposal.md');
|
||||
expect(await fileExists(cursorProposal)).toBe(true);
|
||||
});
|
||||
|
||||
it('should error when extend mode selects no tools', async () => {
|
||||
queueSelections('claude', DONE, DONE);
|
||||
await initCommand.execute(testDir);
|
||||
await expect(initCommand.execute(testDir)).rejects.toThrow(
|
||||
/OpenSpec seems to already be initialized/
|
||||
);
|
||||
await expect(initCommand.execute(testDir)).rejects.toThrow(/OpenSpec seems to already be initialized/);
|
||||
});
|
||||
|
||||
it('should handle non-existent target directory', async () => {
|
||||
queueSelections('claude', DONE);
|
||||
|
||||
|
||||
const newDir = path.join(testDir, 'new-project');
|
||||
await initCommand.execute(newDir);
|
||||
|
||||
|
||||
const openspecPath = path.join(newDir, 'openspec');
|
||||
expect(await directoryExists(openspecPath)).toBe(true);
|
||||
});
|
||||
@@ -295,9 +211,9 @@ describe('InitCommand', () => {
|
||||
it('should display success message with selected tool name', async () => {
|
||||
queueSelections('claude', DONE);
|
||||
const logSpy = vi.spyOn(console, 'log');
|
||||
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
|
||||
const calls = logSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain('Copy these prompts to Claude Code');
|
||||
});
|
||||
@@ -309,9 +225,7 @@ describe('InitCommand', () => {
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const calls = logSpy.mock.calls.flat().join('\n');
|
||||
expect(calls).toContain(
|
||||
'Copy these prompts to your AGENTS.md-compatible assistant'
|
||||
);
|
||||
expect(calls).toContain('Copy these prompts to your AGENTS.md-compatible assistant');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -323,7 +237,7 @@ describe('InitCommand', () => {
|
||||
|
||||
expect(mockPrompt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseMessage: expect.stringContaining('Which AI tools do you use?'),
|
||||
baseMessage: expect.stringContaining('Which AI tools do you use?')
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -333,7 +247,7 @@ describe('InitCommand', () => {
|
||||
queueSelections('claude', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
|
||||
// When other tools are added, we'd test their specific configurations here
|
||||
const claudePath = path.join(testDir, 'CLAUDE.md');
|
||||
expect(await fileExists(claudePath)).toBe(true);
|
||||
@@ -345,9 +259,7 @@ describe('InitCommand', () => {
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const secondRunArgs = mockPrompt.mock.calls[1][0];
|
||||
const claudeChoice = secondRunArgs.choices.find(
|
||||
(choice: any) => choice.value === 'claude'
|
||||
);
|
||||
const claudeChoice = secondRunArgs.choices.find((choice: any) => choice.value === 'claude');
|
||||
expect(claudeChoice.configured).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -357,21 +269,16 @@ describe('InitCommand', () => {
|
||||
// This is tricky to test cross-platform, but we can test the error message
|
||||
const readOnlyDir = path.join(testDir, 'readonly');
|
||||
await fs.mkdir(readOnlyDir);
|
||||
|
||||
|
||||
// Mock the permission check to fail
|
||||
const originalCheck = fs.writeFile;
|
||||
vi.spyOn(fs, 'writeFile').mockImplementation(
|
||||
async (filePath: any, ...args: any[]) => {
|
||||
if (
|
||||
typeof filePath === 'string' &&
|
||||
filePath.includes('.openspec-test-')
|
||||
) {
|
||||
throw new Error('EACCES: permission denied');
|
||||
}
|
||||
return originalCheck.call(fs, filePath, ...args);
|
||||
vi.spyOn(fs, 'writeFile').mockImplementation(async (filePath: any, ...args: any[]) => {
|
||||
if (typeof filePath === 'string' && filePath.includes('.openspec-test-')) {
|
||||
throw new Error('EACCES: permission denied');
|
||||
}
|
||||
);
|
||||
|
||||
return originalCheck.call(fs, filePath, ...args);
|
||||
});
|
||||
|
||||
queueSelections('claude', DONE);
|
||||
await expect(initCommand.execute(readOnlyDir)).rejects.toThrow(
|
||||
/Insufficient permissions/
|
||||
|
||||
+31
-113
@@ -15,11 +15,11 @@ describe('UpdateCommand', () => {
|
||||
// Create a temporary test directory
|
||||
testDir = path.join(os.tmpdir(), `openspec-test-${randomUUID()}`);
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
|
||||
|
||||
// Create openspec directory
|
||||
const openspecDir = path.join(testDir, 'openspec');
|
||||
await fs.mkdir(openspecDir, { recursive: true });
|
||||
|
||||
|
||||
updateCommand = new UpdateCommand();
|
||||
});
|
||||
|
||||
@@ -43,7 +43,7 @@ More content after.`;
|
||||
await fs.writeFile(claudePath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
|
||||
// Execute update command
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
@@ -54,22 +54,17 @@ More content after.`;
|
||||
expect(updatedContent).toContain('OpenSpec Instructions');
|
||||
expect(updatedContent).toContain('Some existing content here');
|
||||
expect(updatedContent).toContain('More content after');
|
||||
|
||||
|
||||
// Check console output
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md');
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain('Updated AI tool files: CLAUDE.md');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should refresh existing Claude slash command files', async () => {
|
||||
const proposalPath = path.join(
|
||||
testDir,
|
||||
'.claude/commands/openspec/proposal.md'
|
||||
);
|
||||
const proposalPath = path.join(testDir, '.claude/commands/openspec/proposal.md');
|
||||
await fs.mkdir(path.dirname(proposalPath), { recursive: true });
|
||||
const initialContent = `---
|
||||
name: OpenSpec: Proposal
|
||||
@@ -89,19 +84,13 @@ Old slash content
|
||||
const updated = await fs.readFile(proposalPath, 'utf-8');
|
||||
expect(updated).toContain('name: OpenSpec: Proposal');
|
||||
expect(updated).toContain('**Guardrails**');
|
||||
expect(updated).toContain(
|
||||
'Validate with `openspec validate <id> --strict`'
|
||||
);
|
||||
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('Updated OpenSpec instructions (openspec/AGENTS.md');
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain(
|
||||
'Updated slash commands: .claude/commands/openspec/proposal.md'
|
||||
);
|
||||
expect(logMessage).toContain('Updated slash commands: .claude/commands/openspec/proposal.md');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
@@ -109,7 +98,7 @@ Old slash content
|
||||
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');
|
||||
|
||||
|
||||
// Execute update command
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
@@ -142,51 +131,9 @@ Old body
|
||||
expect(updated).not.toContain('Old body');
|
||||
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md');
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain(
|
||||
'Updated slash commands: .cursor/commands/openspec-apply.md'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should refresh existing OpenCode slash command files', async () => {
|
||||
const openCodePath = path.join(
|
||||
testDir,
|
||||
'.opencode/command/openspec-apply.md'
|
||||
);
|
||||
await fs.mkdir(path.dirname(openCodePath), { recursive: true });
|
||||
const initialContent = `---
|
||||
name: /openspec-apply
|
||||
id: openspec-apply
|
||||
category: OpenSpec
|
||||
description: Old description
|
||||
---
|
||||
<!-- OPENSPEC:START -->
|
||||
Old body
|
||||
<!-- OPENSPEC:END -->`;
|
||||
await fs.writeFile(openCodePath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updated = await fs.readFile(openCodePath, 'utf-8');
|
||||
expect(updated).toContain('id: openspec-apply');
|
||||
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: .opencode/command/openspec-apply.md'
|
||||
);
|
||||
expect(logMessage).toContain('Updated slash commands: .cursor/commands/openspec-apply.md');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
@@ -198,9 +145,7 @@ Old body
|
||||
|
||||
// Should only update OpenSpec instructions
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md');
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
@@ -212,33 +157,23 @@ Old body
|
||||
// For now, we test with just CLAUDE.md.
|
||||
const claudePath = path.join(testDir, 'CLAUDE.md');
|
||||
await fs.mkdir(path.dirname(claudePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
claudePath,
|
||||
'<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->'
|
||||
);
|
||||
await fs.writeFile(claudePath, '<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->');
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
// Should report updating with new format
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md');
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain('Updated AI tool files: CLAUDE.md');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should skip creating missing slash commands during update', async () => {
|
||||
const proposalPath = path.join(
|
||||
testDir,
|
||||
'.claude/commands/openspec/proposal.md'
|
||||
);
|
||||
const proposalPath = path.join(testDir, '.claude/commands/openspec/proposal.md');
|
||||
await fs.mkdir(path.dirname(proposalPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
proposalPath,
|
||||
`---
|
||||
await fs.writeFile(proposalPath, `---
|
||||
name: OpenSpec: Proposal
|
||||
description: Existing file
|
||||
category: OpenSpec
|
||||
@@ -246,17 +181,12 @@ tags: [openspec, change]
|
||||
---
|
||||
<!-- OPENSPEC:START -->
|
||||
Old content
|
||||
<!-- OPENSPEC:END -->`
|
||||
);
|
||||
<!-- OPENSPEC:END -->`);
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const applyExists = await FileSystemUtils.fileExists(
|
||||
path.join(testDir, '.claude/commands/openspec/apply.md')
|
||||
);
|
||||
const archiveExists = await FileSystemUtils.fileExists(
|
||||
path.join(testDir, '.claude/commands/openspec/archive.md')
|
||||
);
|
||||
const applyExists = await FileSystemUtils.fileExists(path.join(testDir, '.claude/commands/openspec/apply.md'));
|
||||
const archiveExists = await FileSystemUtils.fileExists(path.join(testDir, '.claude/commands/openspec/archive.md'));
|
||||
|
||||
expect(applyExists).toBe(false);
|
||||
expect(archiveExists).toBe(false);
|
||||
@@ -265,7 +195,7 @@ Old content
|
||||
it('should never create new AI tool files', async () => {
|
||||
// Get all configurators
|
||||
const configurators = ToolRegistry.getAll();
|
||||
|
||||
|
||||
// Execute update command
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
@@ -323,9 +253,7 @@ Old content
|
||||
expect(updated).not.toContain('Old content');
|
||||
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md, AGENTS.md)'
|
||||
);
|
||||
expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md, AGENTS.md)');
|
||||
expect(logMessage).not.toContain('AGENTS.md (created)');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
@@ -333,10 +261,7 @@ Old content
|
||||
|
||||
it('should throw error if openspec directory does not exist', async () => {
|
||||
// Remove openspec directory
|
||||
await fs.rm(path.join(testDir, 'openspec'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
await fs.rm(path.join(testDir, 'openspec'), { recursive: true, force: true });
|
||||
|
||||
// Execute update command and expect error
|
||||
await expect(updateCommand.execute(testDir)).rejects.toThrow(
|
||||
@@ -347,24 +272,19 @@ Old content
|
||||
it('should handle configurator errors gracefully', async () => {
|
||||
// Create CLAUDE.md file but make it read-only to cause an error
|
||||
const claudePath = path.join(testDir, 'CLAUDE.md');
|
||||
await fs.writeFile(
|
||||
claudePath,
|
||||
'<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->'
|
||||
);
|
||||
await fs.writeFile(claudePath, '<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->');
|
||||
await fs.chmod(claudePath, 0o444); // Read-only
|
||||
|
||||
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('CLAUDE.md')) {
|
||||
throw new Error('EACCES: permission denied, open');
|
||||
}
|
||||
const writeSpy = vi.spyOn(FileSystemUtils, 'writeFile').mockImplementation(async (filePath, content) => {
|
||||
if (filePath.endsWith('CLAUDE.md')) {
|
||||
throw new Error('EACCES: permission denied, open');
|
||||
}
|
||||
|
||||
return originalWriteFile(filePath, content);
|
||||
});
|
||||
return originalWriteFile(filePath, content);
|
||||
});
|
||||
|
||||
// Execute update command - should not throw
|
||||
await updateCommand.execute(testDir);
|
||||
@@ -372,9 +292,7 @@ Old content
|
||||
// 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('Updated OpenSpec instructions (openspec/AGENTS.md');
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain('Failed to update: CLAUDE.md');
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# Test Change
|
||||
|
||||
## Why
|
||||
Because reasons that are sufficiently long for validation.
|
||||
|
||||
## What Changes
|
||||
- **alpha:** Add something
|
||||
@@ -1,8 +0,0 @@
|
||||
## ADDED Requirements
|
||||
### Requirement: Parser SHALL accept CRLF change proposals
|
||||
The parser SHALL accept CRLF change proposals without manual edits.
|
||||
|
||||
#### Scenario: Validate CRLF change
|
||||
- **GIVEN** a change proposal saved with CRLF line endings
|
||||
- **WHEN** a developer runs openspec validate on the proposal
|
||||
- **THEN** validation succeeds without section errors
|
||||
@@ -1,12 +0,0 @@
|
||||
## Purpose
|
||||
This spec ensures the validation harness exercises a deterministic alpha module for automated tests.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Alpha module SHALL produce deterministic output
|
||||
The alpha module SHALL produce a deterministic response for validation.
|
||||
|
||||
#### Scenario: Deterministic alpha run
|
||||
- **GIVEN** a configured alpha module
|
||||
- **WHEN** the module runs the default flow
|
||||
- **THEN** the output matches the expected fixture result
|
||||
@@ -1,139 +0,0 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..', '..');
|
||||
const cliEntry = path.join(projectRoot, 'dist', 'cli', 'index.js');
|
||||
|
||||
let buildPromise: Promise<void> | undefined;
|
||||
|
||||
interface RunCommandOptions {
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
interface RunCLIOptions {
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
input?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface RunCLIResult {
|
||||
exitCode: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
timedOut: boolean;
|
||||
command: string;
|
||||
}
|
||||
|
||||
function runCommand(command: string, args: string[], options: RunCommandOptions = {}) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd ?? projectRoot,
|
||||
env: { ...process.env, ...options.env },
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
|
||||
child.on('error', (error) => reject(error));
|
||||
child.on('close', (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
const reason = signal ? `signal ${signal}` : `exit code ${code}`;
|
||||
reject(new Error(`Command failed (${reason}): ${command} ${args.join(' ')}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureCliBuilt() {
|
||||
if (existsSync(cliEntry)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!buildPromise) {
|
||||
buildPromise = runCommand('pnpm', ['run', 'build']).catch((error) => {
|
||||
buildPromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
await buildPromise;
|
||||
|
||||
if (!existsSync(cliEntry)) {
|
||||
throw new Error('CLI entry point missing after build. Expected dist/cli/index.js');
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCLI(args: string[] = [], options: RunCLIOptions = {}): Promise<RunCLIResult> {
|
||||
await ensureCliBuilt();
|
||||
|
||||
const finalArgs = Array.isArray(args) ? args : [args];
|
||||
const invocation = [cliEntry, ...finalArgs].join(' ');
|
||||
|
||||
return new Promise<RunCLIResult>((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [cliEntry, ...finalArgs], {
|
||||
cwd: options.cwd ?? projectRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
OPEN_SPEC_INTERACTIVE: '0',
|
||||
...options.env,
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timedOut = false;
|
||||
|
||||
const timeout = options.timeoutMs
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill('SIGKILL');
|
||||
}, options.timeoutMs)
|
||||
: undefined;
|
||||
|
||||
child.stdout?.setEncoding('utf-8');
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
|
||||
child.stderr?.setEncoding('utf-8');
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
resolve({
|
||||
exitCode: code,
|
||||
signal,
|
||||
stdout,
|
||||
stderr,
|
||||
timedOut,
|
||||
command: `node ${invocation}`,
|
||||
});
|
||||
});
|
||||
|
||||
if (options.input && child.stdin) {
|
||||
child.stdin.end(options.input);
|
||||
} else if (child.stdin) {
|
||||
child.stdin.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const cliProjectRoot = projectRoot;
|
||||
+19
-4
@@ -1,6 +1,21 @@
|
||||
import { ensureCliBuilt } from './test/helpers/run-cli.js';
|
||||
import { execSync } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Ensure the CLI bundle exists before tests execute
|
||||
// Run once before all tests
|
||||
export async function setup() {
|
||||
await ensureCliBuilt();
|
||||
}
|
||||
const distPath = path.join(process.cwd(), 'dist', 'cli', 'index.js');
|
||||
|
||||
if (!existsSync(distPath)) {
|
||||
console.log('Building project before tests...');
|
||||
try {
|
||||
execSync('pnpm run build', {
|
||||
stdio: 'inherit',
|
||||
cwd: process.cwd()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to build project:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user