mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-04 06:18:24 +08:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0319ac3bb0 | ||
|
|
dcfb6afe0c | ||
|
|
86925b2b2d | ||
|
|
604ecb8bd1 | ||
|
|
5a4837c37d | ||
|
|
9d9539aaa2 | ||
|
|
c3fecf0619 |
+2
-1
@@ -145,4 +145,5 @@ docs/
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
CLAUDE.md
|
||||
.DS_Store
|
||||
|
||||
@@ -1,40 +1,19 @@
|
||||
<!-- OPENSPEC:START -->
|
||||
# OpenSpec Project
|
||||
# Repository Guidelines
|
||||
|
||||
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.
|
||||
## 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 project uses OpenSpec for spec-driven development. Specifications are the source of truth.
|
||||
## 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.
|
||||
|
||||
See @openspec/AGENTS.md for detailed conventions and guidelines.
|
||||
<!-- OPENSPEC:END -->
|
||||
## 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.
|
||||
|
||||
## Complexity Management
|
||||
## 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.
|
||||
|
||||
**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
|
||||
## 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.
|
||||
|
||||
**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
|
||||
## 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.
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# @fission-ai/openspec
|
||||
|
||||
## 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,6 +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/discord/1411657095639601154?logo=discord&logoColor=white&style=flat-square" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -22,7 +23,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Follow <a href="https://x.com/0xTab">@0xTab on X</a> for updates.
|
||||
Follow <a href="https://x.com/0xTab">@0xTab on X</a> for updates · Join the <a href="https://discord.gg/saTQQGQZ">OpenSpec Discord</a> for help and questions.
|
||||
</p>
|
||||
|
||||
# OpenSpec
|
||||
@@ -82,13 +83,14 @@ 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 • OpenCode • Gemini CLI • GitHub Copilot • Others |
|
||||
| Codex • Amp • Jules • Gemini CLI • GitHub Copilot • Others |
|
||||
|
||||
### Install & Initialize
|
||||
|
||||
|
||||
@@ -13,3 +13,9 @@ 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
|
||||
|
||||
@@ -12,6 +12,11 @@ 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
|
||||
|
||||
@@ -14,3 +14,7 @@
|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Update Markdown Parser CRLF Handling
|
||||
|
||||
## Problem
|
||||
Windows users report that `openspec validate` raises “Change must have a Why section” even when the section exists (see GitHub issue #77). The CLI currently splits markdown on `\n` and compares headers without stripping `\r`, so files saved with CRLF line endings keep a trailing carriage return in the header token. As a result the parser fails to detect `## Why`/`## What Changes`, triggering false validation errors and breaking the workflow on Windows-default editors.
|
||||
|
||||
## Solution
|
||||
- Normalize markdown content inside the parser so CRLF and lone-CR inputs are treated as `\n` before section detection, trimming any carriage returns from titles and content comparisons.
|
||||
- Reuse the normalized reader everywhere `MarkdownParser` is constructed to keep behavior consistent for validation, view, spec, and list flows.
|
||||
- Add regression coverage that reproduces the failure (unit test around `parseChange` and a CLI spawn/e2e test that writes a CRLF change then runs `openspec validate`).
|
||||
- Update the `cli-validate` spec to codify the expectation that required sections are recognized regardless of line-ending style.
|
||||
|
||||
## Benefits
|
||||
- Restores correct validation behavior for Windows editors without requiring manual line-ending conversion.
|
||||
- Locks in the fix with targeted tests so future parser refactors keep cross-platform support.
|
||||
- Clarifies the spec so downstream work (e.g., cross-shell e2e plan) understands the non-negotiable behavior.
|
||||
|
||||
## Risks
|
||||
- Low: parser normalization touches shared code paths that parse specs and changes; need to ensure no regressions in other command consumers (mitigated by existing parser tests plus the new CRLF fixtures).
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
## ADDED Requirements
|
||||
### 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
|
||||
@@ -0,0 +1,11 @@
|
||||
## 1. Guard the regression
|
||||
- [x] 1.1 Add a unit test that feeds a CRLF change document into `MarkdownParser.parseChange` and asserts `Why`/`What Changes` are detected.
|
||||
- [x] 1.2 Add a CLI spawn/e2e test that writes a CRLF change, runs `openspec validate`, and expects success.
|
||||
|
||||
## 2. Normalize parsing
|
||||
- [x] 2.1 Normalize line endings when constructing `MarkdownParser` so headers and content comparisons ignore `\r`.
|
||||
- [x] 2.2 Ensure all CLI entry points (validate, view, spec conversion) reuse the normalized parser path.
|
||||
|
||||
## 3. Document and verify
|
||||
- [x] 3.1 Update the `cli-validate` spec with a scenario covering CRLF line endings.
|
||||
- [x] 3.2 Run the parser and CLI test suites (`pnpm test`, relevant spawn tests) to confirm the fix.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fission-ai/openspec",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "AI-native system for spec-driven development",
|
||||
"keywords": [
|
||||
"openspec",
|
||||
|
||||
@@ -19,5 +19,6 @@ 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' }
|
||||
];
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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,6 +1,7 @@
|
||||
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();
|
||||
@@ -8,9 +9,11 @@ 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 {
|
||||
|
||||
+332
-241
@@ -8,7 +8,7 @@ import {
|
||||
isUpKey,
|
||||
useKeypress,
|
||||
usePagination,
|
||||
useState
|
||||
useState,
|
||||
} from '@inquirer/core';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
@@ -16,70 +16,33 @@ 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 = {
|
||||
@@ -87,7 +50,8 @@ 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);
|
||||
@@ -97,7 +61,7 @@ const parseToolLabel = (raw: string): ToolLabel => {
|
||||
}
|
||||
return {
|
||||
primary: match[1].trim(),
|
||||
annotation: match[2].trim()
|
||||
annotation: match[2].trim(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -118,165 +82,189 @@ 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');
|
||||
}
|
||||
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);
|
||||
useKeypress((key) => {
|
||||
if (step === 'intro') {
|
||||
if (isEnterKey(key)) {
|
||||
setStep('select');
|
||||
}
|
||||
|
||||
updateSelected(next);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEnterKey(key)) {
|
||||
if (selected.length === 0) {
|
||||
setError('Select at least one AI tool to continue.');
|
||||
if (step === 'select') {
|
||||
if (isUpKey(key)) {
|
||||
const previousIndex =
|
||||
cursor <= 0 ? config.choices.length - 1 : cursor - 1;
|
||||
setCursor(previousIndex);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
setStep('review');
|
||||
setError(null);
|
||||
|
||||
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);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.name === 'escape') {
|
||||
setSelected([]);
|
||||
setError(null);
|
||||
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);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
if (step === 'review') {
|
||||
if (isEnterKey(key)) {
|
||||
const finalSelection = config.choices
|
||||
.map((choice) => choice.value)
|
||||
.filter((value) => selectedSet.has(value));
|
||||
done(finalSelection);
|
||||
return;
|
||||
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)}`);
|
||||
});
|
||||
}
|
||||
|
||||
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)}`);
|
||||
});
|
||||
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)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
} 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)}`);
|
||||
});
|
||||
if (error) {
|
||||
return [lines.join('\n'), chalk.red(error)];
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return [lines.join('\n'), chalk.red(error)];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
});
|
||||
);
|
||||
|
||||
type InitCommandOptions = {
|
||||
prompt?: ToolSelectionPrompt;
|
||||
@@ -307,32 +295,48 @@ 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
|
||||
@@ -340,30 +344,49 @@ 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 [];
|
||||
@@ -373,7 +396,9 @@ 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({
|
||||
@@ -382,13 +407,15 @@ 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);
|
||||
@@ -396,14 +423,22 @@ 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;
|
||||
}
|
||||
@@ -413,7 +448,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) {
|
||||
@@ -421,24 +456,32 @@ 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) {
|
||||
@@ -469,34 +512,80 @@ 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 {
|
||||
@@ -510,7 +599,9 @@ 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 {
|
||||
@@ -527,7 +618,7 @@ export class InitCommand {
|
||||
PALETTE.lightGray,
|
||||
PALETTE.midGray,
|
||||
PALETTE.lightGray,
|
||||
PALETTE.white
|
||||
PALETTE.white,
|
||||
];
|
||||
|
||||
console.log();
|
||||
@@ -544,7 +635,7 @@ export class InitCommand {
|
||||
text,
|
||||
stream: process.stdout,
|
||||
color: 'gray',
|
||||
spinner: PROGRESS_SPINNER
|
||||
spinner: PROGRESS_SPINNER,
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ export class ChangeParser extends MarkdownParser {
|
||||
|
||||
private parseRenames(content: string): Array<{ from: string; to: string }> {
|
||||
const renames: Array<{ from: string; to: string }> = [];
|
||||
const lines = content.split('\n');
|
||||
const lines = ChangeParser.normalizeContent(content).split('\n');
|
||||
|
||||
let currentRename: { from?: string; to?: string } = {};
|
||||
|
||||
@@ -177,7 +177,8 @@ export class ChangeParser extends MarkdownParser {
|
||||
}
|
||||
|
||||
private parseSectionsFromContent(content: string): Section[] {
|
||||
const lines = content.split('\n');
|
||||
const normalizedContent = ChangeParser.normalizeContent(content);
|
||||
const lines = normalizedContent.split('\n');
|
||||
const sections: Section[] = [];
|
||||
const stack: Section[] = [];
|
||||
|
||||
|
||||
@@ -12,10 +12,15 @@ export class MarkdownParser {
|
||||
private currentLine: number;
|
||||
|
||||
constructor(content: string) {
|
||||
this.lines = content.split('\n');
|
||||
const normalized = MarkdownParser.normalizeContent(content);
|
||||
this.lines = normalized.split('\n');
|
||||
this.currentLine = 0;
|
||||
}
|
||||
|
||||
protected static normalizeContent(content: string): string {
|
||||
return content.replace(/\r\n?/g, '\n');
|
||||
}
|
||||
|
||||
parseSpec(name: string): Spec {
|
||||
const sections = this.parseSections();
|
||||
const purpose = this.findSection(sections, 'Purpose')?.content || '';
|
||||
|
||||
@@ -22,7 +22,8 @@ const REQUIREMENT_HEADER_REGEX = /^###\s*Requirement:\s*(.+)\s*$/;
|
||||
* Extracts the Requirements section from a spec file and parses requirement blocks.
|
||||
*/
|
||||
export function extractRequirementsSection(content: string): RequirementsSectionParts {
|
||||
const lines = content.split('\n');
|
||||
const normalized = normalizeLineEndings(content);
|
||||
const lines = normalized.split('\n');
|
||||
const reqHeaderIndex = lines.findIndex(l => /^##\s+Requirements\s*$/i.test(l));
|
||||
|
||||
if (reqHeaderIndex === -1) {
|
||||
@@ -102,11 +103,16 @@ export interface DeltaPlan {
|
||||
renamed: Array<{ from: string; to: string }>;
|
||||
}
|
||||
|
||||
function normalizeLineEndings(content: string): string {
|
||||
return content.replace(/\r\n?/g, '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks.
|
||||
*/
|
||||
export function parseDeltaSpec(content: string): DeltaPlan {
|
||||
const sections = splitTopLevelSections(content);
|
||||
const normalized = normalizeLineEndings(content);
|
||||
const sections = splitTopLevelSections(normalized);
|
||||
const added = parseRequirementBlocksFromSection(sections['ADDED Requirements'] || '');
|
||||
const modified = parseRequirementBlocksFromSection(sections['MODIFIED Requirements'] || '');
|
||||
const removedNames = parseRemovedNames(sections['REMOVED Requirements'] || '');
|
||||
@@ -136,7 +142,7 @@ function splitTopLevelSections(content: string): Record<string, string> {
|
||||
|
||||
function parseRequirementBlocksFromSection(sectionBody: string): RequirementBlock[] {
|
||||
if (!sectionBody) return [];
|
||||
const lines = sectionBody.split('\n');
|
||||
const lines = normalizeLineEndings(sectionBody).split('\n');
|
||||
const blocks: RequirementBlock[] = [];
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
@@ -161,7 +167,7 @@ function parseRequirementBlocksFromSection(sectionBody: string): RequirementBloc
|
||||
function parseRemovedNames(sectionBody: string): string[] {
|
||||
if (!sectionBody) return [];
|
||||
const names: string[] = [];
|
||||
const lines = sectionBody.split('\n');
|
||||
const lines = normalizeLineEndings(sectionBody).split('\n');
|
||||
for (const line of lines) {
|
||||
const m = line.match(REQUIREMENT_HEADER_REGEX);
|
||||
if (m) {
|
||||
@@ -180,7 +186,7 @@ function parseRemovedNames(sectionBody: string): string[] {
|
||||
function parseRenamedPairs(sectionBody: string): Array<{ from: string; to: string }> {
|
||||
if (!sectionBody) return [];
|
||||
const pairs: Array<{ from: string; to: string }> = [];
|
||||
const lines = sectionBody.split('\n');
|
||||
const lines = normalizeLineEndings(sectionBody).split('\n');
|
||||
let current: { from?: string; to?: string } = {};
|
||||
for (const line of lines) {
|
||||
const fromMatch = line.match(/^\s*-?\s*FROM:\s*`?###\s*Requirement:\s*(.+?)`?\s*$/);
|
||||
|
||||
@@ -32,7 +32,7 @@ const applyReferences = `**Reference**
|
||||
|
||||
const archiveSteps = `**Steps**
|
||||
1. Identify the requested change ID (via the prompt or \`openspec list\`).
|
||||
2. Run \`openspec archive <id>\` to let the CLI move the change and apply spec updates (use \`--skip-specs\` only for tooling-only work).
|
||||
2. Run \`openspec archive <id> --yes\` to let the CLI move the change and apply spec updates without prompts (use \`--skip-specs\` only for tooling-only work).
|
||||
3. Review the command output to confirm the target specs were updated and the change landed in \`changes/archive/\`.
|
||||
4. Validate with \`openspec validate --strict\` and inspect with \`openspec show <id>\` if anything looks off.`;
|
||||
|
||||
|
||||
@@ -117,6 +117,46 @@ Given A\nWhen B\nThen C`;
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts change proposals saved with CRLF line endings', async () => {
|
||||
const changeId = 'crlf-change';
|
||||
const toCrlf = (segments: string[]) => segments.join('\n').replace(/\n/g, '\r\n');
|
||||
|
||||
const crlfContent = toCrlf([
|
||||
'# CRLF Proposal',
|
||||
'',
|
||||
'## Why',
|
||||
'This change verifies validation works with Windows line endings.',
|
||||
'',
|
||||
'## What Changes',
|
||||
'- **alpha:** Ensure validation passes on CRLF files',
|
||||
]);
|
||||
|
||||
await fs.mkdir(path.join(changesDir, changeId), { recursive: true });
|
||||
await fs.writeFile(path.join(changesDir, changeId, 'proposal.md'), crlfContent, 'utf-8');
|
||||
|
||||
const deltaContent = toCrlf([
|
||||
'## ADDED Requirements',
|
||||
'### Requirement: Parser SHALL accept CRLF change proposals',
|
||||
'The parser SHALL accept CRLF change proposals without manual edits.',
|
||||
'',
|
||||
'#### Scenario: Validate CRLF change',
|
||||
'- **WHEN** a developer runs openspec validate on the proposal',
|
||||
'- **THEN** validation succeeds without section errors',
|
||||
]);
|
||||
|
||||
const deltaDir = path.join(changesDir, changeId, 'specs', 'alpha');
|
||||
await fs.mkdir(deltaDir, { recursive: true });
|
||||
await fs.writeFile(path.join(deltaDir, 'spec.md'), deltaContent, 'utf-8');
|
||||
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(testDir);
|
||||
expect(() => execSync(`node ${bin} validate ${changeId}`, { encoding: 'utf-8' })).not.toThrow();
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
+134
-41
@@ -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,14 +56,20 @@ 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 () => {
|
||||
@@ -73,23 +79,31 @@ 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');
|
||||
@@ -98,13 +112,14 @@ 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');
|
||||
@@ -134,9 +149,18 @@ 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);
|
||||
@@ -154,7 +178,9 @@ 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 () => {
|
||||
@@ -162,9 +188,18 @@ 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);
|
||||
@@ -183,27 +218,76 @@ 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);
|
||||
});
|
||||
@@ -211,9 +295,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');
|
||||
});
|
||||
@@ -225,7 +309,9 @@ 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -237,7 +323,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?'),
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -247,7 +333,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);
|
||||
@@ -259,7 +345,9 @@ 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);
|
||||
});
|
||||
});
|
||||
@@ -269,16 +357,21 @@ 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');
|
||||
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);
|
||||
}
|
||||
return originalCheck.call(fs, filePath, ...args);
|
||||
});
|
||||
|
||||
);
|
||||
|
||||
queueSelections('claude', DONE);
|
||||
await expect(initCommand.execute(readOnlyDir)).rejects.toThrow(
|
||||
/Insufficient permissions/
|
||||
|
||||
@@ -169,6 +169,25 @@ Some general description of changes without specific deltas`;
|
||||
|
||||
expect(change.deltas).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('parses change documents saved with CRLF line endings', () => {
|
||||
const crlfContent = [
|
||||
'# CRLF Change',
|
||||
'',
|
||||
'## Why',
|
||||
'Reasons on Windows editors should parse like POSIX environments.',
|
||||
'',
|
||||
'## What Changes',
|
||||
'- **alpha:** Add cross-platform parsing coverage',
|
||||
].join('\r\n');
|
||||
|
||||
const parser = new MarkdownParser(crlfContent);
|
||||
const change = parser.parseChange('crlf-change');
|
||||
|
||||
expect(change.why).toContain('Windows editors should parse');
|
||||
expect(change.deltas).toHaveLength(1);
|
||||
expect(change.deltas[0].spec).toBe('alpha');
|
||||
});
|
||||
});
|
||||
|
||||
describe('section parsing', () => {
|
||||
|
||||
+113
-31
@@ -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,17 +54,22 @@ 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
|
||||
@@ -84,13 +89,19 @@ 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();
|
||||
});
|
||||
@@ -98,7 +109,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);
|
||||
|
||||
@@ -131,9 +142,51 @@ 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');
|
||||
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'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
@@ -145,7 +198,9 @@ 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();
|
||||
});
|
||||
@@ -157,23 +212,33 @@ 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
|
||||
@@ -181,12 +246,17 @@ 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);
|
||||
@@ -195,7 +265,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);
|
||||
|
||||
@@ -253,7 +323,9 @@ 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();
|
||||
@@ -261,7 +333,10 @@ 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(
|
||||
@@ -272,19 +347,24 @@ 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);
|
||||
@@ -292,7 +372,9 @@ 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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user