Compare commits

..
Author SHA1 Message Date
Tabish Bidiwale 0319ac3bb0 chore(release): version packages 2025-09-26 16:01:31 +10:00
James G. Best dcfb6afe0c feat: add Opencode slash commands (#83)
* First pass at adding Opencode slash commands

* Fix the agent

* Pass in Arguments to opencode slash commands

* Remove unneeded agents file
2025-09-26 01:37:30 +10:00
Tabish Bidiwale 86925b2b2d docs: add --yes flag to archive command template (#84) 2025-09-26 00:32:56 +10:00
Tabish Bidiwale 604ecb8bd1 fix: normalize line endings in markdown parser to handle CRLF files (#79)
Fixes validation errors on Windows by normalizing CRLF/CR line endings
to LF before parsing sections. Adds comprehensive test coverage for
CRLF handling in both unit and integration tests.
2025-09-25 15:59:09 +10:00
Tabish Bidiwale 5a4837c37d feat: add OpenSpec change proposals for CLI improvements (#78)
* feat: add CLI e2e testing improvement plan

## Summary
- Add phased approach to stabilize CLI spawn testing
- Expand cross-shell/OS matrix coverage when stable
- Optional packaging validation for CI environments

* feat: add markdown parser CRLF fix proposal and update e2e plan

- Add comprehensive proposal for fixing CRLF parsing issues on Windows
- Update CLI path references from dist/cli.js to dist/cli/index.js
- Streamline e2e testing tasks based on refined approach

* fix: correct spec delta to add parsing requirement instead of modifying remediation

- Change from MODIFIED to ADDED Requirements for cross-platform line ending parsing
- Create focused requirement for parser behavior rather than validation messages
- Maintain logical coherence between requirement and scenario
2025-09-25 14:53:21 +10:00
Tabish Bidiwale 9d9539aaa2 docs: add Discord badge (#73)
* docs: add discord badge

* chore: ignore ds store
2025-09-24 03:10:13 +10:00
Tabish Bidiwale c3fecf0619 chore: add codeowners (#72) 2025-09-19 11:56:37 +10:00
25 changed files with 815 additions and 360 deletions
+2 -1
View File
@@ -145,4 +145,5 @@ docs/
# Claude
.claude/
CLAUDE.md
CLAUDE.md
.DS_Store
+13 -34
View File
@@ -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.
+12
View File
@@ -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
+4 -2
View File
@@ -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
View File
@@ -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",
+1
View File
@@ -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' }
];
+41
View File
@@ -0,0 +1,41 @@
import { SlashCommandConfigurator } from "./base.js";
import { SlashCommandId } from "../../templates/index.js";
const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: ".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];
}
}
+3
View File
@@ -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
View File
@@ -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();
}
}
+3 -2
View File
@@ -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[] = [];
+6 -1
View File
@@ -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 || '';
+11 -5
View File
@@ -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.`;
+40
View File
@@ -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
View File
@@ -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/
+19
View File
@@ -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
View File
@@ -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');