diff --git a/AGENTS.md b/AGENTS.md index 1e17ab84..4081662c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,40 +1,19 @@ - -# 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. - +## 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 \ No newline at end of file +## 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. diff --git a/README.md b/README.md index 6e2b57ec..5a32ca06 100644 --- a/README.md +++ b/README.md @@ -83,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 diff --git a/openspec/changes/add-slash-command-support/specs/cli-init/spec.md b/openspec/changes/add-slash-command-support/specs/cli-init/spec.md index afa057f2..02fbdeee 100644 --- a/openspec/changes/add-slash-command-support/specs/cli-init/spec.md +++ b/openspec/changes/add-slash-command-support/specs/cli-init/spec.md @@ -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 diff --git a/openspec/changes/add-slash-command-support/specs/cli-update/spec.md b/openspec/changes/add-slash-command-support/specs/cli-update/spec.md index 03f054bc..b92ce01e 100644 --- a/openspec/changes/add-slash-command-support/specs/cli-update/spec.md +++ b/openspec/changes/add-slash-command-support/specs/cli-update/spec.md @@ -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 diff --git a/openspec/changes/add-slash-command-support/tasks.md b/openspec/changes/add-slash-command-support/tasks.md index f3a71bf8..8bd00dc1 100644 --- a/openspec/changes/add-slash-command-support/tasks.md +++ b/openspec/changes/add-slash-command-support/tasks.md @@ -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`. diff --git a/src/core/config.ts b/src/core/config.ts index 6d279c73..eadebf16 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -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' } ]; diff --git a/src/core/configurators/slash/opencode.ts b/src/core/configurators/slash/opencode.ts new file mode 100644 index 00000000..5dd73bf7 --- /dev/null +++ b/src/core/configurators/slash/opencode.ts @@ -0,0 +1,41 @@ +import { SlashCommandConfigurator } from "./base.js"; +import { SlashCommandId } from "../../templates/index.js"; + +const FILE_PATHS: Record = { + proposal: ".opencode/command/openspec-proposal.md", + apply: ".opencode/command/openspec-apply.md", + archive: ".opencode/command/openspec-archive.md", +}; + +const FRONTMATTER: Record = { + 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. + + $ARGUMENTS + +`, + 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]; + } +} diff --git a/src/core/configurators/slash/registry.ts b/src/core/configurators/slash/registry.ts index ba2fdad0..babf138e 100644 --- a/src/core/configurators/slash/registry.ts +++ b/src/core/configurators/slash/registry.ts @@ -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 = 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 { diff --git a/src/core/init.ts b/src/core/init.ts index e3a780c5..733c35a0 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -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 = { - 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; -const toolSelectionWizard = createPrompt((config, done) => { - const totalSteps = 3; - const [step, setStep] = useState('intro'); - const [cursor, setCursor] = useState(0); - const [selected, setSelected] = useState(() => config.initialSelected ?? []); - const [error, setError] = useState(null); - - const selectedSet = new Set(selected); - const pageSize = Math.max(Math.min(config.choices.length, 7), 1); - - const updateSelected = (next: Set) => { - 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 toolSelectionWizard = createPrompt( + (config, done) => { + const totalSteps = 3; + const [step, setStep] = useState('intro'); + const [cursor, setCursor] = useState(0); + const [selected, setSelected] = useState( + () => config.initialSelected ?? [] + ); + const [error, setError] = useState(null); + + const selectedSet = new Set(selected); + const pageSize = Math.max(Math.min(config.choices.length, 7), 1); + + const updateSelected = (next: Set) => { + const ordered = config.choices + .map((choice) => choice.value) + .filter((value) => next.has(value)); + setSelected(ordered); + }; - useKeypress((key) => { - if (step === 'intro') { - if (isEnterKey(key)) { - setStep('select'); - } - return; - } + 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}`; + }, + }); - if (step === 'select') { - if (isUpKey(key)) { - const previousIndex = cursor <= 0 ? config.choices.length - 1 : cursor - 1; - setCursor(previousIndex); - setError(null); + useKeypress((key) => { + if (step === 'intro') { + if (isEnterKey(key)) { + setStep('select'); + } return; } - if (isDownKey(key)) { - const nextIndex = cursor >= config.choices.length - 1 ? 0 : cursor + 1; - setCursor(nextIndex); - setError(null); - return; - } + if (step === 'select') { + if (isUpKey(key)) { + const previousIndex = + cursor <= 0 ? config.choices.length - 1 : cursor - 1; + setCursor(previousIndex); + setError(null); + return; + } + + if (isDownKey(key)) { + const nextIndex = + cursor >= config.choices.length - 1 ? 0 : cursor + 1; + setCursor(nextIndex); + setError(null); + return; + } - if (isSpaceKey(key)) { - const current = config.choices[cursor]; - if (!current) return; + 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); + const next = new Set(selected); + if (next.has(current.value)) { + next.delete(current.value); + } else { + next.add(current.value); + } + + updateSelected(next); + setError(null); + return; } - updateSelected(next); - setError(null); + 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 (isEnterKey(key)) { - if (selected.length === 0) { - setError('Select at least one AI tool to continue.'); + if (step === 'review') { + if (isEnterKey(key)) { + const finalSelection = config.choices + .map((choice) => choice.value) + .filter((value) => selectedSet.has(value)); + done(finalSelection); return; } - setStep('review'); - setError(null); - return; - } - if (key.name === 'escape') { - setSelected([]); - setError(null); + 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); - 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.')); + const stepIndex = step === 'intro' ? 1 : step === 'select' ? 2 : 3; + const lines: string[] = []; + lines.push(PALETTE.midGray(`Step ${stepIndex}/${totalSteps}`)); lines.push(''); - lines.push(page); - lines.push(''); - if (selectedNames.length === 0) { - lines.push(`${PALETTE.midGray('Selected')}: ${PALETTE.midGray('None selected yet')}`); + + if (step === 'intro') { + const introHeadline = config.extendMode + ? 'Extend your OpenSpec tooling' + : 'Configure your OpenSpec tooling'; + const introBody = config.extendMode + ? 'We detected an existing setup. We will help you refresh or add integrations.' + : "Let's get your AI assistants connected so they understand OpenSpec."; + + lines.push(PALETTE.white(introHeadline)); + lines.push(PALETTE.midGray(introBody)); + lines.push(''); + lines.push(PALETTE.midGray('Press Enter to continue.')); + } else if (step === 'select') { + lines.push(PALETTE.white(config.baseMessage)); + lines.push( + PALETTE.midGray( + 'Use ↑/↓ to move · Space to toggle · Enter to review selections.' + ) + ); + lines.push(''); + lines.push(page); + lines.push(''); + if (selectedNames.length === 0) { + lines.push( + `${PALETTE.midGray('Selected')}: ${PALETTE.midGray( + 'None selected yet' + )}` + ); + } else { + lines.push(PALETTE.midGray('Selected:')); + selectedNames.forEach((name) => { + lines.push(` ${PALETTE.white('-')} ${PALETTE.white(name)}`); + }); + } } else { - lines.push(PALETTE.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 { + private async validate( + projectPath: string, + _openspecPath: string + ): Promise { 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, extendMode: boolean): Promise { - const selectedTools = await this.promptForAITools(existingTools, extendMode); + private async getConfiguration( + existingTools: Record, + extendMode: boolean + ): Promise { + const selectedTools = await this.promptForAITools( + existingTools, + extendMode + ); return { aiTools: selectedTools }; } - private async promptForAITools(existingTools: Record, extendMode: boolean): Promise { - const availableTools = AI_TOOLS.filter(tool => tool.available); + private async promptForAITools( + existingTools: Record, + extendMode: boolean + ): Promise { + 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> { + private async getExistingToolStates( + projectPath: string + ): Promise> { const states: Record = {}; 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 { + private async isToolConfigured( + projectPath: string, + toolId: string + ): Promise { 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 { + private async generateFiles( + openspecPath: string, + config: OpenSpecConfig + ): Promise { 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 { + private async configureAITools( + projectPath: string, + openspecDir: string, + toolIds: string[] + ): Promise { 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(); } } diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 1234abad..c8c13dd0 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -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(''); 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(''); 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 '); - 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(''); + + 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/ diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 10591a0a..6d2adbf1 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -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 --strict`'); + expect(updated).toContain( + 'Validate with `openspec validate --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 +--- + +Old body +`; + 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, '\nOld\n'); + await fs.writeFile( + claudePath, + '\nOld\n' + ); 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] --- Old content -`); +` + ); 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, '\nOld\n'); + await fs.writeFile( + claudePath, + '\nOld\n' + ); 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');