Compare commits

...
Author SHA1 Message Date
Tabish Bidiwale 93c846421a fix: include full requirement block in MODIFIED spec
Update the Schema Loading requirement to include all original scenarios
(modified as needed) per the MODIFIED requirement guidelines. The archiver
replaces the entire requirement with the provided content.
2025-12-26 23:30:10 +11:00
Tabish Bidiwale 702c163048 proposal: add instruction loader and schema restructure (Slice 3)
Adds two change proposals for implementing Slice 3 of the artifact POC:

1. restructure-schema-directories
   - Move schemas from embedded TS objects to self-contained directories
   - Each schema becomes a directory with schema.yaml + templates/
   - Enables co-located templates for user extensibility
   - 2-level resolution: user override → package built-in

2. add-instruction-loader (depends on #1)
   - Load templates from schema directories
   - Enrich templates with change context (dependencies, next steps)
   - Format change status for CLI output
   - New instruction-loader capability

These proposals complete Slice 3 from docs/artifact_poc.md.
2025-12-26 23:13:13 +11:00
Tabish Bidiwale 4f4af5708d feat: add change creation utilities (#408)
* proposal: add change manager - extract + new functionality

Slice 2 of the artifact tracker POC. Creates ChangeManager module that:

**Extracts existing functionality:**
- `listChanges()` from ListCommand + ChangeCommand.getActiveChanges()
- `changeExists()` from inline fs.access() checks
- `getChangePath()` from inline path.join() calls
- `isInitialized()` from ListCommand directory check

**Adds new functionality:**
- `createChange(name, description?)` - create change directory + README
- `validateName(name)` - enforce kebab-case naming

**Refactors CLI commands to be thin wrappers:**
- ListCommand delegates to ChangeManager
- ChangeCommand delegates to ChangeManager

Also updates docs/artifact_poc.md to reflect XDG decisions from Slice 1.

* proposal: simplify to utility functions only

Remove extraction/refactor scope. Just add:
- createChange(projectRoot, name, description?)
- validateChangeName(name)

Simple utility functions in src/utils/change-utils.ts.
No class, no abstraction layer.

* docs: update artifact_poc.md for simplified Slice 2

- Rename ChangeManager to change-utils (simple utility functions)
- Remove extracted methods (listChanges, getChangePath, etc.)
- Keep only new functionality: createChange(), validateChangeName()
- Update component diagram and summary table

* docs: clarify existing vs new functionality in artifact_poc.md

Audit artifact_poc.md against existing codebase to mark what already
exists vs what's genuinely new:

- Slice 4 CLI table: Added Status column (NEW/EXISTS)
- Added "Existing CLI commands" section listing what's not in scope
- Updated Implementation Order Slice 4 with explicit new vs existing
- Summary table: Updated status + added "What already exists" section

Key finding: The document was already well-simplified. Only truly new
functionality (createChange, validateChangeName, InstructionLoader,
artifact graph CLI commands) is proposed.

* rename: change-manager -> change-creation capability

The capability name "change-manager" implied a manager class abstraction,
but the simplified proposal uses only utility functions. Renamed to
"change-creation" to accurately reflect what the capability provides.

* feat: implement change creation utilities

Add createChange() and validateChangeName() functions for programmatic
change directory creation with kebab-case validation.

- createChange(projectRoot, name) creates openspec/changes/<name>/
- validateChangeName() enforces kebab-case naming conventions
- Comprehensive test coverage (21 tests)

* chore: archive add-change-manager change

Move change to archive and create change-creation spec with
requirements for createChange() and validateChangeName().

* docs: update change-creation spec purpose
2025-12-26 22:14:56 +11:00
Tabish Bidiwale 9822576770 fix(artifact-graph): normalize paths for cross-platform glob compatibility (#407)
Add FileSystemUtils.toPosixPath() utility and consolidate scattered
path normalization patterns. This fixes Windows test failures where
fast-glob couldn't match paths containing backslashes.

Root cause: path.join() uses backslashes on Windows, but fast-glob
requires forward slashes for glob patterns on all platforms.

Changes:
- Add toPosixPath() to FileSystemUtils for cross-platform path handling
- Update artifact-graph/state.ts to normalize glob patterns
- Consolidate path normalization in update.ts, validator.ts, and
  json-converter.ts to use the new utility
2025-12-25 22:42:00 +11:00
Tabish Bidiwale af273b8e0b proposal: add artifact graph core query system (#400)
* proposal: add artifact graph core query system

Add OpenSpec change proposal for Slice 1 of the artifact POC - the core
"What's Ready?" query system. This implements:

- ArtifactGraph class for DAG-based dependency modeling
- Filesystem-based state detection (file existence = completion)
- Topological sort for build order calculation
- Ready/blocked artifact queries

This is a parallel module that will coexist with the current system.

* docs: specify Zod for schema validation in artifact graph proposal

- Add decision section for Zod schema validation in design.md
- Update data structures to show Zod schemas with z.infer<> types
- Update tasks to specify Zod usage for type definitions and parsing

* docs: add 2-level schema resolution and built-in schemas

- Add decision for global → built-in schema resolution pattern
- Add resolver.ts for schema lookup logic
- Add built-in schemas directory (spec-driven.yaml, tdd.yaml)
- Add schema resolution tests
- Follows ESLint/Prettier/Git patterns (defaults baked in package)

* experiment: add vertical slice version of artifact graph change

Creates add-artifact-graph-core-v2 with requirements organized as
vertical slices - each requirement file contains its spec, design
decisions, and tasks bundled together for comparison.

* feat(core): add getGlobalDataDir for XDG-compliant data directory

Add getGlobalDataDir() function following XDG Base Directory Specification
for storing user data like schema overrides:
- XDG_DATA_HOME takes precedence on all platforms
- Unix/macOS fallback: ~/.local/share/openspec/
- Windows fallback: %LOCALAPPDATA%/openspec/

* feat(artifact-graph): add core dependency graph module

Implement Slice 1 ("What's Ready?") of the artifact graph system:

- types.ts: Zod schemas for artifact definitions with derived TypeScript types
- schema.ts: YAML parsing with validation for duplicates, invalid refs, cycles
- graph.ts: ArtifactGraph class with Kahn's algorithm for topological sort
- state.ts: Filesystem-based completion detection with glob pattern support
- resolver.ts: Two-level schema resolution (global override → built-in)
- builtin-schemas.ts: spec-driven and tdd workflow definitions

Key design decisions:
- Filesystem as database (stateless, git-friendly)
- Cycle errors show full path (e.g., "A → B → C → A")
- Deterministic ordering via sorted queues

* test(artifact-graph): add comprehensive test suite

52 tests covering all artifact-graph functionality:

- schema.test.ts: Parsing, validation errors, cycle detection
- graph.test.ts: Build order, ready artifacts, blocked queries
- state.test.ts: File existence, glob patterns, missing directories
- resolver.test.ts: Schema resolution with global overrides

* docs(openspec): archive add-artifact-graph-core change

Archive completed change proposal and create artifact-graph spec with
6 requirements covering schema loading, build order, state detection,
ready queries, completion checks, and blocked queries.

* chore: remove experimental artifact-graph-core-v2 folder

Clean up experimental vertical slice proposal that is no longer needed.

* feat(artifact-graph): validate global schema overrides

Global schema overrides are now validated through the same pipeline as
built-in schemas, catching invalid schemas, cyclic dependencies, and
invalid requires references at load time. Added SchemaLoadError for
better error context with file paths.

* test(artifact-graph): add workflow integration tests

Add end-to-end integration tests that exercise the full artifact-graph
pipeline: resolveSchema → ArtifactGraph → detectCompleted → queries.

Tests cover:
- Complete spec-driven and tdd workflow progressions
- Out-of-order file creation handling
- Glob pattern matching with multiple files
- Build order consistency
- Edge cases (empty/missing directories, non-matching files)

* refactor(artifact-graph): adopt zod v4 error message format

Update custom error messages from string format to zod v4 object format
using `{ error: 'message' }` convention.

* fix(test): prevent hanging vitest threads after test runs

- Add teardownTimeout (3s) to vitest config for forced cleanup
- Add global teardown function to vitest.setup.ts
- Call child.unref() to prevent child processes from blocking event loop
- Explicitly destroy stdio streams on process close/error
2025-12-25 22:09:42 +11:00
Eunsong-Park 3ceef2db72 fix(archive): allow REMOVED requirements when creating new spec files (#403) (#404)
When creating a new spec file, REMOVED requirements are now ignored
with a warning instead of causing archive to fail. This enables
refactoring scenarios where old fields are removed while documenting
a capability for the first time.

Fixes #403
2025-12-25 03:44:34 +11:00
Tabish Bidiwale 2c2599b1f0 docs: add artifact POC analysis document (#398)
Add internal documentation for the artifact-based approach to OpenSpec
core. This document outlines design decisions, terminology, and the
philosophy behind treating dependencies as enablers rather than gates.
2025-12-23 22:21:11 +11:00
github-actions[bot]andTabish Bidiwale c08a53cb21 chore(release): version packages (#397)
* Version Packages

* chore: trigger CI

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tabish Bidiwale <tabishbidiwale@gmail.com>
2025-12-23 12:59:10 +11:00
Tabish Bidiwale 455c65f3c4 Add changeset for --no-interactive flag fix (#396) 2025-12-23 12:45:56 +11:00
Tabish Bidiwale 9ac6330430 fix(cli): respect --no-interactive flag in validate command (#395)
* fix(cli): respect --no-interactive flag in validate command

The validate command's spinner was starting regardless of the
--no-interactive flag, causing hangs in pre-commit hooks.

Changes:
- Pass noInteractive option to runBulkValidation
- Handle Commander.js --no-* flag syntax (sets interactive=false)
- Only start ora spinner when in interactive mode
- Add CI environment variable check to isInteractive() for industry
  standard compliance

* test: add unit tests for interactive utilities and CLI flag

- Export resolveNoInteractive() helper for reuse
- Add InteractiveOptions type export for testing
- Refactor validate.ts to use resolveNoInteractive()
- Add 17 unit tests for isInteractive() and resolveNoInteractive()
- Add CLI integration test for --no-interactive flag

This prevents future regressions where Commander.js --no-* flag
parsing is not properly handled.
2025-12-23 12:42:25 +11:00
github-actions[bot]andTabish Bidiwale fb264bcbcd chore(release): version packages (#394)
* Version Packages

* chore: trigger CI

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tabish Bidiwale <tabishbidiwale@gmail.com>
2025-12-23 10:00:01 +11:00
Tabish Bidiwale a2757e7856 Add changeset for config command dynamic import fix (#393) 2025-12-23 09:56:51 +11:00
Tabish Bidiwale 6d84924c18 fix(cli): use dynamic import for @inquirer/prompts in config command (#392)
* fix(cli): use dynamic import for @inquirer/prompts in config command

The config command (added in #382) reintroduced the pre-commit hook hang
issue that was fixed in #380. The static import of @inquirer/prompts at
module load time causes stdin event listeners to be registered even when
running non-interactive commands, preventing clean process exit when
stdin is piped (as pre-commit does).

Convert the static import to a dynamic import that only loads inquirer
when the `config reset` command is actually used interactively.

Fixes #367

* chore: add ESLint with no-restricted-imports rule for @inquirer

Add ESLint configuration that prevents static imports of @inquirer/*
modules. This prevents future regressions of the pre-commit hook hang
issue fixed in this PR.

The rule shows a helpful error message pointing to issue #367 for context.
init.ts is exempted since it's already dynamically imported from the CLI.

* ci: add ESLint step to lint job

Run `pnpm lint` in CI to enforce the no-restricted-imports rule
that prevents static @inquirer imports.
2025-12-23 09:50:54 +11:00
Tabish Bidiwale 6de04f3b2b feat(ci): migrate to npm OIDC trusted publishing (#390)
Replace classic npm token authentication with OIDC trusted publishing:

- Add `id-token: write` permission for OIDC token generation
- Upgrade to Node 24 (includes npm 11.5.1+ required for OIDC)
- Remove NPM_TOKEN/NODE_AUTH_TOKEN env vars (OIDC replaces them)

This eliminates the need for rotating npm access tokens and provides
cryptographically verified publisher identity with automatic provenance
attestation.

Requires configuring trusted publisher on npmjs.com:
- Organization: Fission-AI
- Repository: OpenSpec
- Workflow: release-prepare.yml
2025-12-22 20:10:27 +11:00
github-actions[bot]andTabish Bidiwale c2a1a4c807 chore(release): version packages (#389)
* Version Packages

* chore: trigger CI

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tabish Bidiwale <tabishbidiwale@gmail.com>
2025-12-22 18:44:57 +11:00
Tabish Bidiwale 2e71835d23 Add changeset for config command and shell completions (#388) 2025-12-22 18:35:29 +11:00
Tabish Bidiwale 971f8ca4a3 feat(cli): add openspec config command for global configuration management (#382)
* feat(cli): add openspec config command for global configuration management

Implements the `openspec config` command with subcommands:
- `path`: Show config file location
- `list [--json]`: Show all current settings
- `get <key>`: Get a specific value (raw output for scripting)
- `set <key> <value> [--string]`: Set a value with auto type coercion
- `unset <key>`: Remove a key (revert to default)
- `reset --all [-y]`: Reset configuration to defaults
- `edit`: Open config in $EDITOR/$VISUAL

Key features:
- Dot notation for nested key access (e.g., featureFlags.someFlag)
- Auto type coercion (true/false → boolean, numbers → number)
- --string flag to force string storage
- Zod schema validation with unknown field passthrough
- Reserved --scope flag for future project-local config
- Windows-compatible editor spawning with proper path quoting
- Shell completion registry integration

* test(config): add additional unit tests for validation and coercion

- Add tests for unknown fields with various types
- Add test to verify error message path for featureFlags
- Add test for number values rejection in featureFlags
- Add config set simulation tests to verify full coerce → set → validate flow

* fix(config): avoid shell parsing in config edit to handle paths with spaces

Use spawn with shell: false and pass configPath as an argument instead
of building a shell command string. This correctly handles spaces in
both the EDITOR path and config file path on all platforms.

* chore(openspec): archive add-config-command and create cli-config spec

Move completed change to archive and apply spec deltas to create
the cli-config specification documenting the config command interface.

* Validate config keys on set
2025-12-22 18:29:25 +11:00
Tabish Bidiwale 68e0a7e68e fix(cli): prevent hang in pre-commit hooks by using dynamic imports (#380)
Fixes #367

The CLI was hanging when run as a pre-commit hook because @inquirer/prompts
was statically imported at module load time. Even when prompts were never
called (e.g., `openspec validate --specs --no-interactive`), the import
itself could set up stdin references that prevented clean process exit
when stdin was piped.

Changes:
- Convert all static `@inquirer/prompts` imports to dynamic imports
- Dynamically import `InitCommand` (which uses `@inquirer/core`)
- Update `isInteractive()` to accept options object with both
  `noInteractive` and Commander's negated `interactive` property
- Handle empty validation queue with proper exit code

Now when running in non-interactive mode, the inquirer modules are never
loaded, allowing the process to exit cleanly after completion.
2025-12-21 18:10:53 +11:00
Tabish Bidiwale f39cc5c1fb fix(global-config): respect XDG_CONFIG_HOME on all platforms (#378)
Prioritize XDG_CONFIG_HOME on Windows to fix test environment overrides.
Previously, Windows would always use APPDATA regardless of XDG_CONFIG_HOME,
causing tests to fail. Now XDG_CONFIG_HOME is checked first on all platforms
before falling back to platform-specific defaults.

Also update the Windows APPDATA test to explicitly clear XDG_CONFIG_HOME
when testing the fallback behavior.
2025-12-20 23:01:04 +11:00
Tabish Bidiwale 5129a8cf96 feat(core): implement global config directory with XDG support (#377)
* feat(core): implement global config directory with XDG support

Add new global-config module following XDG Base Directory Specification with platform-specific fallbacks (Unix: ~/.config/openspec, Windows: %APPDATA%/openspec). Includes config loading with defaults, config saving with directory creation, and full test coverage. Archive add-global-config-dir change.

* docs(spec): add Purpose section to global-config spec

Replace placeholder text with a concise description of what the spec governs, its scope, and high-level objectives.
2025-12-20 20:16:53 +11:00
Tabish Bidiwale 4ff893048d feat(spec): add XDG global config directory and config command proposals (#376)
Create two OpenSpec change proposals:
1. add-global-config-dir: Foundation for user-level configuration following XDG Base Directory Specification with cross-platform support
2. add-config-command: User-facing CLI command for viewing and managing global settings

Both proposals are minimal and focused on providing a clean, extensible base for OpenSpec settings and future feature flags.
2025-12-20 19:45:55 +11:00
Tabish Bidiwale cefb4719aa fix(completions): resolve Windows compatibility issues in zsh-installer tests (#373)
- Fix canWriteFile to use fs.access with W_OK flag instead of Unix-style
  permission bits (stats.mode & 0o222) which don't work on Windows
- Update test paths to use platform-specific invalid paths that fail on
  both Unix and Windows
- Use regex for path separator matching in test assertions
2025-12-19 23:37:59 +11:00
Tabish Bidiwale 5e1cef3b3b fix(spec): align cli-completion spec with implementation (#360)
Update the cli-completion spec to match the actual implementation:

- Change `completion zsh` to `completion generate [shell]` command structure
- Update uninstall behavior to reflect confirmation prompt cancels entire operation
- Change "not installed" uninstall exit code from 0 to 1
- Update shell detection error message to match implementation
- Replace Purpose placeholder with actual description
2025-12-12 22:13:52 +11:00
1adf3cea88 feature/oh-my-zsh-completions (#289)
* shell completions for zsh

* after code review changes

* expose only postinstall.js script

* Replace _openspec "$@" with compdef in zsh-generator.ts to prevent execution during load

* Update test/commands/completion.test.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* - Fix dispatcher to use $words instead of $line for subcommand routing
  - Add __complete endpoint with tab-separated output for safe parsing
  - Replace brittle awk parsing with __complete in completion helpers
  - Add uninstall confirmation prompt with --yes flag to skip
  - Prefer $ZSH env var for Oh My Zsh detection before dir check
  - Add fpath verification guidance for OMZ installations
  - Update cli-completion spec to document generate subcommand

* improve shell detection and installation handling

  - Return structured result from detectShell() with shell and detected name
  - Detect already-installed completions and skip reinstall
  - Add update detection with automatic backup of previous version
  - Add debug logging to silent catch blocks for diagnostics
  - Quote fpath directories to handle paths with spaces
  - Verify Oh My Zsh fpath configuration and add to .zshrc if needed
  - Show helpful error for detected but unsupported shells
  - Update all tests for new detection API

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
2025-12-12 21:30:40 +11:00
Tabish Bidiwale 6d3cfe0443 docs(readme): alphabetize AI tools list and make collapsible (#343)
- Sort supported AI tools alphabetically (A-Z)
- Wrap both "Native Slash Commands" and "AGENTS.md Compatible" sections
  in collapsible <details> tags to reduce visual clutter
2025-11-28 16:13:36 +11:00
Tabish Bidiwale 17d1e5db3f fix(opencode): remove hardcoded agent field from slash commands (#335)
Remove the `agent: build` field from OpenCode slash command templates
to allow OpenCode to use the current/custom agent instead of requiring
the build agent to be available.

Fixes #334
2025-11-25 12:07:51 +11:00
github-actions[bot]andTabish Bidiwale 3f5a66d3e4 chore(release): version packages (#327)
* Version Packages

* chore: trigger CI

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tabish Bidiwale <tabishbidiwale@gmail.com>
2025-11-21 22:58:29 +11:00
Tabish Bidiwaleandcoderabbitai[bot] c08fbc1ba0 chore: add changeset for new features and improvements (#326)
* Add changeset for new features and improvements

* Update .changeset/new-features-and-improvements.md

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-11-21 21:49:33 +11:00
Tabish Bidiwale 938d03be9a feat(init): add IDE restart instruction after init (#323)
Add prominent restart instruction in success message to inform users
they need to restart their IDE/coding tool for slash commands to appear.
Applies to all tools when slash commands are created or refreshed.

Also updates cli-init spec to document the restart instruction requirement.
2025-11-21 20:29:53 +11:00
dkmos2016 19ccaabfc7 feat(iflow-cli): add iFlow-cli integration (#268)
* support iflow-cli

* docs: add iFlow to supported AI tools in README ([#268](https://github.com/Fission-AI/OpenSpec/pull/268))

Add iFlow to the Native Slash Commands table in the README. iFlow support was implemented but was missing from the documentation.

* add UTs for iflow-cli
2025-11-20 21:57:58 +11:00
Tabish Bidiwale 2e382b9898 Add Antigravity slash command support (#318) 2025-11-19 17:18:52 +11:00
Tabish Bidiwale b5a7d096f0 fix: generate TOML commands for Qwen Code (fixes #293) (#317) 2025-11-19 16:52:44 +11:00
Tabish Bidiwale c54079a0cd Clarify scaffold proposal (#310)
* clarify scaffold proposal

* Update scaffold command proposal to support idempotent execution
2025-11-19 16:23:57 +11:00
jax 1050e57ae4 Enhance proposal guidelines in slash-command-templates.ts (#306)
* Enhance proposal guidelines in slash-command-templates.ts

- Added instructions to avoid writing code during the proposal stage and focus on creating design documents.
- Emphasized the design phase by including a reminder not to implement code until the apply stage.
- Updated validation steps to clarify the importance of confirming project conventions before proceeding with tasks.

* Update proposal guidelines in slash-command-templates.ts to clarify that no implementation code should be written during the design phase.

* Refine proposal and apply steps in slash-command-templates.ts

- Removed redundant instruction in proposal steps to streamline the process.
- Clarified the initial reading order for apply steps by omitting the project conventions document, focusing on the proposal and design documents instead.
2025-11-19 15:34:07 +11:00
github-actions[bot]andTabish Bidiwale 17d7e59343 chore(release): version packages (#305)
* Version Packages

* chore: trigger CI

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tabish Bidiwale <tabishbidiwale@gmail.com>
2025-11-14 16:32:10 +11:00
Tabish Bidiwale 4758c5c68d Add changeset for new AI tool integrations (#304) 2025-11-14 16:30:16 +11:00
jax c4b0826da7 feat(roocode): add RooCode integration (configurator, slash commands, templates) (#288)
* feat(roocode): Added RooCode tool support and related configurations

Added RooCode tool integration, including:

- Added RooCode configurator class
- Registered RooCode to the tool registry
- Implemented RooCode template files
- Added RooCode slash command support
- Updated README documentation
- Added related test cases

* Removed RooCode related configurations from the project. This includes deleting the RooCode configurator, its template, and associated tests.
2025-11-14 13:44:14 +11:00
537e6078b7 Fix Cline: use workflows instead of rules for slash commands (#283)
* fix(cline): use workflows instead of rules for slash commands

- Update ClineSlashCommandConfigurator to use .clinerules/workflows/ paths
- Update tests to expect correct workflow file locations
- Update README.md to reflect workflows instead of rules
- Fixes Cline integration to match Cline's architecture per their blog post

* Adds spec for fix-cline-workflows-implementation

---------

Co-authored-by: didier <didier.boff@axess.fr>
Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
2025-11-13 23:03:51 +11:00
Tabish Bidiwale 5439ab0833 Document Gemini CLI slash updates (#301) 2025-11-11 22:14:56 +11:00
Larry HopeandTabish Bidiwale 9b6a763eb8 feat: Add Gemini CLI support with TOML-based slash commands (#256)
* feat: add Gemini CLI support with TOML-based slash commands

- Add GeminiSlashCommandConfigurator for .gemini/commands/openspec/
- Register Gemini CLI in AI_TOOLS config and slash command registry
- Generate TOML files with description and prompt fields
- Add comprehensive test coverage for Gemini CLI integration
- Update README to list Gemini CLI under Native Slash Commands
- Remove Gemini CLI from AGENTS.md compatible list (now native)

Implements GitHub issue #248

* [add]

* feat: address PR feedback - remove changeset and add update test

- Remove .changeset/add-gemini-cli-support.md as requested by maintainer
- Add test for Gemini CLI TOML update/refresh path
- Test verifies that existing TOML files are properly updated when running init again

Addresses feedback from TabishB in PR #256

---------

Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
2025-11-11 21:26:44 +11:00
github-actions[bot]andTabish Bidiwale d32e50fe36 chore(release): version packages (#271)
* Version Packages

* chore: trigger CI

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tabish Bidiwale <tabishbidiwale@gmail.com>
2025-11-04 19:39:29 +11:00
Tabish Bidiwale 8386b91a71 Add changeset for AI assistants support and configuration improvements (#270) 2025-11-03 11:20:27 +11:00
8f9c3c7d0b feat: add Qwen Code support with slash command integration (#250)
* feat: add Qwen Code support with slash command integration

- Add QwenSlashCommandConfigurator for .qwen/commands/ structure
- Add QwenConfigurator to main registry
- Update README.md to include Qwen Code in supported tools list
- Register Qwen in both slash command and main tool registries
- Implement proper YAML frontmatter for Qwen command files
- Follow OpenSpec's established patterns for AI tool integration

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs: add docstrings and fix review comments for Qwen Code support

- Add comprehensive JSDoc comments to Qwen configurator files
- Add Qwen Code entry to README_CN.md support table
- Fix file ending newline in src/core/configurators/qwen.ts
- Address CodeRabbit review suggestions for improved documentation coverage

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix: add language identifier to markdown code block in supportQwen.md

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix: address CodeRabbit review comments - parameter naming fix

- Fix unused parameter naming convention (_openspecDir)
- Keep only necessary changes for Qwen Code support

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* chore: add personal notes files to .gitignore

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* chore: remove personal notes files from git tracking

- Remove README_CN.md and supportQwen.md from git tracking
- These files are now ignored via .gitignore
- Keep only necessary files for the OpenSpec project

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix: remove duplicate JSDoc comment in QwenConfigurator

- Remove duplicate documentation comment for configure method
- Keep only the updated comment that correctly documents the unused parameter

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test: cover qwen configurators

* chore: revert gitignore changes

* test: extend qwen init coverage

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Tabish Bidiwale <tabishbidiwale@gmail.com>
2025-11-03 11:03:39 +11:00
Tabish Bidiwale 9cdb0743f2 feat: add $ARGUMENTS support to apply slash command (#244)
This change adds the $ARGUMENTS property to the OpenCode apply slash command template, allowing users to pass arguments when invoking the apply command. The template now includes instructions for the agent to find and implement the change proposal, with guidance to ask for clarification when ambiguous.
2025-11-02 23:12:02 +11:00
KUTEJiangandpengjiahan.pjh 4e93d7a881 feat: add Qoder CLI support to configuration and documentation (#261)
Co-authored-by: pengjiahan.pjh <pengjiahan.pjh@antgroup.com>
2025-11-01 19:49:36 +11:00
mini2s c4b6be41c1 feat: add CoStrict AI assistant support (#240)
* feat(ai-tools): add Costrict integration support

Add support for Costrict AI tool with slash commands and configuration template. Includes configurator, registry entries, templates, and comprehensive test coverage.

* feat(config): update branding from 'Costrict' to 'CoStrict'
2025-11-01 19:10:23 +11:00
HariKrishnanandClaude a66580735c docs: add guidance for populating project-level context (#241)
* docs: add optional project context setup instructions

Add documentation for the optional step of populating project.md with
project details, tech stack, and conventions after running openspec init.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: improve project context section formatting and clarity

- Change heading to "Optional: Populate Project Context" for consistency
- Fix double space typo in "After  openspec init"
- Explicitly reference openspec/project.md file
- Enhance explanation of project.md purpose

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-27 10:12:14 +11:00
Tabish Bidiwale fb1d37e56e fix: recreate missing openspec template files in extend mode (#238)
* fix: recreate missing openspec template files in extend mode

The init command now checks for and recreates missing template files
(like openspec/AGENTS.md and openspec/project.md) when running in
extend mode, instead of skipping file generation entirely.

Previously, if a user deleted openspec/AGENTS.md and ran init again,
the file would not be recreated because the openspec/ directory
existed, triggering extend mode which skipped all file generation.

Changes:
- Added ensureTemplateFiles() method to check and recreate missing files
- Modified extend mode to call ensureTemplateFiles() instead of skipping
- Updated message to "Checking for missing files..." for clarity
- Added tests for recreating deleted openspec/AGENTS.md and project.md

* refactor: consolidate duplicate logic in template file generation

Extracted shared logic from generateFiles() and ensureTemplateFiles()
into a new writeTemplateFiles() method with a skipExisting parameter.
This eliminates code duplication while maintaining the same behavior.

* test: improve extend mode test coverage and reduce duplication

- Extracted testFileRecreationInExtendMode helper to reduce code duplication
- Added test to verify existing files are preserved in extend mode
- Ensures skipExisting behavior is properly tested
2025-10-25 23:55:49 +11:00
Tabish Bidiwale cf0de5e569 fix: prevent false 'already configured' detection for tools (#239)
* fix: prevent false "already configured" detection for tools

Fixes #195

## Problem
Users with existing tool config files (like CLAUDE.md) would see those
tools marked as "already configured" even when running `openspec init`
for the first time. This caused confusion as users thought OpenSpec was
already set up when it wasn't.

Root causes:
1. Tool detection checked only for file existence, not OpenSpec ownership
2. Detection ran even in fresh projects without openspec/ folder

## Solution
Two-part fix:

1. **Conditional detection**: Only check tool configuration when in extend
   mode (when openspec/ directory already exists). Fresh initializations
   skip the check entirely, treating all tools as unconfigured.

2. **Marker-based validation**: For tools to be considered "configured by
   OpenSpec", their files must contain OpenSpec markers (<!-- OPENSPEC:START -->
   and <!-- OPENSPEC:END -->). For tools with both config files and slash
   commands (like Claude Code), BOTH must have markers.

## Changes
- Modified getExistingToolStates() to accept extendMode parameter
- Rewrote isToolConfigured() to verify OpenSpec markers in files
- Added OPENSPEC_MARKERS to imports
- Added 4 comprehensive test cases covering the new behavior

## Test Coverage
- Fresh init with existing CLAUDE.md → NOT shown as configured ✅
- Fresh init with existing slash commands → NOT shown as configured ✅
- Extend mode with OpenSpec files → shown as configured ✅
- Fresh init with global Codex prompts → NOT shown as configured ✅

All 240 tests pass.

* refactor: optimize tool state detection and improve code clarity

Address code review feedback:

1. **Parallelize tool state checks**: Changed from sequential `for` loop to
   `Promise.all()` for checking multiple tools simultaneously. This reduces
   I/O latency during extend mode initialization.

2. **Extract marker validation helper**: Created `fileHasMarkers()` helper
   function to eliminate code duplication between config file and slash
   command checks. Makes the logic clearer and more maintainable.

3. **Clarify slash command policy**: Added explicit comment that "at least
   one file with markers is sufficient" (not all required) for slash commands.
   This is correct because OpenSpec creates all files together - if any
   exists with markers, the tool was configured by OpenSpec.

4. **Simplify fresh init path**: Use `Object.fromEntries()` for cleaner
   initialization of all-false states.

Performance improvement: Extend mode now checks tools in parallel instead
of serially, reducing init time especially for projects with many tools.
2025-10-25 20:46:49 +11:00
Tabish Bidiwale 92b45462c6 fix: use change-id as fallback title instead of "Untitled Change" (#236)
* fix: use change-id as fallback title instead of "Untitled Change"

Fixes #225 by addressing mismatch between proposal template and title extraction:

- Updated proposal template to include `# Change: [description]` header
- Changed extractTitle fallback from "Untitled Change" to change-id
- Updated all extractTitle call sites to pass changeName parameter

This ensures both new and existing proposals display meaningful titles.

* refactor: make title extraction case-insensitive for "Change:"

Makes the extractTitle regex case-insensitive to handle variations like
"# change:" or "# CHANGE:" in addition to "# Change:".
2025-10-25 16:30:37 +11:00
Tabish Bidiwale 5ab438f5fd docs: add Crush to supported AI tools in README (#235)
Add Crush to the Native Slash Commands table in the README. Crush support was implemented in the codebase but was missing from the documentation.
2025-10-24 12:46:35 +11:00
github-actions[bot]andTabish Bidiwale 5855fa2353 chore(release): version packages (#228)
* Version Packages

* chore: trigger CI

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tabish Bidiwale <tabishbidiwale@gmail.com>
2025-10-22 17:18:52 +11:00
Tabish Bidiwale 668a125d4d chore: add changeset for AI assistants support and validation fixes (#227)
* fix: manually merge and archive four completed OpenSpec changes

Merged and archived four concurrent changes that modified the same specs:
- add-cline-support: Added Cline AI tool configuration
- add-crush-support: Added Crush AI tool configuration
- add-factory-slash-commands: Added Factory Droid slash commands
- add-archive-command-arguments: Added archive command argument support

Changes to cli-init/spec.md:
- Added Cline and CodeBuddy Code configuration scenarios
- Added slash command scenarios for CodeBuddy Code, Cline, Crush, Factory Droid

Changes to cli-update/spec.md:
- Added update scenarios for CodeBuddy Code, Cline, Crush, Factory Droid
- Added Archive Command Argument Support requirement
- Modified OpenCode scenario to support $ARGUMENTS placeholder

This required manual intervention to prevent data loss from OpenSpec's
requirement-level replacement during archiving. The archive operations
initially overwrote scenarios from earlier changes, requiring restoration
of all missing content to preserve complete spec state.

All four changes successfully archived to openspec/changes/archive/.

* Add changeset for AI assistants support and validation fixes
2025-10-22 17:03:48 +11:00
Tabish Bidiwale fef961f6e3 fix: manually merge and archive four completed OpenSpec changes (#226)
Merged and archived four concurrent changes that modified the same specs:
- add-cline-support: Added Cline AI tool configuration
- add-crush-support: Added Crush AI tool configuration
- add-factory-slash-commands: Added Factory Droid slash commands
- add-archive-command-arguments: Added archive command argument support

Changes to cli-init/spec.md:
- Added Cline and CodeBuddy Code configuration scenarios
- Added slash command scenarios for CodeBuddy Code, Cline, Crush, Factory Droid

Changes to cli-update/spec.md:
- Added update scenarios for CodeBuddy Code, Cline, Crush, Factory Droid
- Added Archive Command Argument Support requirement
- Modified OpenCode scenario to support $ARGUMENTS placeholder

This required manual intervention to prevent data loss from OpenSpec's
requirement-level replacement during archiving. The archive operations
initially overwrote scenarios from earlier changes, requiring restoration
of all missing content to preserve complete spec state.

All four changes successfully archived to openspec/changes/archive/.
2025-10-22 16:07:28 +11:00
jasonwang82andJason Wang 3677e0175f feat: add CodeBuddy Code support to configuration and documentation (#217)
Co-authored-by: Jason Wang <you@example.com>
2025-10-21 16:12:46 +11:00
Tabish Bidiwale 3ddf2586b4 feat: add CodeRabbit AI assistant support (#221)
Add CodeRabbit configuration to enable AI-powered code reviews with customized review settings, path-specific instructions, and chat configuration.
2025-10-21 14:04:47 +11:00
153 changed files with 15390 additions and 227 deletions
+11
View File
@@ -0,0 +1,11 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
# Minimal configuration for getting started
language: "en-US"
reviews:
profile: "chill"
high_level_summary: true
auto_review:
enabled: true
drafts: false
base_branches:
- ".*"
+3
View File
@@ -142,6 +142,9 @@ jobs:
- name: Type check
run: pnpm exec tsc --noEmit
- name: Lint
run: pnpm lint
- name: Check for build artifacts
run: |
if [ ! -d "dist" ]; then
+3 -5
View File
@@ -7,6 +7,7 @@ on:
permissions:
contents: write
pull-requests: write
id-token: write # Required for npm OIDC trusted publishing
concurrency:
group: release-${{ github.ref }}
@@ -27,11 +28,9 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '24' # Node 24 includes npm 11.5.1+ required for OIDC
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org'
scope: '@fission-ai'
always-auth: true
- run: pnpm install --frozen-lockfile
@@ -46,5 +45,4 @@ jobs:
publish: pnpm run release:ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# npm authentication handled via OIDC trusted publishing (no token needed)
+1 -3
View File
@@ -140,8 +140,6 @@ dist/
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Internal Docs
docs/
# Claude
.claude/
@@ -149,4 +147,4 @@ CLAUDE.md
.DS_Store
# Pnpm
.pnpm-store/
.pnpm-store/
+113
View File
@@ -1,5 +1,118 @@
# @fission-ai/openspec
## 0.17.2
### Patch Changes
- 455c65f: Fix `--no-interactive` flag in validate command to properly disable spinner, preventing hangs in pre-commit hooks and CI environments
## 0.17.1
### Patch Changes
- a2757e7: Fix pre-commit hook hang issue in config command by using dynamic import for @inquirer/prompts
The config command was causing pre-commit hooks to hang indefinitely due to stdin event listeners being registered at module load time. This fix converts the static import to a dynamic import that only loads inquirer when the `config reset` command is actually used interactively.
Also adds ESLint with a rule to prevent static @inquirer imports, avoiding future regressions.
## 0.17.0
### Minor Changes
- 2e71835: ### New Features
- Add `openspec config` command for managing global configuration settings
- Implement global config directory with XDG Base Directory specification support
- Add Oh-my-zsh shell completions support for enhanced CLI experience
### Bug Fixes
- Fix hang in pre-commit hooks by using dynamic imports
- Respect XDG_CONFIG_HOME environment variable on all platforms
- Resolve Windows compatibility issues in zsh-installer tests
- Align cli-completion spec with implementation
- Remove hardcoded agent field from slash commands
### Documentation
- Alphabetize AI tools list in README and make it collapsible
## 0.16.0
### Minor Changes
- c08fbc1: Add new AI tool integrations and enhancements:
- **feat(iflow-cli)**: Add iFlow-cli integration with slash command support and documentation
- **feat(init)**: Add IDE restart instruction after init to inform users about slash command availability
**feat(antigravity)**: Add Antigravity slash command support
- **fix**: Generate TOML commands for Qwen Code (fixes #293)
- Clarify scaffold proposal documentation and enhance proposal guidelines
- Update proposal guidelines to emphasize design-first approach before implementation
## Unreleased
### Minor Changes
- Add Antigravity slash command support so `openspec init` can generate `.agent/workflows/openspec-*.md` files with description-only frontmatter and `openspec update` refreshes existing workflows alongside Windsurf.
## 0.15.0
### Minor Changes
- 4758c5c: Add support for new AI tools with native slash command integration
- **Gemini CLI**: Add native TOML-based slash command support for Gemini CLI with `.gemini/commands/openspec/` integration
- **RooCode**: Add RooCode integration with configurator, slash commands, and templates
- **Cline**: Fix Cline to use workflows instead of rules for slash commands (`.clinerules/workflows/` paths)
- **Documentation**: Update documentation to reflect new integrations and workflow changes
## 0.14.0
### Minor Changes
- 8386b91: Add support for new AI assistants and configuration improvements
- feat: add Qwen Code support with slash command integration
- feat: add $ARGUMENTS support to apply slash command for dynamic variable passing
- feat: add Qoder CLI support to configuration and documentation
- feat: add CoStrict AI assistant support
- fix: recreate missing openspec template files in extend mode
- fix: prevent false 'already configured' detection for tools
- fix: use change-id as fallback title instead of "Untitled Change"
- docs: add guidance for populating project-level context
- docs: add Crush to supported AI tools in README
## 0.13.0
### Minor Changes
- 668a125: Add support for multiple AI assistants and improve validation
This release adds support for several new AI coding assistants:
- CodeBuddy Code - AI-powered coding assistant
- CodeRabbit - AI code review assistant
- Cline - Claude-powered CLI assistant
- Crush AI - AI assistant platform
- Auggie (Augment CLI) - Code augmentation tool
New features:
- Archive slash command now supports arguments for more flexible workflows
Bug fixes:
- Delta spec validation now handles case-insensitive headers and properly detects empty sections
- Archive validation now correctly honors --no-validate flag and ignores metadata
Documentation improvements:
- Added VS Code dev container configuration for easier development setup
- Updated AGENTS.md with explicit change-id notation
- Enhanced slash commands documentation with restart notes
## 0.12.0
### Minor Changes
+44 -16
View File
@@ -85,31 +85,48 @@ See the full comparison in [How OpenSpec Compares](#how-openspec-compares).
### Supported AI Tools
#### Native Slash Commands
<details>
<summary><strong>Native Slash Commands</strong> (click to expand)</summary>
These tools have built-in OpenSpec commands. Select the OpenSpec integration when prompted.
| Tool | Commands |
|------|----------|
| **Claude Code** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` |
| **Cursor** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
| **Cline** | Rules in `.clinerules/` directory (`.clinerules/openspec-*.md`) |
| **Factory Droid** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.factory/commands/`) |
| **OpenCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
| **Kilo Code** | `/openspec-proposal.md`, `/openspec-apply.md`, `/openspec-archive.md` (`.kilocode/workflows/`) |
| **Windsurf** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.windsurf/workflows/`) |
| **Codex** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (global: `~/.codex/prompts`, auto-installed) |
| **GitHub Copilot** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.github/prompts/`) |
| **Amazon Q Developer** | `@openspec-proposal`, `@openspec-apply`, `@openspec-archive` (`.amazonq/prompts/`) |
| **Antigravity** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.agent/workflows/`) |
| **Auggie (Augment CLI)** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.augment/commands/`) |
| **Claude Code** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` |
| **Cline** | Workflows in `.clinerules/workflows/` directory (`.clinerules/workflows/openspec-*.md`) |
| **CodeBuddy Code (CLI)** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.codebuddy/commands/`) — see [docs](https://www.codebuddy.ai/cli) |
| **Codex** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (global: `~/.codex/prompts`, auto-installed) |
| **CoStrict** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.cospec/openspec/commands/`) — see [docs](https://costrict.ai)|
| **Crush** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.crush/commands/openspec/`) |
| **Cursor** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
| **Factory Droid** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.factory/commands/`) |
| **Gemini CLI** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.gemini/commands/openspec/`) |
| **GitHub Copilot** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.github/prompts/`) |
| **iFlow (iflow-cli)** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.iflow/commands/`) |
| **Kilo Code** | `/openspec-proposal.md`, `/openspec-apply.md`, `/openspec-archive.md` (`.kilocode/workflows/`) |
| **OpenCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` |
| **Qoder (CLI)** | `/openspec:proposal`, `/openspec:apply`, `/openspec:archive` (`.qoder/commands/openspec/`) — see [docs](https://qoder.com/cli) |
| **Qwen Code** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.qwen/commands/`) |
| **RooCode** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.roo/commands/`) |
| **Windsurf** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.windsurf/workflows/`) |
Kilo Code discovers team workflows automatically. Save the generated files under `.kilocode/workflows/` and trigger them from the command palette with `/openspec-proposal.md`, `/openspec-apply.md`, or `/openspec-archive.md`.
#### AGENTS.md Compatible
</details>
<details>
<summary><strong>AGENTS.md Compatible</strong> (click to expand)</summary>
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 |
|-------|
| Amp • Jules • Gemini CLI • Others |
| Amp • Jules • Others |
</details>
### Install & Initialize
@@ -140,7 +157,7 @@ openspec init
```
**What happens during initialization:**
- You'll be prompted to pick any natively supported AI tools (Claude Code, Cursor, OpenCode, etc.); other assistants always rely on the shared `AGENTS.md` stub
- You'll be prompted to pick any natively supported AI tools (Claude Code, CodeBuddy, Cursor, OpenCode, Qoder,etc.); other assistants always rely on the shared `AGENTS.md` stub
- OpenSpec automatically configures slash commands for the tools you choose and always writes a managed `AGENTS.md` hand-off at the project root
- A new `openspec/` directory structure is created in your project
@@ -148,7 +165,18 @@ openspec init
- Primary AI tools can trigger `/openspec` workflows without additional configuration
- Run `openspec list` to verify the setup and view any active changes
- If your coding assistant doesn't surface the new slash commands right away, restart it. Slash commands are loaded at startup,
so a fresh launch ensures they appear.
so a fresh launch ensures they appear
### Optional: Populate Project Context
After `openspec init` completes, you'll receive a suggested prompt to help populate your project context:
```text
Populate your project context:
"Please read openspec/project.md and help me fill it out with details about my project, tech stack, and conventions"
```
Use `openspec/project.md` to define project-level conventions, standards, architectural patterns, and other guidelines that should be followed across all changes.
### Create Your First Change
@@ -215,7 +243,7 @@ Or run the command yourself in terminal:
$ openspec archive add-profile-filters --yes # Archive the completed change without prompts
```
**Note:** Tools with native slash commands (Claude Code, Cursor, Codex) can use the shortcuts shown. All other tools work with natural language requests to "create an OpenSpec proposal", "apply the OpenSpec change", or "archive the change".
**Note:** Tools with native slash commands (Claude Code, CodeBuddy, Cursor, Codex, Qoder, RooCode) can use the shortcuts shown. All other tools work with natural language requests to "create an OpenSpec proposal", "apply the OpenSpec change", or "archive the change".
## Command Reference
@@ -327,7 +355,7 @@ Without specs, AI coding assistants generate code from vague prompts, often miss
1. **Initialize OpenSpec** – Run `openspec init` in your repo.
2. **Start with new features** – Ask your AI to capture upcoming work as change proposals.
3. **Grow incrementally** – Each change archives into living specs that document your system.
4. **Stay flexible** – Different teammates can use Claude Code, Cursor, or any AGENTS.md-compatible tool while sharing the same specs.
4. **Stay flexible** – Different teammates can use Claude Code, CodeBuddy, Cursor, or any AGENTS.md-compatible tool while sharing the same specs.
Run `openspec update` whenever someone switches tools so your agents pick up the latest instructions and slash-command bindings.
+593
View File
@@ -0,0 +1,593 @@
# POC-OpenSpec-Core Analysis
---
## Design Decisions & Terminology
### Philosophy: Not a Workflow System
This system is **not** a workflow engine. It's an **artifact tracker with dependency awareness**.
| What it's NOT | What it IS |
|---------------|------------|
| Linear step-by-step progression | Exploratory, iterative planning |
| Bureaucratic checkpoints | Enablers that unlock possibilities |
| "You must complete step 1 first" | "Here's what you could create now" |
| Form-filling | Fluid document creation |
**Key insight:** Dependencies are *enablers*, not *gates*. You can't meaningfully write a design document if there's no proposal to design from - that's not bureaucracy, it's logic.
### Terminology
| Term | Definition | Example |
|------|------------|---------|
| **Change** | A unit of work being planned (feature, refactor, migration) | `openspec/changes/add-auth/` |
| **Schema** | An artifact graph definition (what artifacts exist, their dependencies) | `spec-driven.yaml` |
| **Artifact** | A node in the graph (a document to create) | `proposal`, `design`, `specs` |
| **Template** | Instructions/guidance for creating an artifact | `templates/proposal.md` |
### Hierarchy
```
Schema (defines) ──→ Artifacts (guided by) ──→ Templates
```
- **Schema** = the artifact graph (what exists, dependencies)
- **Artifact** = a document to produce
- **Template** = instructions for creating that artifact
### Schema Variations
Schemas can vary across multiple dimensions:
| Dimension | Examples |
|-----------|----------|
| Philosophy | `spec-driven`, `tdd`, `prototype-first` |
| Version | `v1`, `v2`, `v3` |
| Language | `en`, `zh`, `es` |
| Custom | `team-alpha`, `experimental` |
### Schema Resolution (XDG Standard)
Schemas follow the XDG Base Directory Specification with a 2-level resolution:
```
1. ${XDG_DATA_HOME}/openspec/schemas/<name>.yaml # Global user override
2. <package>/schemas/<name>.yaml # Built-in defaults
```
**Platform-specific paths:**
- Unix/macOS: `~/.local/share/openspec/schemas/`
- Windows: `%LOCALAPPDATA%/openspec/schemas/`
- All platforms: `$XDG_DATA_HOME/openspec/schemas/` (when set)
**Why XDG?**
- Schemas are workflow definitions (data), not user preferences (config)
- Built-ins baked into package, never auto-copied
- Users customize by creating files in global data dir
- Consistent with modern CLI tooling standards
### Template Inheritance (2 Levels Max)
Templates also use 2-level resolution (to be implemented in Slice 3):
```
1. ${XDG_DATA_HOME}/openspec/schemas/<schema>/templates/<artifact>.md # Schema-specific
2. ${XDG_DATA_HOME}/openspec/templates/<artifact>.md # Shared
3. <package>/templates/<artifact>.md # Built-in fallback
```
**Rules:**
- Schema-specific templates override shared templates
- Shared templates override package built-ins
- A CLI command shows resolved paths (no guessing)
- No inheritance between schemas (copy if you need to diverge)
- Max 2 levels - no deeper inheritance chains
**Why this matters:**
- Avoids "where does this come from?" debugging
- No implicit magic that works until it doesn't
- Clear boundaries between shared and specific
---
## Executive Summary
This is an **artifact tracker with dependency awareness** that guides iterative development through a structured artifact pipeline. The core innovation is using the **filesystem as a database** - artifact completion is detected by file existence, making the system stateless and version-control friendly.
The system answers:
- "What artifacts exist for this change?"
- "What could I create next?" (not "what must I create")
- "What's blocking X?" (informational, not prescriptive)
---
## Core Components
### 1. ArtifactGraph (Slice 1 - COMPLETE)
The dependency graph engine with XDG-compliant schema resolution.
| Responsibility | Approach |
|----------------|----------|
| Model artifacts as a DAG | Artifact with `requires: string[]` |
| Track completion state | `Set<string>` for completed artifacts |
| Calculate build order | Kahn's algorithm (topological sort) |
| Find ready artifacts | Check if all dependencies are in `completed` set |
| Resolve schemas | XDG global → package built-ins |
**Key Data Structures (Zod-validated):**
```typescript
// Zod schemas define types + validation
const ArtifactSchema = z.object({
id: z.string().min(1),
generates: z.string().min(1), // e.g., "proposal.md" or "specs/*.md"
description: z.string(),
template: z.string(), // path to template file
requires: z.array(z.string()).default([]),
});
const SchemaYamlSchema = z.object({
name: z.string().min(1),
version: z.number().int().positive(),
description: z.string().optional(),
artifacts: z.array(ArtifactSchema).min(1),
});
// Derived types
type Artifact = z.infer<typeof ArtifactSchema>;
type SchemaYaml = z.infer<typeof SchemaYamlSchema>;
```
**Key Methods:**
- `resolveSchema(name)` - Load schema with XDG fallback
- `ArtifactGraph.fromSchema(schema)` - Build graph from schema
- `detectState(graph, changeDir)` - Scan filesystem for completion
- `getNextArtifacts(graph, completed)` - Find artifacts ready to create
- `getBuildOrder(graph)` - Topological sort of all artifacts
- `getBlocked(graph, completed)` - Artifacts with unmet dependencies
---
### 2. Change Utilities (Slice 2)
Simple utility functions for programmatic change creation. No class, no abstraction layer.
| Responsibility | Approach |
|----------------|----------|
| Create changes | Create dirs under `openspec/changes/<name>/` with README |
| Name validation | Enforce kebab-case naming |
**Key Paths:**
```
openspec/changes/<name>/ → Change instances with artifacts (project-level)
```
**Key Functions** (`src/utils/change-utils.ts`):
- `createChange(projectRoot, name, description?)` - Create new change directory + README
- `validateChangeName(name)` - Validate kebab-case naming, returns `{ valid, error? }`
**Note:** Existing CLI commands (`ListCommand`, `ChangeCommand`) already handle listing, path resolution, and existence checks. No need to extract that logic - it works fine as-is.
---
### 3. InstructionLoader (Slice 3)
Template resolution and instruction enrichment.
| Responsibility | Approach |
|----------------|----------|
| Resolve templates | XDG 2-level fallback (schema-specific → shared → built-in) |
| Build dynamic context | Gather dependency status, change info |
| Enrich templates | Inject context into base templates |
| Generate status reports | Formatted markdown with progress |
**Key Class - ChangeState:**
```
ChangeState {
changeName: string
changeDir: string
graph: ArtifactGraph
completed: Set<string>
// Methods
getNextSteps(): string[]
getStatus(artifactId): ArtifactStatus
isComplete(): boolean
}
```
**Key Functions:**
- `getTemplatePath(artifactId, schemaName?)` - Resolve with 2-level fallback
- `getEnrichedInstructions(artifactId, projectRoot, changeName?)` - Main entry point
- `getChangeStatus(projectRoot, changeName?)` - Formatted status report
---
### 4. CLI (Slice 4)
User interface layer. **All commands are deterministic** - require explicit `--change` parameter.
| Command | Function | Status |
|---------|----------|--------|
| `status --change <id>` | Show change progress (artifact graph) | **NEW** |
| `next --change <id>` | Show artifacts ready to create | **NEW** |
| `instructions <artifact> --change <id>` | Get enriched instructions for artifact | **NEW** |
| `list` | List all changes | EXISTS (`openspec change list`) |
| `new <name>` | Create change | **NEW** (uses `createChange()`) |
| `init` | Initialize structure | EXISTS (`openspec init`) |
| `templates --change <id>` | Show resolved template paths | **NEW** |
**Note:** Commands that operate on a change require `--change`. Missing parameter → error with list of available changes. Agent infers the change from conversation and passes it explicitly.
**Existing CLI commands** (not part of this slice):
- `openspec change list` / `openspec change show <id>` / `openspec change validate <id>`
- `openspec list --changes` / `openspec list --specs`
- `openspec view` (dashboard)
- `openspec init` / `openspec archive <change>`
---
### 5. Claude Commands
Integration layer for Claude Code. **Operational commands only** - artifact creation via natural language.
| Command | Purpose |
|---------|---------|
| `/status` | Show change progress |
| `/next` | Show what's ready to create |
| `/run [artifact]` | Execute a specific step (power users) |
| `/list` | List all changes |
| `/new <name>` | Create a new change |
| `/init` | Initialize structure |
**Artifact creation:** Users say "create the proposal" or "write the tests" in natural language. The agent:
1. Infers change from conversation (confirms if uncertain)
2. Infers artifact from request
3. Calls CLI with explicit `--change` parameter
4. Creates artifact following instructions
This works for ANY artifact in ANY schema - no new slash commands needed when schemas change.
**Note:** Legacy commands (`/openspec-proposal`, `/openspec-apply`, `/openspec-archive`) exist in the main project for backward compatibility but are separate from this architecture.
---
## Component Dependency Graph
```
┌─────────────────────────────────────────────────────────────┐
│ PRESENTATION LAYER │
│ ┌──────────────┐ ┌────────────────────┐ │
│ │ CLI │ ←─shell exec───────│ Claude Commands │ │
│ └──────┬───────┘ └────────────────────┘ │
└─────────┼───────────────────────────────────────────────────┘
│ imports
▼
┌─────────────────────────────────────────────────────────────┐
│ ORCHESTRATION LAYER │
│ ┌────────────────────┐ ┌──────────────────────────┐ │
│ │ InstructionLoader │ │ change-utils (Slice 2) │ │
│ │ (Slice 3) │ │ createChange() │ │
│ └─────────┬──────────┘ │ validateChangeName() │ │
│ │ └──────────────────────────┘ │
└────────────┼────────────────────────────────────────────────┘
│ uses
▼
┌─────────────────────────────────────────────────────────────┐
│ CORE LAYER │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ ArtifactGraph (Slice 1) │ │
│ │ │ │
│ │ Schema Resolution (XDG) ──→ Graph ──→ State Detection│ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
▲
│ reads from
▼
┌─────────────────────────────────────────────────────────────┐
│ PERSISTENCE LAYER │
│ ┌──────────────────┐ ┌────────────────────────────────┐ │
│ │ XDG Schemas │ │ Project Artifacts │ │
│ │ ~/.local/share/ │ │ openspec/changes/<name>/ │ │
│ │ openspec/ │ │ - proposal.md, design.md │ │
│ │ schemas/ │ │ - specs/*.md, tasks.md │ │
│ └──────────────────┘ └────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## Key Design Patterns
### 1. Filesystem as Database
No SQLite, no JSON state files. The existence of `proposal.md` means proposal is complete.
```
// State detection is just file existence checking
if (exists(artifactPath)) {
completed.add(artifactId)
}
```
### 2. Deterministic CLI, Inferring Agent
**CLI layer:** Always deterministic - requires explicit `--change` parameter.
```
openspec status --change add-auth # explicit, works
openspec status # error: "No change specified"
```
**Agent layer:** Infers from conversation, confirms if uncertain, passes explicit `--change`.
This separation means:
- CLI is pure, testable, no state to corrupt
- Agent handles all "smartness"
- No config.yaml tracking of "active change"
### 3. XDG-Compliant Schema Resolution
```
${XDG_DATA_HOME}/openspec/schemas/<name>.yaml # User override
↓ (not found)
<package>/schemas/<name>.yaml # Built-in
↓ (not found)
Error (schema not found)
```
### 4. Two-Level Template Fallback (Slice 3)
```
${XDG_DATA_HOME}/openspec/schemas/<schema>/templates/<artifact>.md # Schema-specific
↓ (not found)
${XDG_DATA_HOME}/openspec/templates/<artifact>.md # Shared
↓ (not found)
<package>/templates/<artifact>.md # Built-in
↓ (not found)
Error (no silent fallback to avoid confusion)
```
### 5. Glob Pattern Support
`specs/*.md` allows multiple files to satisfy a single artifact:
```
if (artifact.generates.includes("*")) {
const parentDir = changeDir / patternParts[0]
if (exists(parentDir) && hasFiles(parentDir)) {
completed.add(artifactId)
}
}
```
### 6. Stateless State Detection
Every command re-scans the filesystem. No cached state to corrupt.
---
## Artifact Pipeline (Default Schema)
The default `spec-driven` schema:
```
┌──────────┐
│ proposal │ (no dependencies)
└────┬─────┘
│
▼
┌──────────┐
│ specs │ (requires: proposal)
└────┬─────┘
│
├──────────────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ design │ │ │
│ │◄──┤ proposal │
└────┬─────┘ └──────────┘
│ (requires: proposal, specs)
▼
┌──────────┐
│ tasks │ (requires: design)
└──────────┘
```
Other schemas (TDD, prototype-first) would have different graphs.
---
## Implementation Order
Structured as **vertical slices** - each slice is independently testable.
---
### Slice 1: "What's Ready?" (Core Query) ✅ COMPLETE
**Delivers:** Types + Graph + State Detection + Schema Resolution
**Implementation:** `src/core/artifact-graph/`
- `types.ts` - Zod schemas and derived TypeScript types
- `schema.ts` - YAML parsing with Zod validation
- `graph.ts` - ArtifactGraph class with topological sort
- `state.ts` - Filesystem-based state detection
- `resolver.ts` - XDG-compliant schema resolution
- `builtin-schemas.ts` - Package-bundled default schemas
**Key decisions made:**
- Zod for schema validation (consistent with project)
- XDG for global schema overrides
- `Set<string>` for completion state (immutable, functional)
- `inProgress` and `failed` states deferred (require external tracking)
---
### Slice 2: "Change Creation Utilities"
**Delivers:** Utility functions for programmatic change creation
**Scope:**
- `createChange(projectRoot, name, description?)` → creates directory + README
- `validateChangeName(name)` → kebab-case pattern enforcement
**Not in scope (already exists in CLI commands):**
- `listChanges()` → exists in `ListCommand` and `ChangeCommand.getActiveChanges()`
- `getChangePath()` → simple `path.join()` inline
- `changeExists()` → simple `fs.access()` inline
- `isInitialized()` → simple directory check inline
**Why simplified:** Extracting existing CLI logic into a class would require similar refactoring of `SpecCommand` for consistency. The existing code works fine (~15 lines each). Only truly new functionality is `createChange()` + name validation.
---
### Slice 3: "Get Instructions" (Enrichment)
**Delivers:** Template resolution + context injection
**Testable behaviors:**
- Template fallback: schema-specific → shared → built-in → error
- Context injection: completed deps show ✓, missing show ✗
- Output path shown correctly based on change directory
---
### Slice 4: "CLI + Integration"
**Delivers:** New artifact graph commands (builds on existing CLI)
**New commands:**
- `status --change <id>` - Show artifact completion state
- `next --change <id>` - Show ready-to-create artifacts
- `instructions <artifact> --change <id>` - Get enriched template
- `templates --change <id>` - Show resolved paths
- `new <name>` - Create change (wrapper for `createChange()`)
**Already exists (not in scope):**
- `openspec change list/show/validate` - change management
- `openspec list --changes/--specs` - listing
- `openspec view` - dashboard
- `openspec init` - initialization
**Testable behaviors:**
- Each new command produces expected output
- Commands compose correctly (status → next → instructions flow)
- Error handling for missing changes, invalid artifacts, etc.
---
## Directory Structure
```
# Global (XDG paths - user overrides)
~/.local/share/openspec/ # Unix/macOS ($XDG_DATA_HOME/openspec/)
%LOCALAPPDATA%/openspec/ # Windows
├── schemas/ # Schema overrides
│ └── custom-workflow.yaml # User-defined schema
└── templates/ # Template overrides (Slice 3)
└── proposal.md # Custom proposal template
# Package (built-in defaults)
<package>/
├── schemas/ # Built-in schema definitions
│ ├── spec-driven.yaml # Default: proposal → specs → design → tasks
│ └── tdd.yaml # TDD: tests → implementation → docs
└── templates/ # Built-in templates (Slice 3)
├── proposal.md
├── design.md
├── specs.md
└── tasks.md
# Project (change instances)
openspec/
└── changes/ # Change instances
├── add-auth/
│ ├── README.md # Auto-generated on creation
│ ├── proposal.md # Created artifacts
│ ├── design.md
│ └── specs/
│ └── *.md
├── refactor-db/
│ └── ...
└── archive/ # Completed changes
└── 2025-01-01-add-auth/
.claude/
├── settings.local.json # Permissions
└── commands/ # Slash commands
└── *.md
```
---
## Schema YAML Format
```yaml
# Built-in: <package>/schemas/spec-driven.yaml
# Or user override: ~/.local/share/openspec/schemas/spec-driven.yaml
name: spec-driven
version: 1
description: Specification-driven development
artifacts:
- id: proposal
generates: "proposal.md"
description: "Create project proposal document"
template: "proposal.md" # resolves via 2-level fallback (Slice 3)
requires: []
- id: specs
generates: "specs/*.md" # glob pattern
description: "Create technical specification documents"
template: "specs.md"
requires:
- proposal
- id: design
generates: "design.md"
description: "Create design document"
template: "design.md"
requires:
- proposal
- specs
- id: tasks
generates: "tasks.md"
description: "Create tasks breakdown document"
template: "tasks.md"
requires:
- design
```
---
## Summary
| Layer | Component | Responsibility | Status |
|-------|-----------|----------------|--------|
| Core | ArtifactGraph | Pure dependency logic + XDG schema resolution | ✅ Slice 1 COMPLETE |
| Utils | change-utils | Change creation + name validation only | Slice 2 (new functionality only) |
| Core | InstructionLoader | Template resolution + enrichment | Slice 3 (all new) |
| Presentation | CLI | New artifact graph commands | Slice 4 (new commands only) |
| Integration | Claude Commands | AI assistant glue | Slice 4 |
**What already exists (not in this proposal):**
- `getActiveChangeIds()` in `src/utils/item-discovery.ts` - list changes
- `ChangeCommand.list/show/validate()` in `src/commands/change.ts`
- `ListCommand.execute()` in `src/core/list.ts`
- `ViewCommand.execute()` in `src/core/view.ts` - dashboard
- `src/core/init.ts` - initialization
- `src/core/archive.ts` - archiving
**Key Principles:**
- **Filesystem IS the database** - stateless, version-control friendly
- **Dependencies are enablers** - show what's possible, don't force order
- **Deterministic CLI, inferring agent** - CLI requires explicit `--change`, agent infers from context
- **XDG-compliant paths** - schemas and templates use standard user data directories
- **2-level inheritance** - user override → package built-in (no deeper)
- **Schemas are versioned** - support variations by philosophy, version, language
+42
View File
@@ -0,0 +1,42 @@
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
files: ['src/**/*.ts'],
extends: [...tseslint.configs.recommended],
rules: {
// Prevent static imports of @inquirer modules to avoid pre-commit hook hangs.
// These modules have side effects that can keep the Node.js event loop alive
// when stdin is piped. Use dynamic import() instead.
// See: https://github.com/Fission-AI/OpenSpec/issues/367
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@inquirer/*'],
message:
'Use dynamic import() for @inquirer modules to prevent pre-commit hook hangs. See #367.',
},
],
},
],
// Disable rules that need broader cleanup - focus on critical issues only
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'no-empty': 'off',
'prefer-const': 'off',
},
},
{
// init.ts is dynamically imported from cli/index.ts, so static @inquirer
// imports there are safe - they won't be loaded at CLI startup
files: ['src/core/init.ts'],
rules: {
'no-restricted-imports': 'off',
},
},
{
ignores: ['dist/**', 'node_modules/**', '*.js', '*.mjs'],
}
);
+1 -3
View File
@@ -49,9 +49,7 @@ _Outcome:_ We prevent data loss immediately while we work on a richer merge stor
- On conflict, write conflict markers inside the change delta (similar to Git) and require the author to hand-edit before re-running validation.
2. **Enrich validator messages.**
- `openspec validate` should flag unresolved conflict markers or fingerprint mismatches so errors appear early in the workflow.
3. **Improve diff tooling.**
- Extend `openspec diff` to compare change deltas against the live spec and highlight pending merges.
4. **Optional:** Offer a `--rewrite-scenarios` helper that merges bullet lists of scenarios to reduce manual editing noise.
3. **Optional:** Offer a `--rewrite-scenarios` helper that merges bullet lists of scenarios to reduce manual editing noise.
_Outcome:_ Contributors can safely reconcile their work with the latest spec before archiving, restoring true parallel development.
-2
View File
@@ -95,7 +95,6 @@ After deployment, create separate PR to:
openspec list # List active changes
openspec list --specs # List specifications
openspec show [item] # Display change or spec
openspec diff [change] # Show spec differences
openspec validate [item] # Validate changes or specs
openspec archive <change-id> [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
@@ -448,7 +447,6 @@ Only add complexity with:
```bash
openspec list # What's in progress?
openspec show [item] # View details
openspec diff [change] # What's changing?
openspec validate --strict # Is it correct?
openspec archive <change-id> [--yes|-y] # Mark complete (add --yes for automation)
```
@@ -0,0 +1,11 @@
## Why
Google is rolling out Antigravity, a Windsurf-derived IDE that discovers workflows from `.agent/workflows/*.md`. Today OpenSpec can only scaffold slash commands for Windsurf directories, so Antigravity users cannot run the proposal/apply/archive flows from the IDE.
## What Changes
- Add Antigravity as a selectable native tool in `openspec init` so it creates `.agent/workflows/openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` with YAML frontmatter containing only a `description` field plus the standard OpenSpec-managed body.
- Ensure `openspec update` refreshes the body of any existing Antigravity workflows inside `.agent/workflows/` without creating missing files, mirroring the Windsurf behavior.
- Share e2e/template coverage confirming the generator writes the proper directory, filename casing, and frontmatter format so Antigravity picks up the workflows.
## Impact
- Affected specs: `specs/cli-init`, `specs/cli-update`
- Expected code: CLI init/update tool registries, slash-command templates, associated tests
@@ -0,0 +1,9 @@
## MODIFIED Requirements
### Requirement: Slash Command Configuration
The init command SHALL generate slash command files for supported editors using shared templates.
#### Scenario: Generating slash commands for Antigravity
- **WHEN** the user selects Antigravity during initialization
- **THEN** create `.agent/workflows/openspec-proposal.md`, `.agent/workflows/openspec-apply.md`, and `.agent/workflows/openspec-archive.md`
- **AND** ensure each file begins with YAML frontmatter that contains only a `description: <stage summary>` field followed by the shared OpenSpec workflow instructions wrapped in managed markers
- **AND** populate the workflow body with the same proposal/apply/archive guidance used for other tools so Antigravity behaves like Windsurf while pointing to the `.agent/workflows/` directory
@@ -0,0 +1,8 @@
## MODIFIED Requirements
### Requirement: Slash Command Updates
The update command SHALL refresh existing slash command files for configured tools without creating new ones, and ensure the OpenCode archive command accepts change ID arguments.
#### Scenario: Updating slash commands for Antigravity
- **WHEN** `.agent/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh the OpenSpec-managed portion of each file so the workflow copy matches other tools while preserving the existing single-field `description` frontmatter
- **AND** skip creating any missing workflow files during update, mirroring the behavior for Windsurf and other IDEs
@@ -0,0 +1,12 @@
## 1. CLI init support
- [x] 1.1 Surface Antigravity in the native-tool picker (interactive + `--tools`) so it toggles alongside other IDEs.
- [x] 1.2 Generate `.agent/workflows/openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` with YAML frontmatter restricted to a single `description` field for each stage and wrap the body in OpenSpec markers.
- [x] 1.3 Confirm workspace scaffolding covers missing directory creation and re-run scenarios so repeated init refreshes the managed block.
## 2. CLI update support
- [x] 2.1 Detect existing Antigravity workflow files during `openspec update` and refresh only the managed body, skipping creation when files are missing.
- [x] 2.2 Ensure update logic preserves the `description` frontmatter block exactly as written by init, including case and spacing, and refreshes body templates alongside other tools.
## 3. Templates and tests
- [x] 3.1 Add shared template entries for Antigravity that reuse the Windsurf copy but target `.agent/workflows` plus the description-only frontmatter requirement.
- [x] 3.2 Expand automated coverage (unit or integration) verifying init and update produce the expected file paths and frontmatter + body markers for Antigravity.
@@ -1,27 +0,0 @@
## ADDED Requirements
### Requirement: Cline Tool Support
The system SHALL provide Cline (VS Code extension) as a supported tool option during OpenSpec initialization.
#### Scenario: Initialize project with Cline support
- **WHEN** user runs `openspec init --tools cline`
- **THEN** Cline-specific rule files are configured in `.clinerules/`
- **AND** CLINE.md root file includes OpenSpec workflow instructions
- **AND** Cline is registered as available configurator
#### Scenario: Cline proposal rule generation
- **WHEN** Cline rules are configured
- **THEN** `.clinerules/openspec-proposal.md` contains proposal workflow with guardrails
- **AND** Includes Cline-specific Markdown heading frontmatter
- **AND** Follows established slash command template pattern
#### Scenario: Cline apply and archive rules
- **WHEN** Cline rules are configured
- **THEN** `.clinerules/openspec-apply.md` contains implementation workflow
- **AND** `.clinerules/openspec-archive.md` contains archiving workflow
- **AND** Both commands include appropriate headers and references
#### Scenario: Cline root instructions
- **WHEN** Cline is selected during initialization
- **THEN** CLINE.md is created at project root
- **AND** Contains OpenSpec markers for managed content
- **AND** References `@/openspec/AGENTS.md` for workflow instructions
@@ -1,21 +0,0 @@
## ADDED Requirements
### Requirement: Crush Tool Support
The system SHALL provide Crush AI assistant as a supported tool option during OpenSpec initialization.
#### Scenario: Initialize project with Crush support
- **WHEN** user runs `openspec init --tool crush`
- **THEN** Crush-specific slash commands are configured in `.crush/commands/openspec/`
- **AND** Crush AGENTS.md includes OpenSpec workflow instructions
- **AND** Crush is registered as available configurator
#### Scenario: Crush proposal command generation
- **WHEN** Crush slash commands are configured
- **THEN** `.crush/commands/openspec/proposal.md` contains proposal workflow with guardrails
- **AND** Includes Crush-specific frontmatter with OpenSpec category and tags
- **AND** Follows established slash command template pattern
#### Scenario: Crush apply and archive commands
- **WHEN** Crush slash commands are configured
- **THEN** `.crush/commands/openspec/apply.md` contains implementation workflow
- **AND** `.crush/commands/openspec/archive.md` contains archiving workflow
- **AND** Both commands include appropriate frontmatter and references
@@ -0,0 +1,149 @@
## Context
This is Slice 3 of the artifact-graph POC. We have:
- `ArtifactGraph` class with graph operations (Slice 1)
- `detectCompleted()` for filesystem-based state detection (Slice 1)
- `resolveSchema()` for XDG schema resolution (Slice 1)
- `createChange()` and `validateChangeName()` utilities (Slice 2)
After `restructure-schema-directories` is implemented, schemas will be self-contained directories:
```
schemas/<name>/
├── schema.yaml
└── templates/
└── *.md
```
This proposal adds template loading and instruction enrichment on top of that structure.
## Goals / Non-Goals
**Goals:**
- Load templates from schema directories
- Enrich templates with change-specific context (dependency status)
- Format change status for CLI output
**Non-Goals:**
- Template authoring UI
- Dynamic template compilation/execution
- Caching (keep it stateless like the rest)
## Decisions
### 1. Pure functions over classes
Follow the pattern in `resolver.ts` and `state.ts`. Use a simple `ChangeContext` interface with pure functions:
```typescript
interface ChangeContext {
changeName: string;
changeDir: string;
schemaName: string;
graph: ArtifactGraph;
completed: CompletedSet;
}
function loadChangeContext(projectRoot: string, changeName: string, schemaName?: string): ChangeContext
function loadTemplate(schemaName: string, templatePath: string): string
function getInstructions(artifactId: string, context: ChangeContext): string
function formatStatus(context: ChangeContext): string
```
**Why:** Matches existing codebase patterns. Easier to test. No hidden state.
### 2. Template resolution from schema directory
Templates are loaded from the schema's `templates/` subdirectory:
```typescript
function loadTemplate(schemaName: string, templatePath: string): string {
const schemaDir = getSchemaDir(schemaName); // From resolver.ts
const fullPath = path.join(schemaDir, 'templates', templatePath);
return fs.readFileSync(fullPath, 'utf-8');
}
```
Resolution is handled by `getSchemaDir()` which already checks user override → package built-in.
**Why:** Leverages existing schema resolution. Templates are co-located with schemas.
### 3. Template path from artifact definition
The artifact's `template` field is a path relative to the schema's `templates/` directory:
```yaml
artifacts:
- id: proposal
template: "proposal.md" # → schemas/<schema>/templates/proposal.md
```
**Why:** Explicit, simple, no magic.
### 4. Minimal context injection
Templates are markdown. Injection prepends a header section with context:
```markdown
---
change: add-auth
artifact: proposal
schema: spec-driven
output: openspec/changes/add-auth/proposal.md
---
## Dependencies
- [x] (none - this is a root artifact)
## Next Steps
After creating this artifact, you can work on: design, specs
---
[original template content...]
```
**Why:** Simple string concatenation. No template engine dependency. Clear separation.
### 5. Status output format
```markdown
## Change: add-auth (spec-driven)
| Artifact | Status | Output |
|----------|--------|--------|
| proposal | done | proposal.md |
| specs | ready | specs/*.md |
| design | blocked (needs: proposal) | design.md |
| tasks | blocked (needs: specs, design) | tasks.md |
```
**Why:** Markdown table is readable in terminal and docs. Matches CLI output style.
## File Structure
```
src/core/artifact-graph/
├── index.ts # Add new exports
├── template.ts # NEW: Template loading
├── context.ts # NEW: ChangeContext loading
└── instructions.ts # NEW: Enrichment and formatting
```
## Risks / Trade-offs
**Dependency on restructure-schema-directories:**
- This proposal requires the schema restructure to be done first
- Mitigation: Clear dependency documented, implement in order
**No template engine:**
- Pro: Zero dependencies, simple code
- Con: Limited expressiveness
- Mitigation: Current use case only needs static templates + header injection
## Migration Plan
N/A - new capability, no existing code to migrate.
## Open Questions
None.
@@ -0,0 +1,20 @@
## Why
Slice 1 (artifact-graph) provides graph operations and state detection. Slice 2 (change-utils) provides change creation. We now need the ability to load templates for artifacts and enrich them with change-specific context so users/agents know what to create next.
## What Changes
- Add template resolution from schema directories (uses structure from `restructure-schema-directories`)
- Add instruction enrichment that injects change context into templates
- Add status formatting for CLI output
- New `instruction-loader` capability
## Dependencies
- Requires `restructure-schema-directories` to be implemented first (schemas as directories with co-located templates)
## Impact
- Affected specs: New `instruction-loader` spec
- Affected code: `src/core/artifact-graph/` (new files)
- Builds on: `artifact-graph` (Slice 1), uses `ArtifactGraph`, `detectCompleted`, `resolveSchema`
@@ -0,0 +1,65 @@
## ADDED Requirements
### Requirement: Template Loading
The system SHALL load templates from schema directories.
#### Scenario: Template loaded from schema directory
- **WHEN** `loadTemplate(schemaName, templatePath)` is called
- **THEN** the system loads the template from `schemas/<schemaName>/templates/<templatePath>`
#### Scenario: Template not found
- **WHEN** a template file does not exist in the schema's templates directory
- **THEN** the system throws an error with the template path
### Requirement: Change Context Loading
The system SHALL load change context combining graph and completion state.
#### Scenario: Load context for existing change
- **WHEN** `loadChangeContext(projectRoot, changeName)` is called for an existing change
- **THEN** the system returns a context with graph, completed set, schema name, and change info
#### Scenario: Load context with custom schema
- **WHEN** `loadChangeContext(projectRoot, changeName, schemaName)` is called
- **THEN** the system uses the specified schema instead of default
#### Scenario: Load context for missing change
- **WHEN** `loadChangeContext` is called for a non-existent change directory
- **THEN** the system returns context with empty completed set
### Requirement: Instruction Enrichment
The system SHALL enrich templates with change-specific context.
#### Scenario: Header with change info
- **WHEN** instructions are generated for an artifact
- **THEN** the output includes change name, artifact ID, schema name, and output path
#### Scenario: Dependency status shown
- **WHEN** an artifact has dependencies
- **THEN** the output shows each dependency with completion status (done/missing)
#### Scenario: Next steps shown
- **WHEN** instructions are generated
- **THEN** the output includes which artifacts become available after this one
#### Scenario: Root artifact dependencies
- **WHEN** an artifact has no dependencies
- **THEN** the dependency section indicates this is a root artifact
### Requirement: Status Formatting
The system SHALL format change status as readable output.
#### Scenario: Format complete change
- **WHEN** all artifacts are completed
- **THEN** status shows all artifacts as "done"
#### Scenario: Format partial change
- **WHEN** some artifacts are completed
- **THEN** status shows completed as "done", ready as "ready", blocked as "blocked"
#### Scenario: Show blocked dependencies
- **WHEN** an artifact is blocked
- **THEN** status shows which dependencies are missing
#### Scenario: Show output paths
- **WHEN** status is formatted
- **THEN** each artifact shows its output path pattern
@@ -0,0 +1,34 @@
## 1. Template Loading
- [ ] 1.1 Create `src/core/artifact-graph/template.ts`
- [ ] 1.2 Implement `loadTemplate(schemaName, templatePath)` using schema directory structure
- [ ] 1.3 Add tests for template loading from schema directory
- [ ] 1.4 Add tests for error when template not found
## 2. Change Context
- [ ] 2.1 Create `src/core/artifact-graph/context.ts`
- [ ] 2.2 Define `ChangeContext` interface
- [ ] 2.3 Implement `loadChangeContext()` function
- [ ] 2.4 Add tests for context loading with existing change
- [ ] 2.5 Add tests for context loading with missing change directory
## 3. Instruction Enrichment
- [ ] 3.1 Create `src/core/artifact-graph/instructions.ts`
- [ ] 3.2 Implement `getInstructions()` with header injection
- [ ] 3.3 Add dependency status formatting (done/missing)
- [ ] 3.4 Add next steps calculation
- [ ] 3.5 Add tests for enrichment output
## 4. Status Formatting
- [ ] 4.1 Implement `formatStatus()` function in instructions.ts
- [ ] 4.2 Format as markdown table with status and output path
- [ ] 4.3 Show blocked dependencies
- [ ] 4.4 Add tests for status formatting
## 5. Integration
- [ ] 5.1 Export new functions from `src/core/artifact-graph/index.ts`
- [ ] 5.2 Ensure all tests pass
@@ -2,9 +2,9 @@
Manual setup for new changes leads to formatting mistakes in spec deltas and slows agents who must recreate the same file skeletons for every proposal. A built-in scaffold command will generate compliant templates so assistants can focus on the change content instead of structure.
## What Changes
- Add an `openspec scaffold <change-id>` CLI command that creates a change directory with validated `proposal.md`, `tasks.md`, and spec delta templates.
- Update CLI documentation and quick-reference guidance so agents discover the scaffold workflow before drafting files manually.
- Add automated coverage (unit/integ tests) to ensure the command respects existing naming rules and generated Markdown passes validation.
- Add an `openspec scaffold <change-id>` CLI command that creates a change directory (if it does not already exist) with validated `proposal.md`, `tasks.md`, and spec delta templates.
- Update CLI documentation and quick-reference guidance so agents discover the scaffold workflow before drafting files manually, including reminders on when to create spec deltas.
- Add automated coverage (unit/integ tests) to ensure the command respects naming rules, copies templates correctly, fails for existing directories, and produces output that passes `openspec validate --strict` untouched.
## Impact
- Affected specs: `specs/cli-scaffold`
@@ -9,13 +9,13 @@ The CLI SHALL expose an `openspec scaffold <change-id>` command that validates t
- **AND** exit with code 0 after successful scaffolding
### Requirement: Change Directory Structure
The scaffold command SHALL create the standard change workspace with proposal, tasks, optional design, and delta directories laid out according to OpenSpec conventions.
The scaffold command SHALL create the standard change workspace (if it does not already exist) with proposal, tasks, optional design, and `specs/` directories laid out according to OpenSpec conventions.
#### Scenario: Generating change workspace
- **WHEN** scaffolding a new change with id `add-user-notifications`
- **THEN** create `openspec/changes/add-user-notifications/`
- **AND** generate `proposal.md`, `tasks.md`, and `design.md` (commented placeholder content) in that directory when missing
- **AND** create `openspec/changes/add-user-notifications/specs/` ready for capability-specific deltas
- **THEN** create `openspec/changes/add-user-notifications/` if it does not exist
- **AND** copy the default template bundle (proposal, tasks, design placeholders) into that directory in a single operation
- **AND** create an empty `openspec/changes/add-user-notifications/specs/` directory ready for capability-specific deltas that will be authored later
### Requirement: Template Content Guidance
The scaffold command SHALL populate generated Markdown files with OpenSpec-compliant templates so authors can copy, edit, and pass validation without reformatting.
@@ -25,15 +25,7 @@ The scaffold command SHALL populate generated Markdown files with OpenSpec-compl
- **THEN** include the `## Why`, `## What Changes`, and `## Impact` headings with placeholder guidance text
- **AND** ensure `tasks.md` starts with `## 1. Implementation` and numbered checklist items using `- [ ]` syntax
- **AND** annotate optional sections (like `design.md`) with inline TODO comments so users understand when to keep or delete them
### Requirement: Delta Spec Creation
The scaffold command SHALL create at least one capability delta file with correctly formatted requirement and scenario placeholders that guide authors to enter the actual behavior.
#### Scenario: Creating spec delta skeleton
- **WHEN** scaffolding a change and the capability `cli-scaffold` is provided interactively or via flags
- **THEN** generate `openspec/changes/add-user-notifications/specs/cli-scaffold/spec.md`
- **AND** include `## ADDED Requirements` with at least one `### Requirement:` block and matching `#### Scenario:` entries that remind the author to replace placeholder text
- **AND** ensure the generated delta passes `openspec validate add-user-notifications --strict` until the author edits it
- **AND** include a short reminder inside `specs/README.md` (or similar) instructing authors to add deltas once they know the affected capability
### Requirement: Idempotent Execution
The scaffold command SHALL be safe to rerun, preserving user edits while filling in any missing managed sections.
@@ -1,11 +1,12 @@
## 1. CLI scaffolding command
- [ ] 1.1 Register an `openspec scaffold` command in the CLI entrypoint with `change-id` argument validation.
- [ ] 1.2 Implement generator logic that creates the change directory structure plus default `proposal.md`, `tasks.md`, and delta spec skeletons without overwriting existing populated files.
- [ ] 1.2 Implement generator logic that copies the default change template bundle (`proposal.md`, `tasks.md`, optional `design.md`, `specs/README.md`) into `openspec/changes/<id>/`, creating the directory tree in a single pass.
- [ ] 1.3 Detect when `openspec/changes/<id>/` already exists and exit with a clear error instead of overwriting user files.
## 2. Templates and documentation
- [ ] 2.1 Surface copy/paste templates and scaffold usage in the top-level quick reference for `openspec/AGENTS.md`.
- [ ] 2.2 Refresh other CLI docs (`docs/`, README) to mention the scaffold workflow and link to instructions.
- [ ] 2.1 Update `openspec/AGENTS.md` quick reference so agents see `openspec scaffold` before drafting files manually.
- [ ] 2.2 Refresh CLI docs/README/help text to mention the scaffold workflow, template bundle contents, and when to add spec deltas manually.
## 3. Test coverage
- [ ] 3.1 Add unit tests covering name validation, file generation, and idempotent reruns.
- [ ] 3.2 Add integration coverage ensuring generated files pass `openspec validate --strict` without manual edits.
- [ ] 3.1 Add unit tests covering name validation, template copying, and existing-directory failures.
- [ ] 3.2 Add integration coverage ensuring a freshly scaffolded change (without deltas) passes `openspec validate --strict` until the author customizes it.
@@ -11,8 +11,6 @@ The update command SHALL refresh existing slash command files for configured too
- **AND** ensure templates include instructions for the relevant workflow stage
- **AND** ensure the archive command includes `$ARGUMENTS` placeholder in frontmatter for accepting change ID arguments
## ADDED Requirements
### Requirement: Archive Command Argument Support
The archive slash command template SHALL support optional change ID arguments for tools that support `$ARGUMENTS` placeholder.
@@ -13,9 +13,3 @@
## 3. Update Documentation
- [x] 3.1 Update AGENTS.md archive examples to show argument usage
- [x] 3.2 Document that OpenCode now supports `/openspec:archive <change-id>`
## 4. Validation and Testing
- [ ] 4.1 Run `openspec update` to regenerate OpenCode slash commands
- [ ] 4.2 Manually test with OpenCode using `/openspec:archive <change-id>`
- [ ] 4.3 Test backward compatibility (archive command without arguments)
- [ ] 4.4 Run `openspec validate --strict` to ensure no issues
@@ -0,0 +1,97 @@
## MODIFIED Requirements
### Requirement: AI Tool Configuration Details
The command SHALL properly configure selected AI tools with OpenSpec-specific instructions using a marker system.
#### Scenario: Configuring Claude Code
- **WHEN** Claude Code is selected
- **THEN** create or update `CLAUDE.md` in the project root directory (not inside openspec/)
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
#### Scenario: Configuring CodeBuddy Code
- **WHEN** CodeBuddy Code is selected
- **THEN** create or update `CODEBUDDY.md` in the project root directory (not inside openspec/)
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
#### Scenario: Configuring Cline
- **WHEN** Cline is selected
- **THEN** create or update `CLINE.md` in the project root directory (not inside openspec/)
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
#### Scenario: Creating new CLAUDE.md
- **WHEN** CLAUDE.md does not exist
- **THEN** create new file with stub instructions wrapped in markers so the full workflow stays in `openspec/AGENTS.md`:
```markdown
<!-- OPENSPEC:START -->
# OpenSpec Instructions
This project uses OpenSpec to manage AI assistant workflows.
- Full guidance lives in '@/openspec/AGENTS.md'.
- Keep this managed block so 'openspec update' can refresh the instructions.
<!-- OPENSPEC:END -->
```
### Requirement: Slash Command Configuration
The init command SHALL generate slash command files for supported editors using shared templates.
#### Scenario: Generating slash commands for Claude Code
- **WHEN** the user selects Claude Code during initialization
- **THEN** create `.claude/commands/openspec/proposal.md`, `.claude/commands/openspec/apply.md`, and `.claude/commands/openspec/archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for CodeBuddy Code
- **WHEN** the user selects CodeBuddy Code during initialization
- **THEN** create `.codebuddy/commands/openspec/proposal.md`, `.codebuddy/commands/openspec/apply.md`, and `.codebuddy/commands/openspec/archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Cline
- **WHEN** the user selects Cline during initialization
- **THEN** create `.clinerules/openspec-proposal.md`, `.clinerules/openspec-apply.md`, and `.clinerules/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** include Cline-specific Markdown heading frontmatter
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Cursor
- **WHEN** the user selects Cursor during initialization
- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for OpenCode
- **WHEN** the user selects OpenCode during initialization
- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Windsurf
- **WHEN** the user selects Windsurf during initialization
- **THEN** create `.windsurf/workflows/openspec-proposal.md`, `.windsurf/workflows/openspec-apply.md`, and `.windsurf/workflows/openspec-archive.md`
- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Kilo Code
- **WHEN** the user selects Kilo Code during initialization
- **THEN** create `.kilocode/workflows/openspec-proposal.md`, `.kilocode/workflows/openspec-apply.md`, and `.kilocode/workflows/openspec-archive.md`
- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Codex
- **WHEN** the user selects Codex during initialization
- **THEN** create global prompt files at `~/.codex/prompts/openspec-proposal.md`, `~/.codex/prompts/openspec-apply.md`, and `~/.codex/prompts/openspec-archive.md` (or under `$CODEX_HOME/prompts` if set)
- **AND** populate each file from shared templates that map the first numbered placeholder (`$1`) to the primary user input (e.g., change identifier or question text)
- **AND** wrap the generated content in OpenSpec markers so `openspec update` can refresh the prompts without touching surrounding custom notes
#### Scenario: Generating slash commands for GitHub Copilot
- **WHEN** the user selects GitHub Copilot during initialization
- **THEN** create `.github/prompts/openspec-proposal.prompt.md`, `.github/prompts/openspec-apply.prompt.md`, and `.github/prompts/openspec-archive.prompt.md`
- **AND** populate each file with YAML frontmatter containing a `description` field that summarizes the workflow stage
- **AND** include `$ARGUMENTS` placeholder to capture user input
- **AND** wrap the shared template body with OpenSpec markers so `openspec update` can refresh the content
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
@@ -0,0 +1,67 @@
## MODIFIED Requirements
### Requirement: Slash Command Configuration
The init command SHALL generate slash command files for supported editors using shared templates.
#### Scenario: Generating slash commands for Claude Code
- **WHEN** the user selects Claude Code during initialization
- **THEN** create `.claude/commands/openspec/proposal.md`, `.claude/commands/openspec/apply.md`, and `.claude/commands/openspec/archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for CodeBuddy Code
- **WHEN** the user selects CodeBuddy Code during initialization
- **THEN** create `.codebuddy/commands/openspec/proposal.md`, `.codebuddy/commands/openspec/apply.md`, and `.codebuddy/commands/openspec/archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Cline
- **WHEN** the user selects Cline during initialization
- **THEN** create `.clinerules/openspec-proposal.md`, `.clinerules/openspec-apply.md`, and `.clinerules/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** include Cline-specific Markdown heading frontmatter
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Crush
- **WHEN** the user selects Crush during initialization
- **THEN** create `.crush/commands/openspec/proposal.md`, `.crush/commands/openspec/apply.md`, and `.crush/commands/openspec/archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** include Crush-specific frontmatter with OpenSpec category and tags
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Cursor
- **WHEN** the user selects Cursor during initialization
- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for OpenCode
- **WHEN** the user selects OpenCode during initialization
- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Windsurf
- **WHEN** the user selects Windsurf during initialization
- **THEN** create `.windsurf/workflows/openspec-proposal.md`, `.windsurf/workflows/openspec-apply.md`, and `.windsurf/workflows/openspec-archive.md`
- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Kilo Code
- **WHEN** the user selects Kilo Code during initialization
- **THEN** create `.kilocode/workflows/openspec-proposal.md`, `.kilocode/workflows/openspec-apply.md`, and `.kilocode/workflows/openspec-archive.md`
- **AND** populate each file from shared templates (wrapped in OpenSpec markers) so workflow text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Codex
- **WHEN** the user selects Codex during initialization
- **THEN** create global prompt files at `~/.codex/prompts/openspec-proposal.md`, `~/.codex/prompts/openspec-apply.md`, and `~/.codex/prompts/openspec-archive.md` (or under `$CODEX_HOME/prompts` if set)
- **AND** populate each file from shared templates that map the first numbered placeholder (`$1`) to the primary user input (e.g., change identifier or question text)
- **AND** wrap the generated content in OpenSpec markers so `openspec update` can refresh the prompts without touching surrounding custom notes
#### Scenario: Generating slash commands for GitHub Copilot
- **WHEN** the user selects GitHub Copilot during initialization
- **THEN** create `.github/prompts/openspec-proposal.prompt.md`, `.github/prompts/openspec-apply.prompt.md`, and `.github/prompts/openspec-archive.prompt.md`
- **AND** populate each file with YAML frontmatter containing a `description` field that summarizes the workflow stage
- **AND** include `$ARGUMENTS` placeholder to capture user input
- **AND** wrap the shared template body with OpenSpec markers so `openspec update` can refresh the content
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
@@ -0,0 +1,525 @@
# Shell Completions Design
## Overview
This design establishes a plugin-based architecture for shell completions that prioritizes clean TypeScript patterns, scalability, and maintainability. The system separates concerns between shell-specific generation logic, dynamic completion data providers, and installation automation.
**Scope:** This proposal implements **Zsh completion only** (with Oh My Zsh priority). The architecture is designed to support bash, fish, and PowerShell in future proposals.
## Native Shell Completion Behaviors
**Design Philosophy:** We integrate with each shell's native completion system rather than attempting to customize or unify behaviors. This ensures familiar UX for users and reduces maintenance complexity.
**Note:** While all four shell behaviors are documented below for architectural reference, **only Zsh is implemented in this proposal**. Bash, Fish, and PowerShell are documented to guide future implementations.
### Bash Completion Behavior
**Interaction Pattern:**
- **Single TAB:** Completes if only one match exists, otherwise does nothing
- **Double TAB (TAB TAB):** Displays all possible completions as a list
- **Type more characters + TAB:** Narrows matches and completes or shows refined list
**OpenSpec Integration:**
```bash
# After installing: openspec completion install bash
openspec val<TAB> # Completes to "openspec validate"
openspec validate <TAB><TAB> # Shows: --all --changes --specs --strict --json [change-ids] [spec-ids]
openspec show add-<TAB><TAB> # Shows all changes starting with "add-"
```
**Implementation:** Uses bash-completion framework with `_init_completion`, `compgen`, and `COMPREPLY` array.
### Zsh Completion Behavior (with Oh My Zsh)
**Interaction Pattern:**
- **Single TAB:** Shows interactive menu with all matches immediately
- **TAB / Arrow Keys:** Navigate through completion options
- **Enter:** Selects highlighted option
- **Ctrl+C / Esc:** Cancels completion menu
**OpenSpec Integration:**
```zsh
# After installing: openspec completion install zsh
openspec val<TAB> # Shows menu with "validate" and "view" highlighted
openspec show <TAB> # Shows menu with all change IDs and spec IDs, categorized
```
**Implementation:** Uses Zsh completion system with `_arguments`, `_describe`, and `compadd` built-ins. Oh My Zsh provides enhanced menu styling automatically.
### Fish Completion Behavior
**Interaction Pattern:**
- **As-you-type:** Gray suggestions appear automatically in real-time
- **Right Arrow / Ctrl+F:** Accepts the suggestion
- **TAB:** Shows menu with all matches if multiple exist
- **TAB again:** Cycles through options or navigates menu
- **Enter:** Accepts current selection
**OpenSpec Integration:**
```fish
# After installing: openspec completion install fish
openspec val # Gray suggestion shows "validate" immediately
openspec show a # Real-time suggestions for changes starting with "a"
openspec <TAB> # Shows all commands with descriptions in paged menu
```
**Implementation:** Uses Fish's declarative `complete -c` syntax. Completions are auto-loaded from `~/.config/fish/completions/`.
### PowerShell Completion Behavior
**Interaction Pattern:**
- **TAB:** Cycles forward through completions one at a time (inline replacement)
- **Shift+TAB:** Cycles backward through completions
- **Ctrl+Space:** Shows IntelliSense-style menu (PSReadLine v2.2+)
- **Arrow Keys:** Navigate menu if shown
**OpenSpec Integration:**
```powershell
# After installing: openspec completion install powershell
openspec val<TAB> # Cycles: validate → view → validate
openspec show <TAB> # Cycles through change IDs one by one
openspec <Ctrl+Space> # Shows IntelliSense menu with all commands
```
**Implementation:** Uses `Register-ArgumentCompleter` with custom script block that returns `[System.Management.Automation.CompletionResult]` objects.
### Comparison Table
| Shell | Trigger | Display Style | Navigation | Selection |
|-------------|-----------------|------------------------|----------------------|----------------|
| Bash | TAB TAB | List (printed once) | Type more + TAB | Auto-complete |
| Zsh | TAB | Interactive menu | TAB/Arrows | Enter |
| Fish | TAB/Auto | Real-time + menu | TAB/Arrows | Enter/Right |
| PowerShell | TAB | Inline cycling | TAB/Shift+TAB | Stop cycling |
**Key Insight:** Each shell's completion UX reflects its design philosophy. We respect these conventions rather than forcing uniformity.
## Architectural Principles
### 1. Plugin-Based Generator System
Each shell has unique completion syntax and conventions. Rather than creating a monolithic generator with branching logic, we use a plugin pattern where each shell implements a common interface:
```typescript
interface CompletionGenerator {
generate(): string;
getInstallPath(): string;
getConfigFile(): string;
}
```
**Benefits:**
- New shells can be added without modifying existing generators
- Shell-specific logic is isolated and testable
- Type safety ensures all generators implement required methods
- Easy to maintain and understand (single responsibility per generator)
**Implementation Classes:**
- `ZshCompletionGenerator` - Uses Zsh's `_arguments` and `_describe` functions
- `BashCompletionGenerator` - Uses `_init_completion` and `compgen` built-ins
- `FishCompletionGenerator` - Uses `complete -c` declarative syntax
- `PowerShellCompletionGenerator` - Uses `Register-ArgumentCompleter` cmdlet
### 2. Centralized Command Registry
Shell completions must stay synchronized with actual CLI commands. To avoid duplication and drift, we maintain a single source of truth:
```typescript
type CommandDefinition = {
name: string;
description: string;
flags: FlagDefinition[];
acceptsChangeId: boolean;
acceptsSpecId: boolean;
subcommands?: CommandDefinition[];
};
const COMMAND_REGISTRY: CommandDefinition[] = [
{
name: 'init',
description: 'Initialize OpenSpec in your project',
flags: [
{ name: '--tools', description: 'Configure AI tools non-interactively', hasValue: true }
],
acceptsChangeId: false,
acceptsSpecId: false
},
// ... all other commands
];
```
**Benefits:**
- All generators consume the same command definitions
- Adding a new command automatically propagates to all shells
- Flag changes only need to be made in one place
- Type safety prevents typos and missing fields
- Easier to test (mock the registry)
**TypeScript Sugar:**
- Use `const` assertions for readonly registry
- Leverage discriminated unions for command types
- Use `satisfies` operator to ensure registry matches interface
### 3. Dynamic Completion Provider
Change and spec IDs are project-specific and discovered at runtime. A dedicated provider encapsulates this logic:
```typescript
class CompletionProvider {
private changeCache: { ids: string[]; timestamp: number } | null = null;
private specCache: { ids: string[]; timestamp: number } | null = null;
private readonly CACHE_TTL_MS = 2000;
async getChangeIds(): Promise<string[]> {
if (this.changeCache && Date.now() - this.changeCache.timestamp < this.CACHE_TTL_MS) {
return this.changeCache.ids;
}
const ids = await discoverActiveChangeIds();
this.changeCache = { ids, timestamp: Date.now() };
return ids;
}
async getSpecIds(): Promise<string[]> {
// Similar caching logic
}
isOpenSpecProject(): boolean {
// Check for openspec/ directory
}
}
```
**Benefits:**
- Caching reduces file system overhead during rapid tab completion
- Encapsulates project detection logic
- Easy to test with mocked file system
- Shared across all shell generators
**Design Decisions:**
- 2-second cache TTL balances freshness with performance
- Cache per-process (not persistent) to avoid stale data across sessions
- Graceful degradation when outside OpenSpec projects
### 4. Separate Installation Logic
Installation involves shell configuration file manipulation, which differs from generation. We separate this concern:
```typescript
interface CompletionInstaller {
install(): Promise<InstallResult>;
uninstall(): Promise<UninstallResult>;
isInstalled(): Promise<boolean>;
}
```
**Shell-Specific Installers:**
- `ZshInstaller` - Handles both Oh My Zsh (custom completions) and standard Zsh (fpath)
- `BashInstaller` - Detects completion directories and sources from `.bashrc`
- `FishInstaller` - Writes to `~/.config/fish/completions/` (auto-loaded)
- `PowerShellInstaller` - Appends to PowerShell profile
**Benefits:**
- Installation logic doesn't pollute generator code
- Can test installation without generating completion scripts
- Easier to handle edge cases (missing directories, permissions, already installed)
### 5. Type-Safe Shell Detection
We use TypeScript's literal types and type guards for shell detection:
```typescript
type SupportedShell = 'bash' | 'zsh' | 'fish' | 'powershell';
function detectShell(): SupportedShell {
const shellPath = process.env.SHELL || '';
const shellName = path.basename(shellPath).toLowerCase();
// PowerShell normalization
if (shellName === 'pwsh' || shellName === 'powershell') {
return 'powershell';
}
const supported: SupportedShell[] = ['bash', 'zsh', 'fish', 'powershell'];
if (supported.includes(shellName as SupportedShell)) {
return shellName as SupportedShell;
}
throw new Error(`Shell '${shellName}' is not supported. Supported: ${supported.join(', ')}`);
}
```
**Benefits:**
- Compile-time type checking prevents invalid shell names
- Easy to add new shells (add to union type)
- Type narrowing works in switch statements
- Clear error messages for unsupported shells
### 6. Factory Pattern for Instantiation
A factory function selects the appropriate generator/installer based on shell type:
```typescript
function createGenerator(shell: SupportedShell, provider: CompletionProvider): CompletionGenerator {
switch (shell) {
case 'bash': return new BashCompletionGenerator(COMMAND_REGISTRY, provider);
case 'zsh': return new ZshCompletionGenerator(COMMAND_REGISTRY, provider);
case 'fish': return new FishCompletionGenerator(COMMAND_REGISTRY, provider);
case 'powershell': return new PowerShellCompletionGenerator(COMMAND_REGISTRY, provider);
}
}
```
**Benefits:**
- Single point of instantiation
- Type safety ensures exhaustive switch (TypeScript error if shell type missing)
- Easy to inject dependencies (registry, provider)
## Command Structure
**This Proposal (Zsh-only):**
```
openspec completion
├── zsh # Generate Zsh completion script
├── install [shell] # Install Zsh completion (auto-detects or explicit zsh)
└── uninstall [shell] # Remove Zsh completion (auto-detects or explicit zsh)
```
**Future (after follow-up proposals):**
```
openspec completion
├── bash # Generate Bash completion script (future)
├── zsh # Generate Zsh completion script (this proposal)
├── fish # Generate Fish completion script (future)
├── powershell # Generate PowerShell completion script (future)
├── install [shell] # Install completion (auto-detects or explicit shell)
└── uninstall [shell] # Remove completion (auto-detects or explicit shell)
```
## File Organization
**This Proposal (Zsh-only):**
```
src/
├── commands/
│ └── completion.ts # CLI command registration (zsh, install, uninstall)
├── core/
│ └── completions/
│ ├── types.ts # Interfaces: CompletionGenerator, CommandDefinition, etc.
│ ├── command-registry.ts # Single source of truth for OpenSpec commands
│ ├── completion-provider.ts # Dynamic change/spec ID discovery with caching
│ ├── factory.ts # Factory for instantiating Zsh generator/installer
│ ├── generators/
│ │ └── zsh-generator.ts # Zsh completion script generator
│ └── installers/
│ └── zsh-installer.ts # Handles Oh My Zsh + standard Zsh installation
└── utils/
└── shell-detection.ts # Shell detection (returns 'zsh' or throws)
```
**Future additions (bash, fish, powershell):**
- `generators/bash-generator.ts`, `fish-generator.ts`, `powershell-generator.ts`
- `installers/bash-installer.ts`, `fish-installer.ts`, `powershell-installer.ts`
- Update `shell-detection.ts` to support additional shell types
## Oh My Zsh Priority
Zsh implementation prioritizes Oh My Zsh because:
1. **Popularity** - Oh My Zsh is the most popular Zsh configuration framework
2. **Convention** - Has standard completion directory (`~/.oh-my-zsh/custom/completions/`)
3. **Detection** - Easy to detect via `$ZSH` environment variable
4. **Fallback** - Standard Zsh support provides compatibility when Oh My Zsh isn't installed
**Installation Strategy:**
```typescript
if (isOhMyZshInstalled()) {
// Install to ~/.oh-my-zsh/custom/completions/_openspec
// Automatically loaded by Oh My Zsh
} else {
// Install to ~/.zsh/completions/_openspec
// Update ~/.zshrc with fpath and compinit if needed
}
```
## Caching Strategy
Dynamic completions cache results for 2 seconds to balance freshness with performance:
**Why 2 seconds?**
- Typical tab completion sessions last < 2 seconds
- Prevents repeated file system scans during rapid tabbing
- Short enough to feel "live" when changes/specs are added
- Automatic per-process expiration (no stale data across sessions)
**Implementation:**
```typescript
private changeCache: { ids: string[]; timestamp: number } | null = null;
private readonly CACHE_TTL_MS = 2000;
if (this.changeCache && Date.now() - this.changeCache.timestamp < this.CACHE_TTL_MS) {
return this.changeCache.ids; // Use cached
}
// Refresh cache
```
## Error Handling Philosophy
Completions should degrade gracefully rather than break workflows:
1. **Unsupported shell** - Clear error with list of supported shells
2. **Not in OpenSpec project** - Skip dynamic completions, only offer static commands
3. **Permission errors** - Suggest alternative installation methods
4. **Missing config directories** - Auto-create with user notification
5. **Already installed** - Offer to reinstall/update
6. **Not installed (during uninstall)** - Exit gracefully with informational message
## Testing Strategy
Each component is independently testable:
1. **Unit Tests**
- Shell detection with mocked `$SHELL` environment variable
- Generator output verification (regex pattern matching)
- Completion provider caching behavior
- Command registry structure validation
2. **Integration Tests**
- Installation to temporary test directories
- Configuration file modifications
- End-to-end command flow (generate → install → verify)
3. **Manual Testing**
- Real shell environments (Oh My Zsh, Bash, Fish, PowerShell)
- Tab completion behavior in OpenSpec projects
- Dynamic change/spec ID suggestions
- Installation/uninstallation workflows
## TypeScript Sugar Patterns
### 1. Const Assertions for Immutable Data
```typescript
const COMMAND_REGISTRY = [
{ name: 'init', ... },
{ name: 'list', ... }
] as const;
```
### 2. Discriminated Unions for Command Types
```typescript
type Command =
| { type: 'simple'; name: string }
| { type: 'with-subcommands'; name: string; subcommands: Command[] };
```
### 3. Template Literal Types for Strings
```typescript
type ShellConfigFile = `~/.${SupportedShell}rc` | `~/.${SupportedShell}_profile`;
```
### 4. Satisfies Operator for Type Validation
```typescript
const config = {
shell: 'zsh',
path: '~/.zshrc'
} satisfies ShellConfig;
```
### 5. Optional Chaining and Nullish Coalescing
```typescript
const path = process.env.ZSH ?? `${os.homedir()}/.oh-my-zsh`;
```
### 6. Async/Await with Promise.all for Parallel Operations
```typescript
const [changes, specs] = await Promise.all([
provider.getChangeIds(),
provider.getSpecIds()
]);
```
## Scalability Considerations
### Adding a New Shell
1. Define shell in `SupportedShell` union type
2. Create generator class implementing `CompletionGenerator`
3. Create installer class implementing `CompletionInstaller`
4. Add cases to factory functions
5. Add command registration in CLI
6. Write tests
**TypeScript will enforce** that all switch statements are updated (exhaustiveness checking).
### Adding a New Command
1. Add to `COMMAND_REGISTRY` with appropriate metadata
2. All generators automatically include it
3. Update tests to verify new command appears
### Changing Completion Behavior
Dynamic completion logic is centralized in `CompletionProvider`, making behavior changes trivial without touching shell-specific code.
## Trade-offs and Decisions
### Decision: Separate Generators vs. Template Engine
**Chosen:** Separate generator classes per shell
**Alternative:** Template engine with shell-specific templates
**Rationale:**
- Shell completion syntax is fundamentally different (not just text substitution)
- Type safety is better with classes than templates
- Logic complexity (caching, dynamic completions) doesn't fit template paradigm
- Easier to debug and test dedicated classes
### Decision: 2-Second Cache TTL
**Chosen:** 2-second cache
**Alternatives:** No cache (slow), longer cache (stale), persistent cache (complex)
**Rationale:**
- Balances performance with freshness
- Matches typical user interaction patterns
- Simple implementation (no invalidation complexity)
- Automatic cleanup on process exit
### Decision: Oh My Zsh Detection
**Chosen:** Check `$ZSH` env var first, then `~/.oh-my-zsh/` directory
**Rationale:**
- `$ZSH` is set by Oh My Zsh initialization (reliable)
- Directory check is fallback for non-interactive scenarios
- Standard Zsh serves as ultimate fallback
### Decision: Installation Automation vs. Manual Instructions
**Chosen:** Automated installation with install/uninstall commands
**Alternative:** Generate script and provide manual installation instructions
**Rationale:**
- Better user experience (one command vs. multiple manual steps)
- Reduces errors from manual configuration
- Aligns with user expectations for modern CLI tools
- Still supports manual workflow via script generation to stdout
## Future Enhancements
1. **Contextual Flag Completion** - Suggest only valid flags for current command
2. **Fuzzy Matching** - Allow partial matching for change/spec IDs
3. **Rich Descriptions** - Include "why" section in completion suggestions (shell-dependent)
4. **Completion Stats** - Track completion usage for analytics
5. **Custom Completion Hooks** - Allow projects to extend completions
6. **MCP Integration** - Provide completions via Model Context Protocol
## References
- [Bash Programmable Completion](https://www.gnu.org/software/bash/manual/html_node/Programmable-Completion.html)
- [Zsh Completion System](https://zsh.sourceforge.io/Doc/Release/Completion-System.html)
- [Fish Completions](https://fishshell.com/docs/current/completions.html)
- [PowerShell Argument Completers](https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/register-argumentcompleter)
- [Oh My Zsh Custom Completions](https://github.com/ohmyzsh/ohmyzsh/wiki/Customization#adding-custom-completions)
@@ -0,0 +1,29 @@
# Add Shell Completions
## Why
OpenSpec CLI commands lack shell completion, forcing users to remember all commands, subcommands, flags, and change/spec IDs manually. This creates friction during daily use and slows developer workflows. Shell completions are a standard expectation for modern CLI tools and significantly improve user experience through:
- Faster command discovery via tab completion
- Reduced cognitive load by removing memorization requirements
- Fewer typos through validated suggestions
- Professional polish expected of production-grade tools
## What Changes
This change adds shell completion support for the OpenSpec CLI, starting with **Zsh (including Oh My Zsh)** and establishing a scalable architecture for future shells (bash, fish, PowerShell). The implementation provides:
1. **New `openspec completion` command** with Zsh generation and installation/uninstallation capabilities
2. **Native Zsh integration** that respects standard Zsh tab completion behavior (single-TAB menu navigation)
3. **Dynamic completion providers** that discover active changes and specs from the current project
4. **Plugin-based architecture** using TypeScript interfaces for easy extension to additional shells in future proposals
5. **Installation automation** for Oh My Zsh (priority) and standard Zsh configurations
6. **Context-aware suggestions** that only activate within OpenSpec-enabled projects
The architecture emphasizes clean TypeScript patterns, composable generators, separation of concerns between shell-specific logic and shared completion data providers, and integration with native shell completion systems. Other shells (bash, fish, PowerShell) are architecturally documented but not implemented in this proposal—they will be added in follow-up changes.
## Deltas
### Delta: New CLI completion specification
- **Spec:** cli-completion
- **Operation:** ADDED
- **Description:** Defines requirements for the new `openspec completion` command including generation, installation, and shell-specific behaviors for Oh My Zsh, bash, fish, and PowerShell.
@@ -0,0 +1,300 @@
# CLI Completion Specification
## Purpose
The `openspec completion` command SHALL provide shell completion functionality for all OpenSpec CLI commands, flags, and dynamic values (change IDs, spec IDs), with support for Zsh (including Oh My Zsh) and a scalable architecture ready for future shells (bash, fish, PowerShell). The completion system SHALL integrate with Zsh's native completion behavior rather than attempting to customize the user experience.
## ADDED Requirements
### Requirement: Native Shell Behavior Integration
The completion system SHALL respect and integrate with Zsh's native completion patterns and user interaction model.
#### Scenario: Zsh native completion
- **WHEN** generating Zsh completion scripts
- **THEN** use Zsh completion system with `_arguments`, `_describe`, and `compadd`
- **AND** completions SHALL trigger on single TAB (standard Zsh behavior)
- **AND** display as an interactive menu that users navigate with TAB/arrow keys
- **AND** support Oh My Zsh's enhanced menu styling automatically
#### Scenario: No custom UX patterns
- **WHEN** implementing Zsh completion
- **THEN** do NOT attempt to customize completion trigger behavior
- **AND** do NOT override Zsh-specific navigation patterns
- **AND** ensure completions feel native to experienced Zsh users
### Requirement: Command Structure
The completion command SHALL follow a subcommand pattern for generating and managing completion scripts.
#### Scenario: Available subcommands
- **WHEN** user executes `openspec completion --help`
- **THEN** display available subcommands:
- `zsh` - Generate Zsh completion script
- `install [shell]` - Install completion for Zsh (auto-detects or requires explicit shell)
- `uninstall [shell]` - Remove completion for Zsh (auto-detects or requires explicit shell)
### Requirement: Shell Detection
The completion system SHALL automatically detect the user's current shell environment.
#### Scenario: Detecting Zsh from environment
- **WHEN** no shell is explicitly specified
- **THEN** read the `$SHELL` environment variable
- **AND** extract the shell name from the path (e.g., `/bin/zsh` → `zsh`)
- **AND** validate the shell is `zsh`
- **AND** throw an error if the shell is not `zsh`, with message indicating only Zsh is currently supported
#### Scenario: Non-Zsh shell detection
- **WHEN** shell path indicates bash, fish, powershell, or other non-Zsh shell
- **THEN** throw error: "Shell '<name>' is not supported yet. Currently supported: zsh"
### Requirement: Completion Generation
The completion command SHALL generate Zsh completion scripts on demand.
#### Scenario: Generating Zsh completion
- **WHEN** user executes `openspec completion zsh`
- **THEN** output a complete Zsh completion script to stdout
- **AND** include completions for all commands: init, list, show, validate, archive, view, update, change, spec, completion
- **AND** include all command-specific flags and options
- **AND** use Zsh's `_arguments` and `_describe` built-in functions
- **AND** support dynamic completion for change and spec IDs
### Requirement: Dynamic Completions
The completion system SHALL provide context-aware dynamic completions for project-specific values.
#### Scenario: Completing change IDs
- **WHEN** completing arguments for commands that accept change names (show, validate, archive)
- **THEN** discover active changes from `openspec/changes/` directory
- **AND** exclude archived changes in `openspec/changes/archive/`
- **AND** return change IDs as completion suggestions
- **AND** only provide suggestions when inside an OpenSpec-enabled project
#### Scenario: Completing spec IDs
- **WHEN** completing arguments for commands that accept spec names (show, validate)
- **THEN** discover specs from `openspec/specs/` directory
- **AND** return spec IDs as completion suggestions
- **AND** only provide suggestions when inside an OpenSpec-enabled project
#### Scenario: Completion caching
- **WHEN** dynamic completions are requested
- **THEN** cache discovered change and spec IDs for 2 seconds
- **AND** reuse cached values for subsequent requests within cache window
- **AND** automatically refresh cache after expiration
#### Scenario: Project detection
- **WHEN** user requests completions outside an OpenSpec project
- **THEN** skip dynamic change/spec ID completions
- **AND** only suggest static commands and flags
### Requirement: Installation Automation
The completion command SHALL automatically install completion scripts into shell configuration files.
#### Scenario: Installing for Oh My Zsh
- **WHEN** user executes `openspec completion install zsh`
- **THEN** detect if Oh My Zsh is installed by checking for `$ZSH` environment variable or `~/.oh-my-zsh/` directory
- **AND** create custom completions directory at `~/.oh-my-zsh/custom/completions/` if it doesn't exist
- **AND** write completion script to `~/.oh-my-zsh/custom/completions/_openspec`
- **AND** ensure `~/.oh-my-zsh/custom/completions` is in `$fpath` by updating `~/.zshrc` if needed
- **AND** display success message with instruction to run `exec zsh` or restart terminal
#### Scenario: Installing for standard Zsh
- **WHEN** user executes `openspec completion install zsh` and Oh My Zsh is not detected
- **THEN** create completions directory at `~/.zsh/completions/` if it doesn't exist
- **AND** write completion script to `~/.zsh/completions/_openspec`
- **AND** add `fpath=(~/.zsh/completions $fpath)` to `~/.zshrc` if not already present
- **AND** add `autoload -Uz compinit && compinit` to `~/.zshrc` if not already present
- **AND** display success message with instruction to run `exec zsh` or restart terminal
#### Scenario: Auto-detecting Zsh for installation
- **WHEN** user executes `openspec completion install` without specifying a shell
- **THEN** detect current shell using shell detection logic
- **AND** install completion if detected shell is Zsh
- **AND** throw error if detected shell is not Zsh
- **AND** display which shell was detected
#### Scenario: Already installed
- **WHEN** completion is already installed for the target shell
- **THEN** display message indicating completion is already installed
- **AND** offer to reinstall/update by overwriting existing files
- **AND** exit with code 0
### Requirement: Uninstallation
The completion command SHALL remove installed completion scripts and configuration.
#### Scenario: Uninstalling Oh My Zsh completion
- **WHEN** user executes `openspec completion uninstall zsh`
- **THEN** remove `~/.oh-my-zsh/custom/completions/_openspec` if Oh My Zsh is detected
- **AND** remove `~/.zsh/completions/_openspec` if standard Zsh setup is detected
- **AND** optionally remove fpath modifications from `~/.zshrc` (with confirmation)
- **AND** display success message
#### Scenario: Auto-detecting Zsh for uninstallation
- **WHEN** user executes `openspec completion uninstall` without specifying a shell
- **THEN** detect current shell and uninstall completion if shell is Zsh
- **AND** throw error if detected shell is not Zsh
#### Scenario: Not installed
- **WHEN** attempting to uninstall completion that isn't installed
- **THEN** display message indicating completion is not installed
- **AND** exit with code 0
### Requirement: Architecture Patterns
The completion implementation SHALL follow clean architecture principles with TypeScript best practices.
#### Scenario: Shell-specific generators
- **WHEN** implementing completion generators
- **THEN** create `ZshCompletionGenerator` class for Zsh
- **AND** implement a common `CompletionGenerator` interface with methods:
- `generate(): string` - Returns complete shell script
- `getInstallPath(): string` - Returns target installation path
- `getConfigFile(): string` - Returns shell configuration file path
- **AND** design interface to be extensible for future shells (bash, fish, powershell)
#### Scenario: Dynamic completion providers
- **WHEN** implementing dynamic completions
- **THEN** create a `CompletionProvider` class that encapsulates project discovery logic
- **AND** implement methods:
- `getChangeIds(): Promise<string[]>` - Discovers active change IDs
- `getSpecIds(): Promise<string[]>` - Discovers spec IDs
- `isOpenSpecProject(): boolean` - Checks if current directory is OpenSpec-enabled
- **AND** implement caching with 2-second TTL using class properties
#### Scenario: Command registry
- **WHEN** defining completable commands
- **THEN** create a centralized `CommandDefinition` type with properties:
- `name: string` - Command name
- `description: string` - Help text
- `flags: FlagDefinition[]` - Available flags
- `acceptsChangeId: boolean` - Whether command takes change ID argument
- `acceptsSpecId: boolean` - Whether command takes spec ID argument
- `subcommands?: CommandDefinition[]` - Nested subcommands
- **AND** export a `COMMAND_REGISTRY` constant with all command definitions
- **AND** generators consume this registry to ensure consistency
#### Scenario: Type-safe shell detection
- **WHEN** implementing shell detection
- **THEN** define a `SupportedShell` type as literal type: `'zsh'`
- **AND** implement `detectShell()` function that returns 'zsh' or throws error
- **AND** design type to be extensible (e.g., future: `'bash' | 'zsh' | 'fish' | 'powershell'`)
### Requirement: Error Handling
The completion command SHALL provide clear error messages for common failure scenarios.
#### Scenario: Unsupported shell
- **WHEN** user requests completion for unsupported shell (bash, fish, powershell, etc.)
- **THEN** display error message: "Shell '<name>' is not supported yet. Currently supported: zsh"
- **AND** exit with code 1
#### Scenario: Permission errors during installation
- **WHEN** installation fails due to file permission issues
- **THEN** display clear error message indicating permission problem
- **AND** suggest using appropriate permissions or alternative installation method
- **AND** exit with code 1
#### Scenario: Missing shell configuration directory
- **WHEN** expected shell configuration directory doesn't exist
- **THEN** create the directory automatically (with user notification)
- **AND** proceed with installation
#### Scenario: Shell not detected
- **WHEN** `openspec completion install` cannot detect current shell or detects non-Zsh shell
- **THEN** display error: "Could not detect Zsh. Please specify explicitly: openspec completion install zsh"
- **AND** exit with code 1
### Requirement: Output Format
The completion command SHALL provide machine-parseable and human-readable output.
#### Scenario: Script generation output
- **WHEN** generating completion script to stdout
- **THEN** output only the completion script content (no extra messages)
- **AND** allow redirection to files: `openspec completion zsh > /path/to/_openspec`
#### Scenario: Installation success output
- **WHEN** installation completes successfully
- **THEN** display formatted success message with:
- Checkmark indicator
- Installation location
- Next steps (shell reload instructions)
- **AND** use colors when terminal supports it (unless `--no-color` is set)
#### Scenario: Verbose installation output
- **WHEN** user provides `--verbose` flag during installation
- **THEN** display detailed steps:
- Shell detection result
- Target file paths
- Configuration modifications
- File creation confirmations
### Requirement: Testing Support
The completion implementation SHALL be testable with unit and integration tests.
#### Scenario: Mock shell environment
- **WHEN** writing tests for shell detection
- **THEN** allow overriding `$SHELL` environment variable
- **AND** use dependency injection for file system operations
#### Scenario: Generator output verification
- **WHEN** testing completion generators
- **THEN** verify generated scripts contain expected patterns
- **AND** test that command registry is properly consumed
- **AND** ensure dynamic completion placeholders are present
#### Scenario: Installation simulation
- **WHEN** testing installation logic
- **THEN** use temporary test directories instead of actual home directories
- **AND** verify file creation without modifying real shell configurations
- **AND** test path resolution logic independently
## Not in Scope
The following shells are **architecturally documented but not implemented** in this proposal. They will be added in future proposals:
- **Bash completion** - Will use bash-completion framework with `_init_completion`, `compgen`, and `COMPREPLY`
- **Fish completion** - Will use Fish's declarative `complete -c` syntax
- **PowerShell completion** - Will use `Register-ArgumentCompleter` with completion result objects
The plugin-based architecture (CompletionGenerator interface, command registry, dynamic providers) is designed to make adding these shells straightforward in follow-up changes.
## Why
Shell completions are essential for professional CLI tools and significantly improve developer experience by reducing friction, errors, and cognitive load during daily workflows.
@@ -0,0 +1,81 @@
# Implementation Tasks
## Phase 1: Foundation & Architecture
- [x] Create `src/utils/shell-detection.ts` with `SupportedShell` type and `detectShell()` function
- [x] Create `src/core/completions/types.ts` with interfaces: `CompletionGenerator`, `CommandDefinition`, `FlagDefinition`
- [x] Create `src/core/completions/command-registry.ts` with `COMMAND_REGISTRY` constant defining all OpenSpec commands, flags, and metadata
- [x] Create `src/core/completions/completion-provider.ts` with `CompletionProvider` class for dynamic change/spec ID discovery with 2-second caching
- [x] Write tests for shell detection (`test/utils/shell-detection.test.ts`)
- [x] Write tests for completion provider (`test/core/completions/completion-provider.test.ts`)
## Phase 2: Zsh Completion (Oh My Zsh Priority)
- [x] Create `src/core/completions/generators/zsh-generator.ts` implementing `CompletionGenerator` interface
- [x] Implement Zsh script generation using `_arguments` and `_describe` patterns
- [x] Add dynamic completion logic for change/spec IDs using completion provider
- [x] Test Zsh generator output (`test/core/completions/generators/zsh-generator.test.ts`)
- [x] Create `src/core/completions/installers/zsh-installer.ts` with Oh My Zsh and standard Zsh support
- [x] Implement Oh My Zsh detection (`$ZSH` env var or `~/.oh-my-zsh/` directory)
- [x] Implement installation to `~/.oh-my-zsh/custom/completions/_openspec` for Oh My Zsh
- [x] Implement fallback installation to `~/.zsh/completions/_openspec` with `fpath` updates
- [x] Test Zsh installer logic with mocked file system (`test/core/completions/installers/zsh-installer.test.ts`)
## Phase 3: CLI Command Implementation
- [x] Create `src/commands/completion.ts` with `CompletionCommand` class
- [x] Register `completion` command in `src/cli/index.ts` with subcommands: generate, install, uninstall
- [x] Implement `generateSubcommand()` that outputs Zsh script to stdout
- [x] Implement `installSubcommand(shell?: 'zsh')` with auto-detection for Zsh-only
- [x] Implement `uninstallSubcommand(shell?: 'zsh')` for removing Zsh completions
- [x] Add `--verbose` flag support for detailed installation output
- [x] Add error handling with clear messages: "Shell '<name>' is not supported yet. Currently supported: zsh"
- [x] Test completion command integration (`test/commands/completion.test.ts`)
## Phase 4: Integration & Polish
- [x] Create factory pattern in `src/core/completions/factory.ts` to instantiate Zsh generator/installer (extensible for future shells)
- [x] Add `completion` command to command registry for self-referential completion
- [x] Implement dynamic completion helper functions in Zsh generator (`_openspec_complete_changes`, `_openspec_complete_specs`, `_openspec_complete_items`)
- [x] Add 'shell' positional type for completion command arguments
- [x] Test completion generation with dynamic helpers
- [x] Test completion install/uninstall flow
- [x] Verify all tests pass (97 completion tests, 340 total tests)
- [x] Implement auto-install via npm postinstall script
- [x] Add safety checks (CI detection, opt-out flag)
- [x] Handle Oh My Zsh vs standard Zsh installation paths
- [x] Add test script for postinstall validation
- [x] Document auto-install behavior and opt-out in README
- [ ] Manually test Zsh completion in Oh My Zsh environment (install, test tab completion, uninstall)
- [ ] Manually test Zsh completion in standard Zsh environment
- [ ] Test dynamic change/spec ID completion in real OpenSpec projects
- [ ] Verify completion cache behavior (2-second TTL)
- [ ] Test behavior outside OpenSpec projects (should skip dynamic completions)
- [x] Update `openspec --help` output to include completion command (automatically done via Commander)
## Phase 5: Edge Cases & Error Handling
- [ ] Test and handle permission errors during installation
- [ ] Test and handle missing shell configuration directories (auto-create with notification)
- [ ] Test "already installed" detection and reinstall flow
- [ ] Test "not installed" detection during uninstall
- [ ] Verify `--no-color` flag is respected in completion command output
- [ ] Test shell detection failure scenarios with helpful error messages
- [ ] Ensure graceful handling when `$SHELL` is unset or invalid
- [ ] Test non-Zsh shells get clear "not supported yet" error messages
- [ ] Test generator output can be redirected to files without corruption
## Dependencies
- Phase 2 depends on Phase 1 (foundation must exist first)
- Phase 3 depends on Phase 2 (CLI needs Zsh generator working)
- Phase 4 depends on Phase 3 (integration requires CLI + Zsh implementation)
- Phase 5 depends on Phase 4 (edge case testing after core functionality works)
## Future Work (Not in This Proposal)
- **Bash completions** - Create bash-generator.ts and bash-installer.ts in follow-up proposal
- **Fish completions** - Create fish-generator.ts and fish-installer.ts in follow-up proposal
- **PowerShell completions** - Create powershell-generator.ts and powershell-installer.ts in follow-up proposal
The architecture is designed to make adding these shells straightforward by implementing the `CompletionGenerator` interface.
@@ -0,0 +1,105 @@
## Context
OpenSpec needs a standard location for user-level configuration that works across platforms and follows established conventions. This will serve as the foundation for settings, feature flags, and future artifacts like workflows or templates.
## Goals / Non-Goals
**Goals:**
- Provide a single, well-defined location for global config
- Follow XDG Base Directory Specification (widely adopted by CLI tools)
- Support cross-platform usage (Unix, macOS, Windows)
- Keep implementation minimal - just the foundation
- Enable future expansion (cache, state, workflows)
**Non-Goals:**
- Project-local config override (not in scope)
- Config file migration tooling
- Config validation CLI commands
- Multiple config profiles
## Decisions
### Path Resolution Strategy
**Decision:** Use XDG Base Directory Specification with platform fallbacks.
```
Unix/macOS: $XDG_CONFIG_HOME/openspec/ or ~/.config/openspec/
Windows: %APPDATA%/openspec/
```
**Rationale:**
- XDG is the de facto standard for CLI tools (used by gh, bat, ripgrep, etc.)
- Environment variable override allows user customization
- Windows uses its native convention (%APPDATA%) for better integration
**Alternatives considered:**
- `~/.openspec/` - Simple but clutters home directory
- `~/Library/Application Support/` on macOS - Overkill for a CLI tool
### Config File Format
**Decision:** JSON (`config.json`)
**Rationale:**
- Native Node.js support (no dependencies)
- Human-readable and editable
- Type-safe with TypeScript
- Matches project.md's "minimal dependencies" principle
**Alternatives considered:**
- YAML - Requires dependency, more error-prone to edit
- TOML - Less common in Node.js ecosystem
- Environment variables only - Too limited for structured settings
### Config Schema
**Decision:** Flat structure with typed fields, start minimal.
```typescript
interface GlobalConfig {
featureFlags?: Record<string, boolean>;
}
```
**Rationale:**
- `featureFlags` enables controlled rollout of new features
- Optional fields with defaults avoid breaking changes
- Flat structure is easy to understand and extend
### Loading Strategy
**Decision:** Read from disk on each call, no caching.
```typescript
export function getGlobalConfig(): GlobalConfig {
return loadConfigFromDisk();
}
```
**Rationale:**
- CLI commands are short-lived; caching adds complexity without benefit
- Reading a small JSON file is ~1ms; negligible overhead
- Always returns fresh data; no cache invalidation concerns
- Simpler implementation
### Directory Creation
**Decision:** Create directory only when saving, not when reading.
**Rationale:**
- Don't create empty directories on read operations
- Users who never save config won't have unnecessary directories
- Aligns with principle of least surprise
## Risks / Trade-offs
| Risk | Mitigation |
|------|------------|
| Config file corruption | Return defaults on parse error, log warning |
| Permissions issues | Check write permissions before save, clear error message |
| Future schema changes | Use optional fields, add version field if needed later |
## Open Questions
None - this proposal is intentionally minimal.
@@ -0,0 +1,20 @@
## Why
OpenSpec currently has no mechanism for user-level global settings or feature flags. As the CLI grows, we need a standard location to store user preferences, experimental features, and other configuration that persists across projects. Following XDG Base Directory Specification provides a well-understood, cross-platform approach.
## What Changes
- Add new `src/core/global-config.ts` module with:
- Path resolution following XDG Base Directory spec (`$XDG_CONFIG_HOME/openspec/` or fallback)
- Cross-platform support (Unix, macOS, Windows)
- Lazy config loading with sensible defaults
- TypeScript types for config shape
- Export a global config directory path getter for future use (workflows, templates, cache)
- Initial config schema supports 1-2 settings/feature flags only
## Impact
- Affected specs: New `global-config` capability (no existing specs modified)
- Affected code:
- New `src/core/global-config.ts`
- Update `src/core/index.ts` to export new module
@@ -0,0 +1,76 @@
## ADDED Requirements
### Requirement: Global Config Directory Path
The system SHALL resolve the global configuration directory path following XDG Base Directory Specification with platform-specific fallbacks.
#### Scenario: Unix/macOS with XDG_CONFIG_HOME set
- **WHEN** `$XDG_CONFIG_HOME` environment variable is set to `/custom/config`
- **THEN** `getGlobalConfigDir()` returns `/custom/config/openspec`
#### Scenario: Unix/macOS without XDG_CONFIG_HOME
- **WHEN** `$XDG_CONFIG_HOME` environment variable is not set
- **AND** the platform is Unix or macOS
- **THEN** `getGlobalConfigDir()` returns `~/.config/openspec` (expanded to absolute path)
#### Scenario: Windows platform
- **WHEN** the platform is Windows
- **AND** `%APPDATA%` is set to `C:\Users\User\AppData\Roaming`
- **THEN** `getGlobalConfigDir()` returns `C:\Users\User\AppData\Roaming\openspec`
### Requirement: Global Config Loading
The system SHALL load global configuration from the config directory with sensible defaults when the config file does not exist or cannot be parsed.
#### Scenario: Config file exists and is valid
- **WHEN** `config.json` exists in the global config directory
- **AND** the file contains valid JSON matching the config schema
- **THEN** `getGlobalConfig()` returns the parsed configuration
#### Scenario: Config file does not exist
- **WHEN** `config.json` does not exist in the global config directory
- **THEN** `getGlobalConfig()` returns the default configuration
- **AND** no directory or file is created
#### Scenario: Config file is invalid JSON
- **WHEN** `config.json` exists but contains invalid JSON
- **THEN** `getGlobalConfig()` returns the default configuration
- **AND** a warning is logged to stderr
### Requirement: Global Config Saving
The system SHALL save global configuration to the config directory, creating the directory if it does not exist.
#### Scenario: Save config to new directory
- **WHEN** `saveGlobalConfig(config)` is called
- **AND** the global config directory does not exist
- **THEN** the directory is created
- **AND** `config.json` is written with the provided configuration
#### Scenario: Save config to existing directory
- **WHEN** `saveGlobalConfig(config)` is called
- **AND** the global config directory already exists
- **THEN** `config.json` is written (overwriting if exists)
### Requirement: Default Configuration
The system SHALL provide a default configuration that is used when no config file exists.
#### Scenario: Default config structure
- **WHEN** no config file exists
- **THEN** the default configuration includes an empty `featureFlags` object
### Requirement: Config Schema Evolution
The system SHALL merge loaded configuration with default values to ensure new config fields are available even when loading older config files.
#### Scenario: Config file missing new fields
- **WHEN** `config.json` exists with `{ "featureFlags": {} }`
- **AND** the current schema includes a new field `defaultAiTool`
- **THEN** `getGlobalConfig()` returns `{ featureFlags: {}, defaultAiTool: <default> }`
- **AND** the loaded values take precedence over defaults for fields that exist in both
#### Scenario: Config file has extra unknown fields
- **WHEN** `config.json` contains fields not in the current schema
- **THEN** the unknown fields are preserved in the returned configuration
- **AND** no error or warning is raised
@@ -0,0 +1,26 @@
## 1. Core Implementation
- [x] 1.1 Create `src/core/global-config.ts` with path resolution
- Implement `getGlobalConfigDir()` following XDG spec
- Support `$XDG_CONFIG_HOME` environment variable override
- Platform-specific fallbacks (Unix: `~/.config/`, Windows: `%APPDATA%`)
- [x] 1.2 Define TypeScript interfaces for config shape
- `GlobalConfig` interface with optional fields
- Start minimal: just `featureFlags?: Record<string, boolean>`
- [x] 1.3 Implement config loading with defaults
- `getGlobalConfig()` - reads config.json if exists, merges with defaults
- No directory/file creation on read (lazy initialization)
- [x] 1.4 Implement config saving
- `saveGlobalConfig(config)` - writes config.json, creates directory if needed
## 2. Integration
- [x] 2.1 Export new module from `src/core/index.ts`
- [x] 2.2 Add constants for config file name and directory name
## 3. Testing
- [x] 3.1 Manual testing of path resolution on current platform
- [x] 3.2 Test with/without `$XDG_CONFIG_HOME` set
- [x] 3.3 Test config load when file doesn't exist (should return defaults)
- [x] 3.4 Unit tests in `test/core/global-config.test.ts` (18 tests)
@@ -0,0 +1,89 @@
## Context
The `global-config` spec defines how OpenSpec reads/writes `config.json`, but users currently must edit it by hand. This command provides a CLI interface to that config.
## Goals / Non-Goals
**Goals:**
- Provide a discoverable CLI for config management
- Support scripting with machine-readable output
- Validate config changes with zod schema
- Handle nested keys gracefully
**Non-Goals:**
- Project-local config (reserved for future via `--scope` flag)
- Complex queries (JSONPath, filtering)
- Config file format migration
## Decisions
### Key Naming: camelCase with Dot Notation
**Decision:** Keys use camelCase matching the JSON structure, with dot notation for nesting.
**Rationale:**
- Matches the actual JSON keys (no translation layer)
- Dot notation is intuitive and widely used (lodash, jq, kubectl)
- Avoids complexity of supporting multiple casing styles
**Examples:**
```bash
openspec config get featureFlags # Returns object
openspec config get featureFlags.experimental # Returns nested value
openspec config set featureFlags.newFlag true
```
### Type Coercion: Auto-detect with `--string` Override
**Decision:** Parse values automatically; provide `--string` flag to force string storage.
**Rationale:**
- Most intuitive for common cases (`true`, `false`, `123`)
- Explicit override for edge cases (storing literal string "true")
- Follows npm/yarn config patterns
**Coercion rules:**
| Input | Stored As |
|-------|-----------|
| `true`, `false` | boolean |
| Numeric string (`123`, `3.14`) | number |
| Everything else | string |
| Any value with `--string` | string |
### Output Format: Raw by Default
**Decision:** `get` prints raw value only. `list` prints YAML-like format by default, JSON with `--json`.
**Rationale:**
- Raw output enables piping: `VAR=$(openspec config get key)`
- YAML-like is human-readable for inspection
- JSON for automation/scripting
### Schema Validation: Zod with Unknown Field Passthrough
**Decision:** Use zod for validation but preserve unknown fields per `global-config` spec.
**Rationale:**
- Type safety for known fields
- Forward compatibility (old CLI doesn't break new config)
- Follows existing `global-config` spec requirement
### Reserved Flag: `--scope`
**Decision:** Reserve `--scope global|project` but only implement `global` initially.
**Rationale:**
- Avoids breaking change if project-local config is added later
- Clear error message if someone tries `--scope project`
## Risks / Trade-offs
| Risk | Mitigation |
|------|------------|
| Dot notation conflicts with keys containing dots | Rare in practice; document limitation |
| Type coercion surprises | `--string` escape hatch; document rules |
| $EDITOR not set | Check and provide helpful error message |
## Open Questions
None - design is straightforward.
@@ -0,0 +1,60 @@
## Why
Users need a way to view and modify their global OpenSpec settings without manually editing JSON files. The `global-config` spec provides the foundation, but there's no user-facing interface to interact with the config. A dedicated `openspec config` command provides discoverability and ease of use.
## What Changes
Add `openspec config` subcommand with the following operations:
```bash
openspec config path # Show config file location
openspec config list [--json] # Show all current settings
openspec config get <key> # Get a specific value (raw, scriptable)
openspec config set <key> <value> [--string] # Set a value (auto-coerce types)
openspec config unset <key> # Remove a key (revert to default)
openspec config reset --all [-y] # Reset everything to defaults
openspec config edit # Open config in $EDITOR
```
**Key design decisions:**
- **Key naming**: Use camelCase to match JSON structure (e.g., `featureFlags.someFlag`)
- **Nested keys**: Support dot notation for nested access
- **Type coercion**: Auto-detect types by default; `--string` flag forces string storage
- **Scriptable output**: `get` prints raw value only (no labels) for easy piping
- **Zod validation**: Use zod for config schema validation and type safety
- **Future-proofing**: Reserve `--scope global|project` flag for potential project-local config
**Example usage:**
```bash
$ openspec config path
/Users/me/.config/openspec/config.json
$ openspec config list
featureFlags: {}
$ openspec config set featureFlags.enableTelemetry false
Set featureFlags.enableTelemetry = false
$ openspec config get featureFlags.enableTelemetry
false
$ openspec config list --json
{
"featureFlags": {}
}
$ openspec config unset featureFlags.enableTelemetry
Unset featureFlags.enableTelemetry (reverted to default)
$ openspec config edit
# Opens $EDITOR with config.json
```
## Impact
- Affected specs: New `cli-config` capability
- Affected code:
- New `src/commands/config.ts`
- New `src/core/config-schema.ts` (zod schema)
- Update CLI entry point to register config command
- Dependencies: Requires `global-config` spec (already implemented)
@@ -0,0 +1,213 @@
# cli-config Specification
## Purpose
Provide a CLI interface for viewing and modifying global OpenSpec configuration. Enables users to manage settings without manually editing JSON files, with support for scripting and automation.
## ADDED Requirements
### Requirement: Command Structure
The config command SHALL provide subcommands for all configuration operations.
#### Scenario: Available subcommands
- **WHEN** user executes `openspec config --help`
- **THEN** display available subcommands:
- `path` - Show config file location
- `list` - Show all current settings
- `get <key>` - Get a specific value
- `set <key> <value>` - Set a value
- `unset <key>` - Remove a key (revert to default)
- `reset` - Reset configuration to defaults
- `edit` - Open config in editor
### Requirement: Config Path
The config command SHALL display the config file location.
#### Scenario: Show config path
- **WHEN** user executes `openspec config path`
- **THEN** print the absolute path to the config file
- **AND** exit with code 0
### Requirement: Config List
The config command SHALL display all current configuration values.
#### Scenario: List config in human-readable format
- **WHEN** user executes `openspec config list`
- **THEN** display all config values in YAML-like format
- **AND** show nested objects with indentation
#### Scenario: List config as JSON
- **WHEN** user executes `openspec config list --json`
- **THEN** output the complete config as valid JSON
- **AND** output only JSON (no additional text)
### Requirement: Config Get
The config command SHALL retrieve specific configuration values.
#### Scenario: Get top-level key
- **WHEN** user executes `openspec config get <key>` with a valid top-level key
- **THEN** print the raw value only (no labels or formatting)
- **AND** exit with code 0
#### Scenario: Get nested key with dot notation
- **WHEN** user executes `openspec config get featureFlags.someFlag`
- **THEN** traverse the nested structure using dot notation
- **AND** print the value at that path
#### Scenario: Get non-existent key
- **WHEN** user executes `openspec config get <key>` with a key that does not exist
- **THEN** print nothing (empty output)
- **AND** exit with code 1
#### Scenario: Get object value
- **WHEN** user executes `openspec config get <key>` where the value is an object
- **THEN** print the object as JSON
### Requirement: Config Set
The config command SHALL set configuration values with automatic type coercion.
#### Scenario: Set string value
- **WHEN** user executes `openspec config set <key> <value>`
- **AND** value does not match boolean or number patterns
- **THEN** store value as a string
- **AND** display confirmation message
#### Scenario: Set boolean value
- **WHEN** user executes `openspec config set <key> true` or `openspec config set <key> false`
- **THEN** store value as boolean (not string)
- **AND** display confirmation message
#### Scenario: Set numeric value
- **WHEN** user executes `openspec config set <key> <value>`
- **AND** value is a valid number (integer or float)
- **THEN** store value as number (not string)
#### Scenario: Force string with --string flag
- **WHEN** user executes `openspec config set <key> <value> --string`
- **THEN** store value as string regardless of content
- **AND** this allows storing literal "true" or "123" as strings
#### Scenario: Set nested key
- **WHEN** user executes `openspec config set featureFlags.newFlag true`
- **THEN** create intermediate objects if they don't exist
- **AND** set the value at the nested path
### Requirement: Config Unset
The config command SHALL remove configuration overrides.
#### Scenario: Unset existing key
- **WHEN** user executes `openspec config unset <key>`
- **AND** the key exists in the config
- **THEN** remove the key from the config file
- **AND** the value reverts to its default
- **AND** display confirmation message
#### Scenario: Unset non-existent key
- **WHEN** user executes `openspec config unset <key>`
- **AND** the key does not exist in the config
- **THEN** display message indicating key was not set
- **AND** exit with code 0
### Requirement: Config Reset
The config command SHALL reset configuration to defaults.
#### Scenario: Reset all with confirmation
- **WHEN** user executes `openspec config reset --all`
- **THEN** prompt for confirmation before proceeding
- **AND** if confirmed, delete the config file or reset to defaults
- **AND** display confirmation message
#### Scenario: Reset all with -y flag
- **WHEN** user executes `openspec config reset --all -y`
- **THEN** reset without prompting for confirmation
#### Scenario: Reset without --all flag
- **WHEN** user executes `openspec config reset` without `--all`
- **THEN** display error indicating `--all` is required
- **AND** exit with code 1
### Requirement: Config Edit
The config command SHALL open the config file in the user's editor.
#### Scenario: Open editor successfully
- **WHEN** user executes `openspec config edit`
- **AND** `$EDITOR` or `$VISUAL` environment variable is set
- **THEN** open the config file in that editor
- **AND** create the config file with defaults if it doesn't exist
- **AND** wait for the editor to close before returning
#### Scenario: No editor configured
- **WHEN** user executes `openspec config edit`
- **AND** neither `$EDITOR` nor `$VISUAL` is set
- **THEN** display error message suggesting to set `$EDITOR`
- **AND** exit with code 1
### Requirement: Key Naming Convention
The config command SHALL use camelCase keys matching the JSON structure.
#### Scenario: Keys match JSON structure
- **WHEN** accessing configuration keys via CLI
- **THEN** use camelCase matching the actual JSON property names
- **AND** support dot notation for nested access (e.g., `featureFlags.someFlag`)
### Requirement: Schema Validation
The config command SHALL validate configuration writes against the config schema using zod, while allowing unknown fields for forward compatibility.
#### Scenario: Unknown key accepted
- **WHEN** user executes `openspec config set someFutureKey 123`
- **THEN** the value is saved successfully
- **AND** exit with code 0
#### Scenario: Invalid feature flag value rejected
- **WHEN** user executes `openspec config set featureFlags.someFlag notABoolean`
- **THEN** display a descriptive error message
- **AND** do not modify the config file
- **AND** exit with code 1
### Requirement: Reserved Scope Flag
The config command SHALL reserve the `--scope` flag for future extensibility.
#### Scenario: Scope flag defaults to global
- **WHEN** user executes any config command without `--scope`
- **THEN** operate on global configuration (default behavior)
#### Scenario: Project scope not yet implemented
- **WHEN** user executes `openspec config --scope project <subcommand>`
- **THEN** display error message: "Project-local config is not yet implemented"
- **AND** exit with code 1
@@ -0,0 +1,28 @@
## 1. Core Infrastructure
- [x] 1.1 Create zod schema for global config in `src/core/config-schema.ts`
- [x] 1.2 Add utility functions for dot-notation key access (get/set nested values)
- [x] 1.3 Add type coercion logic (auto-detect boolean/number/string)
## 2. Config Command Implementation
- [x] 2.1 Create `src/commands/config.ts` with Commander.js subcommands
- [x] 2.2 Implement `config path` subcommand
- [x] 2.3 Implement `config list` subcommand with `--json` flag
- [x] 2.4 Implement `config get <key>` subcommand (raw output)
- [x] 2.5 Implement `config set <key> <value>` with `--string` flag
- [x] 2.6 Implement `config unset <key>` subcommand
- [x] 2.7 Implement `config reset --all` with `-y` confirmation flag
- [x] 2.8 Implement `config edit` subcommand (spawn $EDITOR)
## 3. Integration
- [x] 3.1 Register config command in CLI entry point
- [x] 3.2 Update shell completion registry to include config subcommands
## 4. Testing
- [x] 4.1 Manual testing of all subcommands
- [x] 4.2 Verify zod validation rejects invalid keys/values
- [x] 4.3 Test nested key access with dot notation
- [x] 4.4 Test type coercion edge cases (true/false, numbers, strings)
@@ -0,0 +1,197 @@
## Context
This implements "Slice 1: What's Ready?" from the artifact POC analysis. The core insight is using the filesystem as a database - artifact completion is detected by file existence, making the system stateless and version-control friendly.
This module will coexist with the current OpenSpec system as a parallel capability, potentially enabling future migration or integration.
## Goals / Non-Goals
**Goals:**
- Pure dependency graph logic with no side effects
- Stateless state detection (rescan filesystem each query)
- Support glob patterns for multi-file artifacts (e.g., `specs/*.md`)
- Load artifact definitions from YAML schemas
- Calculate topological build order
- Determine "ready" artifacts based on dependency completion
**Non-Goals:**
- CLI commands (Slice 4)
- Multi-change management (Slice 2)
- Template resolution and enrichment (Slice 3)
- Agent integration or Claude commands
- Replacing existing OpenSpec functionality
## Decisions
### Decision: Filesystem as Database
Use file existence for state detection rather than a separate state file.
**Rationale:**
- Stateless - no state corruption possible
- Git-friendly - state derived from committed files
- Simple - no sync issues between state file and actual files
**Alternatives considered:**
- JSON/SQLite state file: More complex, sync issues, not git-friendly
- Git metadata: Too coupled to git, complex implementation
### Decision: Kahn's Algorithm for Topological Sort
Use Kahn's algorithm for computing build order.
**Rationale:**
- Well-understood, O(V+E) complexity
- Naturally detects cycles during execution
- Produces a stable, deterministic order
### Decision: Glob Pattern Support
Support glob patterns like `specs/*.md` in artifact `generates` field.
**Rationale:**
- Allows multiple files to satisfy a single artifact requirement
- Common pattern for spec directories with multiple files
- Uses standard glob syntax
### Decision: Immutable Completed Set
Represent completion state as an immutable Set of completed artifact IDs.
**Rationale:**
- Functional style, easier to reason about
- State derived fresh each query, no mutation needed
- Clear separation between graph structure and runtime state
- Filesystem can only detect binary existence (complete vs not complete)
**Note:** `inProgress` and `failed` states are deferred to future slices. They would require external state tracking (e.g., a status file) since file existence alone cannot distinguish these states.
### Decision: Zod for Schema Validation
Use Zod for validating YAML schema structure and deriving TypeScript types.
**Rationale:**
- Already a project dependency (v4.0.17) used in `src/core/schemas/`
- Type inference via `z.infer<>` - single source of truth for types
- Runtime validation with detailed error messages
- Consistent with existing project patterns (`base.schema.ts`, `config-schema.ts`)
**Alternatives considered:**
- Manual validation: More code, error-prone, no type inference
- JSON Schema: Would require additional dependency, less TypeScript integration
- io-ts: Not already in project, steeper learning curve
### Decision: Two-Level Schema Resolution
Schemas resolve from global user data directory, falling back to package built-ins.
**Resolution order:**
1. `${XDG_DATA_HOME:-~/.local/share}/openspec/schemas/<name>.yaml` - Global user override
2. `<package>/schemas/<name>.yaml` - Built-in defaults
**Rationale:**
- Follows XDG Base Directory Specification (schemas are data, not config)
- Mirrors existing `getGlobalConfigDir()` pattern in `src/core/global-paths.ts`
- Built-ins baked into package, never auto-copied
- Users customize by creating files in global data dir
- Simple - no project-level overrides (can add later if needed)
**XDG compliance:**
- Uses `XDG_DATA_HOME` env var when set (all platforms)
- Unix/macOS fallback: `~/.local/share/openspec/`
- Windows fallback: `%LOCALAPPDATA%/openspec/`
**Alternatives considered:**
- Project-level overrides: Added complexity, not needed initially
- Auto-copy to user space: Creates drift, harder to update defaults
- Config directory (`XDG_CONFIG_HOME`): Schemas are workflow definitions (data), not user preferences (config)
### Decision: Template Field Parsed But Not Resolved
The `template` field is required in schema YAML for completeness, but template resolution is deferred to Slice 3.
**Rationale:**
- Slice 1 focuses on "What's Ready?" - dependency and completion queries only
- Template paths are validated syntactically (non-empty string) but not resolved
- Keeps Slice 1 focused and independently testable
### Decision: Cycle Error Format
Cycle errors list all artifact IDs in the cycle for easy debugging.
**Format:** `"Cyclic dependency detected: A → B → C → A"`
**Rationale:**
- Shows the full cycle path, not just that a cycle exists
- Actionable - developer can see exactly which artifacts to fix
- Consistent with Kahn's algorithm which naturally identifies cycle participants
## Data Structures
**Zod Schemas (source of truth):**
```typescript
import { z } from 'zod';
// Artifact definition schema
export const ArtifactSchema = z.object({
id: z.string().min(1, 'Artifact ID is required'),
generates: z.string().min(1), // e.g., "proposal.md" or "specs/*.md"
description: z.string(),
template: z.string(), // path to template file
requires: z.array(z.string()).default([]),
});
// Full schema YAML structure
export const SchemaYamlSchema = z.object({
name: z.string().min(1, 'Schema name is required'),
version: z.number().int().positive(),
description: z.string().optional(),
artifacts: z.array(ArtifactSchema).min(1, 'At least one artifact required'),
});
// Derived TypeScript types
export type Artifact = z.infer<typeof ArtifactSchema>;
export type SchemaYaml = z.infer<typeof SchemaYamlSchema>;
```
**Runtime State (not Zod - internal only):**
```typescript
// Slice 1: Simple completion tracking via filesystem
type CompletedSet = Set<string>;
// Return type for blocked query
interface BlockedArtifacts {
[artifactId: string]: string[]; // artifact → list of unmet dependencies
}
interface ArtifactGraphResult {
completed: string[];
ready: string[];
blocked: BlockedArtifacts;
buildOrder: string[];
}
```
## File Structure
```
src/core/artifact-graph/
├── index.ts # Public exports
├── types.ts # Zod schemas and type definitions
├── graph.ts # ArtifactGraph class
├── state.ts # State detection logic
├── resolver.ts # Schema resolution (global → built-in)
└── schemas/ # Built-in schema definitions (package level)
├── spec-driven.yaml # Default: proposal → specs → design → tasks
└── tdd.yaml # Alternative: tests → implementation → docs
```
**Schema Resolution Paths:**
- Global user override: `${XDG_DATA_HOME:-~/.local/share}/openspec/schemas/<name>.yaml`
- Package built-in: `src/core/artifact-graph/schemas/<name>.yaml` (bundled with package)
## Risks / Trade-offs
| Risk | Mitigation |
|------|------------|
| Glob pattern edge cases | Use well-tested glob library (fast-glob or similar) |
| Cycle detection | Kahn's algorithm naturally fails on cycles; provide clear error |
| Schema evolution | Version field in schema, validate on load |
## Open Questions
None - all questions resolved in Decisions section.
@@ -0,0 +1,18 @@
## Why
The current OpenSpec system relies on conventions and AI inference for artifact ordering. A formal artifact graph with dependency awareness would enable deterministic "what's ready?" queries, making the system more predictable and enabling future features like automated pipeline execution.
## What Changes
- Add `ArtifactGraph` class to model artifacts as a DAG with dependency relationships
- Add `ArtifactState` type to track completion status (completed, in_progress, failed)
- Add filesystem-based state detection using file existence and glob patterns
- Add schema YAML parser to load artifact definitions
- Implement topological sort (Kahn's algorithm) for build order calculation
- Add `getNextArtifacts()` to find artifacts ready for creation
## Impact
- Affected specs: New `artifact-graph` capability
- Affected code: `src/core/artifact-graph/` (new directory)
- No changes to existing functionality - this is a parallel module
@@ -0,0 +1,103 @@
## ADDED Requirements
### Requirement: Schema Loading
The system SHALL load artifact graph definitions from YAML schema files.
#### Scenario: Valid schema loaded
- **WHEN** a valid schema YAML file is provided
- **THEN** the system returns an ArtifactGraph with all artifacts and dependencies
#### Scenario: Invalid schema rejected
- **WHEN** a schema YAML file is missing required fields
- **THEN** the system throws an error with a descriptive message
#### Scenario: Cyclic dependencies detected
- **WHEN** a schema contains cyclic artifact dependencies
- **THEN** the system throws an error listing the artifact IDs in the cycle
#### Scenario: Invalid dependency reference
- **WHEN** an artifact's `requires` array references a non-existent artifact ID
- **THEN** the system throws an error identifying the invalid reference
#### Scenario: Duplicate artifact IDs rejected
- **WHEN** a schema contains multiple artifacts with the same ID
- **THEN** the system throws an error identifying the duplicate
### Requirement: Build Order Calculation
The system SHALL compute a valid topological build order for artifacts.
#### Scenario: Linear dependency chain
- **WHEN** artifacts form a linear chain (A → B → C)
- **THEN** getBuildOrder() returns [A, B, C]
#### Scenario: Diamond dependency
- **WHEN** artifacts form a diamond (A → B, A → C, B → D, C → D)
- **THEN** getBuildOrder() returns A before B and C, and D last
#### Scenario: Independent artifacts
- **WHEN** artifacts have no dependencies
- **THEN** getBuildOrder() returns them in a stable order
### Requirement: State Detection
The system SHALL detect artifact completion state by scanning the filesystem.
#### Scenario: Simple file exists
- **WHEN** an artifact generates "proposal.md" and the file exists
- **THEN** the artifact is marked as completed
#### Scenario: Simple file missing
- **WHEN** an artifact generates "proposal.md" and the file does not exist
- **THEN** the artifact is not marked as completed
#### Scenario: Glob pattern with files
- **WHEN** an artifact generates "specs/*.md" and the specs/ directory contains .md files
- **THEN** the artifact is marked as completed
#### Scenario: Glob pattern empty
- **WHEN** an artifact generates "specs/*.md" and the specs/ directory is empty or missing
- **THEN** the artifact is not marked as completed
#### Scenario: Missing change directory
- **WHEN** the change directory does not exist
- **THEN** all artifacts are marked as not completed (empty state)
### Requirement: Ready Artifact Query
The system SHALL identify which artifacts are ready to be created based on dependency completion.
#### Scenario: Root artifacts ready initially
- **WHEN** no artifacts are completed
- **THEN** getNextArtifacts() returns artifacts with no dependencies
#### Scenario: Dependent artifact becomes ready
- **WHEN** an artifact's dependencies are all completed
- **THEN** getNextArtifacts() includes that artifact
#### Scenario: Blocked artifacts excluded
- **WHEN** an artifact has uncompleted dependencies
- **THEN** getNextArtifacts() does not include that artifact
### Requirement: Completion Check
The system SHALL determine when all artifacts in a graph are complete.
#### Scenario: All complete
- **WHEN** all artifacts in the graph are in the completed set
- **THEN** isComplete() returns true
#### Scenario: Partially complete
- **WHEN** some artifacts in the graph are not completed
- **THEN** isComplete() returns false
### Requirement: Blocked Query
The system SHALL identify which artifacts are blocked and return all their unmet dependencies.
#### Scenario: Artifact blocked by single dependency
- **WHEN** artifact B requires artifact A and A is not complete
- **THEN** getBlocked() returns `{ B: ['A'] }`
#### Scenario: Artifact blocked by multiple dependencies
- **WHEN** artifact C requires A and B, and only A is complete
- **THEN** getBlocked() returns `{ C: ['B'] }`
#### Scenario: Artifact blocked by all dependencies
- **WHEN** artifact C requires A and B, and neither is complete
- **THEN** getBlocked() returns `{ C: ['A', 'B'] }`
@@ -0,0 +1,61 @@
## 1. Type Definitions
- [x] 1.1 Create `src/core/artifact-graph/types.ts` with Zod schemas (`ArtifactSchema`, `SchemaYamlSchema`) and inferred types via `z.infer<>`
- [x] 1.2 Define `CompletedSet` (Set<string>), `BlockedArtifacts`, and `ArtifactGraphResult` types for runtime state
## 2. Schema Parser
- [x] 2.1 Create `src/core/artifact-graph/schema.ts` with YAML loading and Zod validation via `.safeParse()`
- [x] 2.2 Implement dependency reference validation (ensure `requires` references valid artifact IDs)
- [x] 2.3 Implement duplicate artifact ID detection
- [x] 2.4 Add cycle detection during schema load (error format: "Cyclic dependency detected: A → B → C → A")
## 3. Artifact Graph Core
- [x] 3.1 Create `src/core/artifact-graph/graph.ts` with ArtifactGraph class
- [x] 3.2 Implement `fromYaml(path)` - load graph from schema file
- [x] 3.3 Implement `getBuildOrder()` - topological sort via Kahn's algorithm
- [x] 3.4 Implement `getArtifact(id)` - retrieve single artifact definition
- [x] 3.5 Implement `getAllArtifacts()` - list all artifacts
## 4. State Detection
- [x] 4.1 Create `src/core/artifact-graph/state.ts` with state detection logic
- [x] 4.2 Implement file existence checking for simple paths
- [x] 4.3 Implement glob pattern matching for multi-file artifacts
- [x] 4.4 Implement `detectCompleted(graph, changeDir)` - scan filesystem and return CompletedSet
- [x] 4.5 Handle missing changeDir gracefully (return empty CompletedSet)
## 5. Ready Calculation
- [x] 5.1 Implement `getNextArtifacts(graph, completed)` - find artifacts with all deps completed
- [x] 5.2 Implement `isComplete(graph, completed)` - check if all artifacts done
- [x] 5.3 Implement `getBlocked(graph, completed)` - return BlockedArtifacts map (artifact → unmet deps)
## 6. Schema Resolution
- [x] 6.1 Create `src/core/artifact-graph/resolver.ts` with schema resolution logic
- [x] 6.2 Add `getGlobalDataDir()` to `src/core/global-config.ts` (XDG_DATA_HOME with platform fallbacks)
- [x] 6.3 Implement `resolveSchema(name)` - global (`${XDG_DATA_HOME}/openspec/schemas/`) → built-in fallback
## 7. Built-in Schemas
- [x] 7.1 Create `src/core/artifact-graph/schemas/spec-driven.yaml` (default: proposal → specs → design → tasks)
- [x] 7.2 Create `src/core/artifact-graph/schemas/tdd.yaml` (alternative: tests → implementation → docs)
## 8. Integration
- [x] 8.1 Create `src/core/artifact-graph/index.ts` with public exports
## 9. Testing
- [x] 9.1 Test: Parse valid schema YAML returns correct artifact graph
- [x] 9.2 Test: Parse invalid schema (missing fields) throws descriptive error
- [x] 9.3 Test: Duplicate artifact IDs throws error
- [x] 9.4 Test: Invalid `requires` reference throws error identifying the invalid ID
- [x] 9.5 Test: Cycle in schema throws error listing cycle path (e.g., "A → B → C → A")
- [x] 9.6 Test: Compute build order returns correct topological ordering (linear chain)
- [x] 9.7 Test: Compute build order handles diamond dependencies correctly
- [x] 9.8 Test: Independent artifacts return in stable order
- [x] 9.9 Test: Empty/missing changeDir returns empty CompletedSet
- [x] 9.10 Test: File existence marks artifact as completed
- [x] 9.11 Test: Glob pattern specs/*.md detected as complete when files exist
- [x] 9.12 Test: Glob pattern with empty directory not marked complete
- [x] 9.13 Test: getNextArtifacts returns only root artifacts when nothing completed
- [x] 9.14 Test: getNextArtifacts includes artifact when all deps completed
- [x] 9.15 Test: getBlocked returns artifact with all unmet dependencies listed
- [x] 9.16 Test: isComplete() returns true when all artifacts completed
- [x] 9.17 Test: isComplete() returns false when some artifacts incomplete
- [x] 9.18 Test: Schema resolution finds global override before built-in
- [x] 9.19 Test: Schema resolution falls back to built-in when no global
@@ -0,0 +1,74 @@
## Context
This is Slice 2 of the artifact tracker POC. The goal is to provide utilities for creating change directories programmatically.
**Current state:** No programmatic way to create changes. Users must manually create directories.
**Proposed state:** Utility functions for change creation with name validation.
## Goals / Non-Goals
### Goals
- **Add** `createChange()` function to create change directories
- **Add** `validateChangeName()` function for kebab-case validation
- **Enable** automation (Claude commands, scripts) to create changes
### Non-Goals
- Refactor existing CLI commands (they work fine)
- Create abstraction layers or manager classes
- Change how `ListCommand` or `ChangeCommand` work
## Decisions
### Decision 1: Simple Utility Functions
**Choice**: Add functions to `src/utils/change-utils.ts` - no class.
```typescript
// src/utils/change-utils.ts
export function validateChangeName(name: string): { valid: boolean; error?: string }
export async function createChange(
projectRoot: string,
name: string
): Promise<void>
```
**Why**:
- Simple, no abstraction overhead
- Easy to test
- Easy to import where needed
- Matches existing utility patterns in `src/utils/`
**Alternatives considered**:
- ChangeManager class: Rejected - over-engineered for 2 functions
- Add to existing command: Rejected - mixes CLI with reusable logic
### Decision 2: Kebab-Case Validation Pattern
**Choice**: Validate names with `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`
Valid: `add-auth`, `refactor-db`, `add-feature-2`, `refactor`
Invalid: `Add-Auth`, `add auth`, `add_auth`, `-add-auth`, `add-auth-`, `add--auth`
**Why**:
- Filesystem-safe (no special characters)
- URL-safe (for future web UI)
- Consistent with existing change naming in repo
## File Changes
### New Files
- `src/utils/change-utils.ts` - Utility functions
- `src/utils/change-utils.test.ts` - Unit tests
### Modified Files
- None
## Risks / Trade-offs
| Risk | Mitigation |
|------|------------|
| Function might not cover all use cases | Start simple, extend if needed |
| Naming conflicts with future work | Using clear, specific function names |
@@ -0,0 +1,45 @@
## Why
There's no programmatic way to create a new change directory. Users must manually:
1. Create `openspec/changes/<name>/` directory
2. Create a `proposal.md` file
3. Hope they got the naming right
This is error-prone and blocks automation (e.g., Claude commands, scripts).
**This proposal adds:**
1. `createChange(projectRoot, name)` - Create change directories programmatically
2. `validateChangeName(name)` - Enforce kebab-case naming conventions
## What Changes
### New Utilities
| Function | Description |
|----------|-------------|
| `createChange(projectRoot, name)` | Creates `openspec/changes/<name>/` directory |
| `validateChangeName(name)` | Returns `{ valid: boolean; error?: string }` |
### Name Validation Rules
Pattern: `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`
| Valid | Invalid |
|-------|---------|
| `add-auth` | `Add-Auth` (uppercase) |
| `refactor-db` | `add auth` (spaces) |
| `add-feature-2` | `add_auth` (underscores) |
| `refactor` | `-add-auth` (leading hyphen) |
### Location
New file: `src/utils/change-utils.ts`
Simple utility functions - no class, no abstraction layer.
## Impact
- **Affected specs**: None
- **Affected code**: None (new utilities only)
- **New files**: `src/utils/change-utils.ts`
- **Breaking changes**: None
@@ -0,0 +1,63 @@
## ADDED Requirements
### Requirement: Change Creation
The system SHALL provide a function to create new change directories programmatically.
#### Scenario: Create change
- **WHEN** `createChange(projectRoot, 'add-auth')` is called
- **THEN** the system creates `openspec/changes/add-auth/` directory
#### Scenario: Duplicate change rejected
- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/add-auth/` already exists
- **THEN** the system throws an error indicating the change already exists
#### Scenario: Creates parent directories if needed
- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/` does not exist
- **THEN** the system creates the full path including parent directories
#### Scenario: Invalid change name rejected
- **WHEN** `createChange(projectRoot, 'Add Auth')` is called with an invalid name
- **THEN** the system throws a validation error
### Requirement: Change Name Validation
The system SHALL validate change names follow kebab-case conventions.
#### Scenario: Valid kebab-case name accepted
- **WHEN** a change name like `add-user-auth` is validated
- **THEN** validation returns `{ valid: true }`
#### Scenario: Numeric suffixes accepted
- **WHEN** a change name like `add-feature-2` is validated
- **THEN** validation returns `{ valid: true }`
#### Scenario: Single word accepted
- **WHEN** a change name like `refactor` is validated
- **THEN** validation returns `{ valid: true }`
#### Scenario: Uppercase characters rejected
- **WHEN** a change name like `Add-Auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Spaces rejected
- **WHEN** a change name like `add auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Underscores rejected
- **WHEN** a change name like `add_auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Special characters rejected
- **WHEN** a change name like `add-auth!` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Leading hyphen rejected
- **WHEN** a change name like `-add-auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Trailing hyphen rejected
- **WHEN** a change name like `add-auth-` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Consecutive hyphens rejected
- **WHEN** a change name like `add--auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
@@ -0,0 +1,30 @@
## Phase 1: Implement Name Validation
- [x] 1.1 Create `src/utils/change-utils.ts`
- [x] 1.2 Implement `validateChangeName()` with kebab-case pattern
- [x] 1.3 Pattern: `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`
- [x] 1.4 Return `{ valid: boolean; error?: string }`
- [x] 1.5 Add test: valid names accepted (`add-auth`, `refactor`, `add-feature-2`)
- [x] 1.6 Add test: uppercase rejected
- [x] 1.7 Add test: spaces rejected
- [x] 1.8 Add test: underscores rejected
- [x] 1.9 Add test: special characters rejected
- [x] 1.10 Add test: leading/trailing hyphens rejected
- [x] 1.11 Add test: consecutive hyphens rejected
## Phase 2: Implement Change Creation
- [x] 2.1 Implement `createChange(projectRoot, name)`
- [x] 2.2 Validate name before creating
- [x] 2.3 Create parent directories if needed (`openspec/changes/`)
- [x] 2.4 Throw if change already exists
- [x] 2.5 Add test: creates directory
- [x] 2.6 Add test: duplicate change throws error
- [x] 2.7 Add test: invalid name throws validation error
- [x] 2.8 Add test: creates parent directories if needed
## Phase 3: Integration
- [x] 3.1 Export functions from `src/utils/index.ts`
- [x] 3.2 Add JSDoc comments
- [x] 3.3 Run all tests to verify no regressions
@@ -0,0 +1,13 @@
## Why
The Cline implementation was architecturally incorrect. According to Cline's official documentation, Cline uses workflows for on-demand automation and rules for behavioral guidelines. The OpenSpec slash commands are procedural workflows (scaffold → implement → archive), not behavioral rules, so they should be placed in `.clinerules/workflows/` instead of `.clinerules/`.
## What Changes
- Update ClineSlashCommandConfigurator to use `.clinerules/workflows/` paths instead of `.clinerules/` paths
- Update all tests to expect the correct workflow file locations
- Update README.md documentation to reflect workflows instead of rules
- **BREAKING**: Existing Cline users will need to re-run `openspec init` to get the corrected workflow files
## Impact
- Affected specs: cli-init (corrected Cline workflow paths)
- Affected code: `src/core/configurators/slash/cline.ts`, test files, README.md
- Modified files: `.clinerules/workflows/openspec-*.md` (moved from `.clinerules/openspec-*.md`)
@@ -0,0 +1,11 @@
# Delta for CLI Init
## MODIFIED Requirements
### Requirement: Slash Command Configuration
#### Scenario: Generating slash commands for Cline
- **WHEN** the user selects Cline during initialization
- **THEN** create `.clinerules/workflows/openspec-proposal.md`, `.clinerules/workflows/openspec-apply.md`, and `.clinerules/workflows/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** include Cline-specific Markdown heading frontmatter
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
@@ -0,0 +1,13 @@
## 1. Update ClineSlashCommandConfigurator
- [x] Change FILE_PATHS in `src/core/configurators/slash/cline.ts` from `.clinerules/openspec-*.md` to `.clinerules/workflows/openspec-*.md`
## 2. Update Tests
- [x] Update "should refresh existing Cline rule files" test in `test/core/update.test.ts` to use workflow paths
- [x] Update "should create Cline rule files with templates" test in `test/core/init.test.ts` to use workflow paths
## 3. Update Documentation
- [x] Update README.md table to show "Workflows in `.clinerules/workflows/` directory" for Cline
## 4. Validate Changes
- [x] Ensure all tests pass with the new paths
- [x] Verify the change follows OpenSpec conventions
@@ -0,0 +1,129 @@
## Context
Built-in schemas are currently embedded as TypeScript objects:
```typescript
// src/core/artifact-graph/builtin-schemas.ts
export const SPEC_DRIVEN_SCHEMA: SchemaYaml = {
name: 'spec-driven',
version: 1,
artifacts: [...]
};
```
This doesn't support templates co-located with schemas. The instruction loader (Slice 3) needs templates, and the cleanest approach is self-contained schema directories.
## Goals / Non-Goals
**Goals:**
- Schemas as self-contained directories (schema.yaml + templates/)
- User overrides via XDG data directory
- Simple 2-level resolution (user → package)
- Templates co-located with their schema
**Non-Goals:**
- Shared template fallback (intentionally avoiding complexity)
- Runtime schema compilation
- Schema inheritance
## Decisions
### 1. Directory structure
Each schema is a directory containing `schema.yaml` and `templates/`:
```
<package>/schemas/
├── spec-driven/
│ ├── schema.yaml
│ └── templates/
│ ├── proposal.md
│ ├── design.md
│ ├── spec.md
│ └── tasks.md
└── tdd/
├── schema.yaml
└── templates/
├── spec.md
├── test.md
├── implementation.md
└── docs.md
```
**Why:** Self-contained like Helm charts. No cross-schema dependencies. Each schema owns its templates.
### 2. Resolution order (2 levels)
```
1. ${XDG_DATA_HOME}/openspec/schemas/<name>/schema.yaml # User override
2. <package>/schemas/<name>/schema.yaml # Built-in
3. Error (not found)
```
**Why:** Simple mental model. User can override entire schema directory or just parts.
### 3. Template path in schema.yaml
The `template` field is relative to the schema's `templates/` directory:
```yaml
# schemas/spec-driven/schema.yaml
artifacts:
- id: proposal
template: "proposal.md" # → schemas/spec-driven/templates/proposal.md
```
**Why:** Paths are relative to the schema, not a global templates directory.
### 4. Resolve package directory via import.meta.url
```typescript
function getPackageSchemasDir(): string {
const currentFile = fileURLToPath(import.meta.url);
// Navigate from src/core/artifact-graph/ to package root
return path.join(path.dirname(currentFile), '..', '..', '..', 'schemas');
}
```
**Why:** Works in ESM. No hardcoded paths.
### 5. Keep schema.yaml format unchanged
The YAML format stays the same - only the storage location changes:
```yaml
name: spec-driven
version: 1
description: Specification-driven development
artifacts:
- id: proposal
generates: "proposal.md"
template: "proposal.md"
requires: []
```
**Why:** No breaking changes to schema format. Just moving from TS to YAML files.
## Migration
1. Create `schemas/` directory at package root
2. Convert `SPEC_DRIVEN_SCHEMA` to `schemas/spec-driven/schema.yaml`
3. Convert `TDD_SCHEMA` to `schemas/tdd/schema.yaml`
4. Update `resolveSchema()` to load from directories
5. Remove `builtin-schemas.ts`
6. Update `listSchemas()` to scan directories
## Risks / Trade-offs
**File I/O at runtime:**
- Previously schemas were in-memory objects
- Now requires reading YAML files
- Mitigation: Schemas are small, loaded once per operation
**Package distribution:**
- Must ensure `schemas/` directory is included in npm package
- Add to `files` in package.json
## Open Questions
None.
@@ -0,0 +1,20 @@
## Why
Currently, built-in schemas are embedded as TypeScript objects in `builtin-schemas.ts`. This works for schemas but doesn't support co-located templates. To enable self-contained schema packages (schema + templates together), we need to restructure schemas as directories.
## What Changes
- **BREAKING (internal):** Move built-in schemas from embedded TS objects to actual directory structure
- Schemas become directories containing `schema.yaml` + `templates/`
- Update `resolveSchema()` to load from directory structure
- Remove `builtin-schemas.ts` (replaced by file-based schemas)
- Update resolution to check user dir → package dir
## Impact
- Affected specs: `artifact-graph` (schema resolution changes)
- Affected code:
- Remove `src/core/artifact-graph/builtin-schemas.ts`
- Update `src/core/artifact-graph/resolver.ts`
- Add `schemas/` directory at package root
- No external API changes (resolution still returns `SchemaYaml`)
@@ -0,0 +1,49 @@
## MODIFIED Requirements
### Requirement: Schema Loading
The system SHALL load artifact graph definitions from YAML schema files within schema directories.
#### Scenario: Valid schema loaded
- **WHEN** a schema directory contains a valid `schema.yaml` file
- **THEN** the system returns an ArtifactGraph with all artifacts and dependencies
#### Scenario: Invalid schema rejected
- **WHEN** a schema YAML file is missing required fields
- **THEN** the system throws an error with a descriptive message
#### Scenario: Cyclic dependencies detected
- **WHEN** a schema contains cyclic artifact dependencies
- **THEN** the system throws an error listing the artifact IDs in the cycle
#### Scenario: Invalid dependency reference
- **WHEN** an artifact's `requires` array references a non-existent artifact ID
- **THEN** the system throws an error identifying the invalid reference
#### Scenario: Duplicate artifact IDs rejected
- **WHEN** a schema contains multiple artifacts with the same ID
- **THEN** the system throws an error identifying the duplicate
#### Scenario: Schema directory not found
- **WHEN** resolving a schema name that has no corresponding directory
- **THEN** the system throws an error listing available schemas
## ADDED Requirements
### Requirement: Schema Directory Structure
The system SHALL support self-contained schema directories with co-located templates.
#### Scenario: Schema with templates
- **WHEN** a schema directory contains `schema.yaml` and `templates/` subdirectory
- **THEN** artifacts can reference templates relative to the schema's templates directory
#### Scenario: User schema override
- **WHEN** a schema directory exists at `${XDG_DATA_HOME}/openspec/schemas/<name>/`
- **THEN** the system uses that directory instead of the built-in
#### Scenario: Built-in schema fallback
- **WHEN** no user override exists for a schema
- **THEN** the system uses the package built-in schema directory
#### Scenario: List available schemas
- **WHEN** listing schemas
- **THEN** the system returns schema names from both user and package directories
@@ -0,0 +1,32 @@
## 1. Create Schema Directories
- [ ] 1.1 Create `schemas/` directory at package root
- [ ] 1.2 Create `schemas/spec-driven/schema.yaml` from `SPEC_DRIVEN_SCHEMA`
- [ ] 1.3 Create `schemas/spec-driven/templates/` with placeholder templates
- [ ] 1.4 Create `schemas/tdd/schema.yaml` from `TDD_SCHEMA`
- [ ] 1.5 Create `schemas/tdd/templates/` with placeholder templates
## 2. Update Schema Resolution
- [ ] 2.1 Add `getPackageSchemasDir()` function using `import.meta.url`
- [ ] 2.2 Add `getSchemaDir(name)` to resolve schema directory path
- [ ] 2.3 Update `resolveSchema()` to load from directory structure
- [ ] 2.4 Update `listSchemas()` to scan directories instead of object keys
- [ ] 2.5 Add tests for user override resolution
- [ ] 2.6 Add tests for built-in fallback
## 3. Cleanup
- [ ] 3.1 Remove `builtin-schemas.ts`
- [ ] 3.2 Update `index.ts` exports (remove `BUILTIN_SCHEMAS`, `SPEC_DRIVEN_SCHEMA`, `TDD_SCHEMA`)
- [ ] 3.3 Update any code that imports removed exports
## 4. Package Distribution
- [ ] 4.1 Add `schemas/` to `files` array in `package.json`
- [ ] 4.2 Verify schemas are included in built package
## 5. Fix Template Paths
- [ ] 5.1 Update `template` field in schema.yaml files (remove `templates/` prefix)
- [ ] 5.2 Ensure template paths are relative to schema's templates directory
+107
View File
@@ -0,0 +1,107 @@
# artifact-graph Specification
## Purpose
TBD - created by archiving change add-artifact-graph-core. Update Purpose after archive.
## Requirements
### Requirement: Schema Loading
The system SHALL load artifact graph definitions from YAML schema files.
#### Scenario: Valid schema loaded
- **WHEN** a valid schema YAML file is provided
- **THEN** the system returns an ArtifactGraph with all artifacts and dependencies
#### Scenario: Invalid schema rejected
- **WHEN** a schema YAML file is missing required fields
- **THEN** the system throws an error with a descriptive message
#### Scenario: Cyclic dependencies detected
- **WHEN** a schema contains cyclic artifact dependencies
- **THEN** the system throws an error listing the artifact IDs in the cycle
#### Scenario: Invalid dependency reference
- **WHEN** an artifact's `requires` array references a non-existent artifact ID
- **THEN** the system throws an error identifying the invalid reference
#### Scenario: Duplicate artifact IDs rejected
- **WHEN** a schema contains multiple artifacts with the same ID
- **THEN** the system throws an error identifying the duplicate
### Requirement: Build Order Calculation
The system SHALL compute a valid topological build order for artifacts.
#### Scenario: Linear dependency chain
- **WHEN** artifacts form a linear chain (A → B → C)
- **THEN** getBuildOrder() returns [A, B, C]
#### Scenario: Diamond dependency
- **WHEN** artifacts form a diamond (A → B, A → C, B → D, C → D)
- **THEN** getBuildOrder() returns A before B and C, and D last
#### Scenario: Independent artifacts
- **WHEN** artifacts have no dependencies
- **THEN** getBuildOrder() returns them in a stable order
### Requirement: State Detection
The system SHALL detect artifact completion state by scanning the filesystem.
#### Scenario: Simple file exists
- **WHEN** an artifact generates "proposal.md" and the file exists
- **THEN** the artifact is marked as completed
#### Scenario: Simple file missing
- **WHEN** an artifact generates "proposal.md" and the file does not exist
- **THEN** the artifact is not marked as completed
#### Scenario: Glob pattern with files
- **WHEN** an artifact generates "specs/*.md" and the specs/ directory contains .md files
- **THEN** the artifact is marked as completed
#### Scenario: Glob pattern empty
- **WHEN** an artifact generates "specs/*.md" and the specs/ directory is empty or missing
- **THEN** the artifact is not marked as completed
#### Scenario: Missing change directory
- **WHEN** the change directory does not exist
- **THEN** all artifacts are marked as not completed (empty state)
### Requirement: Ready Artifact Query
The system SHALL identify which artifacts are ready to be created based on dependency completion.
#### Scenario: Root artifacts ready initially
- **WHEN** no artifacts are completed
- **THEN** getNextArtifacts() returns artifacts with no dependencies
#### Scenario: Dependent artifact becomes ready
- **WHEN** an artifact's dependencies are all completed
- **THEN** getNextArtifacts() includes that artifact
#### Scenario: Blocked artifacts excluded
- **WHEN** an artifact has uncompleted dependencies
- **THEN** getNextArtifacts() does not include that artifact
### Requirement: Completion Check
The system SHALL determine when all artifacts in a graph are complete.
#### Scenario: All complete
- **WHEN** all artifacts in the graph are in the completed set
- **THEN** isComplete() returns true
#### Scenario: Partially complete
- **WHEN** some artifacts in the graph are not completed
- **THEN** isComplete() returns false
### Requirement: Blocked Query
The system SHALL identify which artifacts are blocked and return all their unmet dependencies.
#### Scenario: Artifact blocked by single dependency
- **WHEN** artifact B requires artifact A and A is not complete
- **THEN** getBlocked() returns `{ B: ['A'] }`
#### Scenario: Artifact blocked by multiple dependencies
- **WHEN** artifact C requires A and B, and only A is complete
- **THEN** getBlocked() returns `{ C: ['B'] }`
#### Scenario: Artifact blocked by all dependencies
- **WHEN** artifact C requires A and B, and neither is complete
- **THEN** getBlocked() returns `{ C: ['A', 'B'] }`
+67
View File
@@ -0,0 +1,67 @@
# change-creation Specification
## Purpose
Provide programmatic utilities for creating and validating OpenSpec change directories.
## Requirements
### Requirement: Change Creation
The system SHALL provide a function to create new change directories programmatically.
#### Scenario: Create change
- **WHEN** `createChange(projectRoot, 'add-auth')` is called
- **THEN** the system creates `openspec/changes/add-auth/` directory
#### Scenario: Duplicate change rejected
- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/add-auth/` already exists
- **THEN** the system throws an error indicating the change already exists
#### Scenario: Creates parent directories if needed
- **WHEN** `createChange(projectRoot, 'add-auth')` is called and `openspec/changes/` does not exist
- **THEN** the system creates the full path including parent directories
#### Scenario: Invalid change name rejected
- **WHEN** `createChange(projectRoot, 'Add Auth')` is called with an invalid name
- **THEN** the system throws a validation error
### Requirement: Change Name Validation
The system SHALL validate change names follow kebab-case conventions.
#### Scenario: Valid kebab-case name accepted
- **WHEN** a change name like `add-user-auth` is validated
- **THEN** validation returns `{ valid: true }`
#### Scenario: Numeric suffixes accepted
- **WHEN** a change name like `add-feature-2` is validated
- **THEN** validation returns `{ valid: true }`
#### Scenario: Single word accepted
- **WHEN** a change name like `refactor` is validated
- **THEN** validation returns `{ valid: true }`
#### Scenario: Uppercase characters rejected
- **WHEN** a change name like `Add-Auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Spaces rejected
- **WHEN** a change name like `add auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Underscores rejected
- **WHEN** a change name like `add_auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Special characters rejected
- **WHEN** a change name like `add-auth!` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Leading hyphen rejected
- **WHEN** a change name like `-add-auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Trailing hyphen rejected
- **WHEN** a change name like `add-auth-` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
#### Scenario: Consecutive hyphens rejected
- **WHEN** a change name like `add--auth` is validated
- **THEN** validation returns `{ valid: false, error: "..." }`
+287
View File
@@ -0,0 +1,287 @@
# cli-completion Specification
## Purpose
Provide shell completion scripts for the OpenSpec CLI, enabling tab-completion for commands, flags, and dynamic values (change IDs, spec IDs) in supported shells. Currently supports Zsh with architecture designed for future shell expansion.
## Requirements
### Requirement: Native Shell Behavior Integration
The completion system SHALL respect and integrate with Zsh's native completion patterns and user interaction model.
#### Scenario: Zsh native completion
- **WHEN** generating Zsh completion scripts
- **THEN** use Zsh completion system with `_arguments`, `_describe`, and `compadd`
- **AND** completions SHALL trigger on single TAB (standard Zsh behavior)
- **AND** display as an interactive menu that users navigate with TAB/arrow keys
- **AND** support Oh My Zsh's enhanced menu styling automatically
#### Scenario: No custom UX patterns
- **WHEN** implementing Zsh completion
- **THEN** do NOT attempt to customize completion trigger behavior
- **AND** do NOT override Zsh-specific navigation patterns
- **AND** ensure completions feel native to experienced Zsh users
### Requirement: Command Structure
The completion command SHALL follow a subcommand pattern for generating and managing completion scripts.
#### Scenario: Available subcommands
- **WHEN** user executes `openspec completion --help`
- **THEN** display available subcommands:
- `generate [shell]` - Generate completion script for a shell (outputs to stdout)
- `install [shell]` - Install completion for Zsh (auto-detects or requires explicit shell)
- `uninstall [shell]` - Remove completion for Zsh (auto-detects or requires explicit shell)
### Requirement: Shell Detection
The completion system SHALL automatically detect the user's current shell environment.
#### Scenario: Detecting Zsh from environment
- **WHEN** no shell is explicitly specified
- **THEN** read the `$SHELL` environment variable
- **AND** extract the shell name from the path (e.g., `/bin/zsh` → `zsh`)
- **AND** validate the shell is `zsh`
- **AND** throw an error if the shell is not `zsh`, with message indicating only Zsh is currently supported
#### Scenario: Non-Zsh shell detection
- **WHEN** shell path indicates bash, fish, powershell, or other non-Zsh shell
- **THEN** throw error: "Shell '<name>' is not supported yet. Currently supported: zsh"
### Requirement: Completion Generation
The completion command SHALL generate Zsh completion scripts on demand.
#### Scenario: Generating Zsh completion
- **WHEN** user executes `openspec completion generate zsh`
- **THEN** output a complete Zsh completion script to stdout
- **AND** include completions for all commands: init, list, show, validate, archive, view, update, change, spec, completion
- **AND** include all command-specific flags and options
- **AND** use Zsh's `_arguments` and `_describe` built-in functions
- **AND** support dynamic completion for change and spec IDs
### Requirement: Dynamic Completions
The completion system SHALL provide context-aware dynamic completions for project-specific values.
#### Scenario: Completing change IDs
- **WHEN** completing arguments for commands that accept change names (show, validate, archive)
- **THEN** discover active changes from `openspec/changes/` directory
- **AND** exclude archived changes in `openspec/changes/archive/`
- **AND** return change IDs as completion suggestions
- **AND** only provide suggestions when inside an OpenSpec-enabled project
#### Scenario: Completing spec IDs
- **WHEN** completing arguments for commands that accept spec names (show, validate)
- **THEN** discover specs from `openspec/specs/` directory
- **AND** return spec IDs as completion suggestions
- **AND** only provide suggestions when inside an OpenSpec-enabled project
#### Scenario: Completion caching
- **WHEN** dynamic completions are requested
- **THEN** cache discovered change and spec IDs for 2 seconds
- **AND** reuse cached values for subsequent requests within cache window
- **AND** automatically refresh cache after expiration
#### Scenario: Project detection
- **WHEN** user requests completions outside an OpenSpec project
- **THEN** skip dynamic change/spec ID completions
- **AND** only suggest static commands and flags
### Requirement: Installation Automation
The completion command SHALL automatically install completion scripts into shell configuration files.
#### Scenario: Installing for Oh My Zsh
- **WHEN** user executes `openspec completion install zsh`
- **THEN** detect if Oh My Zsh is installed by checking for `$ZSH` environment variable or `~/.oh-my-zsh/` directory
- **AND** create custom completions directory at `~/.oh-my-zsh/custom/completions/` if it doesn't exist
- **AND** write completion script to `~/.oh-my-zsh/custom/completions/_openspec`
- **AND** ensure `~/.oh-my-zsh/custom/completions` is in `$fpath` by updating `~/.zshrc` if needed
- **AND** display success message with instruction to run `exec zsh` or restart terminal
#### Scenario: Installing for standard Zsh
- **WHEN** user executes `openspec completion install zsh` and Oh My Zsh is not detected
- **THEN** create completions directory at `~/.zsh/completions/` if it doesn't exist
- **AND** write completion script to `~/.zsh/completions/_openspec`
- **AND** add `fpath=(~/.zsh/completions $fpath)` to `~/.zshrc` if not already present
- **AND** add `autoload -Uz compinit && compinit` to `~/.zshrc` if not already present
- **AND** display success message with instruction to run `exec zsh` or restart terminal
#### Scenario: Auto-detecting Zsh for installation
- **WHEN** user executes `openspec completion install` without specifying a shell
- **THEN** detect current shell using shell detection logic
- **AND** install completion if detected shell is Zsh
- **AND** throw error if detected shell is not Zsh
- **AND** display which shell was detected
#### Scenario: Already installed
- **WHEN** completion is already installed for the target shell
- **THEN** display message indicating completion is already installed
- **AND** offer to reinstall/update by overwriting existing files
- **AND** exit with code 0
### Requirement: Uninstallation
The completion command SHALL remove installed completion scripts and configuration.
#### Scenario: Uninstalling Oh My Zsh completion
- **WHEN** user executes `openspec completion uninstall zsh`
- **THEN** prompt for confirmation before proceeding (unless `--yes` flag provided)
- **AND** if user declines, cancel uninstall and display "Uninstall cancelled."
- **AND** if user confirms, remove `~/.oh-my-zsh/custom/completions/_openspec` if Oh My Zsh is detected
- **AND** remove `~/.zsh/completions/_openspec` if standard Zsh setup is detected
- **AND** remove fpath modifications from `~/.zshrc`
- **AND** display success message
#### Scenario: Auto-detecting Zsh for uninstallation
- **WHEN** user executes `openspec completion uninstall` without specifying a shell
- **THEN** detect current shell and uninstall completion if shell is Zsh
- **AND** throw error if detected shell is not Zsh
#### Scenario: Not installed
- **WHEN** attempting to uninstall completion that isn't installed
- **THEN** display error message indicating completion is not installed
- **AND** exit with code 1
### Requirement: Architecture Patterns
The completion implementation SHALL follow clean architecture principles with TypeScript best practices.
#### Scenario: Shell-specific generators
- **WHEN** implementing completion generators
- **THEN** create `ZshCompletionGenerator` class for Zsh
- **AND** implement a common `CompletionGenerator` interface with methods:
- `generate(): string` - Returns complete shell script
- `getInstallPath(): string` - Returns target installation path
- `getConfigFile(): string` - Returns shell configuration file path
- **AND** design interface to be extensible for future shells (bash, fish, powershell)
#### Scenario: Dynamic completion providers
- **WHEN** implementing dynamic completions
- **THEN** create a `CompletionProvider` class that encapsulates project discovery logic
- **AND** implement methods:
- `getChangeIds(): Promise<string[]>` - Discovers active change IDs
- `getSpecIds(): Promise<string[]>` - Discovers spec IDs
- `isOpenSpecProject(): boolean` - Checks if current directory is OpenSpec-enabled
- **AND** implement caching with 2-second TTL using class properties
#### Scenario: Command registry
- **WHEN** defining completable commands
- **THEN** create a centralized `CommandDefinition` type with properties:
- `name: string` - Command name
- `description: string` - Help text
- `flags: FlagDefinition[]` - Available flags
- `acceptsChangeId: boolean` - Whether command takes change ID argument
- `acceptsSpecId: boolean` - Whether command takes spec ID argument
- `subcommands?: CommandDefinition[]` - Nested subcommands
- **AND** export a `COMMAND_REGISTRY` constant with all command definitions
- **AND** generators consume this registry to ensure consistency
#### Scenario: Type-safe shell detection
- **WHEN** implementing shell detection
- **THEN** define a `SupportedShell` type as literal type: `'zsh'`
- **AND** implement `detectShell()` function that returns 'zsh' or throws error
- **AND** design type to be extensible (e.g., future: `'bash' | 'zsh' | 'fish' | 'powershell'`)
### Requirement: Error Handling
The completion command SHALL provide clear error messages for common failure scenarios.
#### Scenario: Unsupported shell
- **WHEN** user requests completion for unsupported shell (bash, fish, powershell, etc.)
- **THEN** display error message: "Shell '<name>' is not supported yet. Currently supported: zsh"
- **AND** exit with code 1
#### Scenario: Permission errors during installation
- **WHEN** installation fails due to file permission issues
- **THEN** display clear error message indicating permission problem
- **AND** suggest using appropriate permissions or alternative installation method
- **AND** exit with code 1
#### Scenario: Missing shell configuration directory
- **WHEN** expected shell configuration directory doesn't exist
- **THEN** create the directory automatically (with user notification)
- **AND** proceed with installation
#### Scenario: Shell not detected
- **WHEN** `openspec completion install` cannot detect current shell or detects non-Zsh shell
- **THEN** display error: "Could not auto-detect shell. Please specify shell explicitly."
- **AND** display usage hint: "Usage: openspec completion <operation> [shell]"
- **AND** exit with code 1
### Requirement: Output Format
The completion command SHALL provide machine-parseable and human-readable output.
#### Scenario: Script generation output
- **WHEN** generating completion script to stdout
- **THEN** output only the completion script content (no extra messages)
- **AND** allow redirection to files: `openspec completion generate zsh > /path/to/_openspec`
#### Scenario: Installation success output
- **WHEN** installation completes successfully
- **THEN** display formatted success message with:
- Checkmark indicator
- Installation location
- Next steps (shell reload instructions)
- **AND** use colors when terminal supports it (unless `--no-color` is set)
#### Scenario: Verbose installation output
- **WHEN** user provides `--verbose` flag during installation
- **THEN** display detailed steps:
- Shell detection result
- Target file paths
- Configuration modifications
- File creation confirmations
### Requirement: Testing Support
The completion implementation SHALL be testable with unit and integration tests.
#### Scenario: Mock shell environment
- **WHEN** writing tests for shell detection
- **THEN** allow overriding `$SHELL` environment variable
- **AND** use dependency injection for file system operations
#### Scenario: Generator output verification
- **WHEN** testing completion generators
- **THEN** verify generated scripts contain expected patterns
- **AND** test that command registry is properly consumed
- **AND** ensure dynamic completion placeholders are present
#### Scenario: Installation simulation
- **WHEN** testing installation logic
- **THEN** use temporary test directories instead of actual home directories
- **AND** verify file creation without modifying real shell configurations
- **AND** test path resolution logic independently
+217
View File
@@ -0,0 +1,217 @@
# cli-config Specification
## Purpose
Provide a user-friendly CLI interface for viewing and modifying global OpenSpec configuration settings without manually editing JSON files.
## Requirements
### Requirement: Command Structure
The config command SHALL provide subcommands for all configuration operations.
#### Scenario: Available subcommands
- **WHEN** user executes `openspec config --help`
- **THEN** display available subcommands:
- `path` - Show config file location
- `list` - Show all current settings
- `get <key>` - Get a specific value
- `set <key> <value>` - Set a value
- `unset <key>` - Remove a key (revert to default)
- `reset` - Reset configuration to defaults
- `edit` - Open config in editor
### Requirement: Config Path
The config command SHALL display the config file location.
#### Scenario: Show config path
- **WHEN** user executes `openspec config path`
- **THEN** print the absolute path to the config file
- **AND** exit with code 0
### Requirement: Config List
The config command SHALL display all current configuration values.
#### Scenario: List config in human-readable format
- **WHEN** user executes `openspec config list`
- **THEN** display all config values in YAML-like format
- **AND** show nested objects with indentation
#### Scenario: List config as JSON
- **WHEN** user executes `openspec config list --json`
- **THEN** output the complete config as valid JSON
- **AND** output only JSON (no additional text)
### Requirement: Config Get
The config command SHALL retrieve specific configuration values.
#### Scenario: Get top-level key
- **WHEN** user executes `openspec config get <key>` with a valid top-level key
- **THEN** print the raw value only (no labels or formatting)
- **AND** exit with code 0
#### Scenario: Get nested key with dot notation
- **WHEN** user executes `openspec config get featureFlags.someFlag`
- **THEN** traverse the nested structure using dot notation
- **AND** print the value at that path
#### Scenario: Get non-existent key
- **WHEN** user executes `openspec config get <key>` with a key that does not exist
- **THEN** print nothing (empty output)
- **AND** exit with code 1
#### Scenario: Get object value
- **WHEN** user executes `openspec config get <key>` where the value is an object
- **THEN** print the object as JSON
### Requirement: Config Set
The config command SHALL set configuration values with automatic type coercion.
#### Scenario: Set string value
- **WHEN** user executes `openspec config set <key> <value>`
- **AND** value does not match boolean or number patterns
- **THEN** store value as a string
- **AND** display confirmation message
#### Scenario: Set boolean value
- **WHEN** user executes `openspec config set <key> true` or `openspec config set <key> false`
- **THEN** store value as boolean (not string)
- **AND** display confirmation message
#### Scenario: Set numeric value
- **WHEN** user executes `openspec config set <key> <value>`
- **AND** value is a valid number (integer or float)
- **THEN** store value as number (not string)
#### Scenario: Force string with --string flag
- **WHEN** user executes `openspec config set <key> <value> --string`
- **THEN** store value as string regardless of content
- **AND** this allows storing literal "true" or "123" as strings
#### Scenario: Set nested key
- **WHEN** user executes `openspec config set featureFlags.newFlag true`
- **THEN** create intermediate objects if they don't exist
- **AND** set the value at the nested path
### Requirement: Config Unset
The config command SHALL remove configuration overrides.
#### Scenario: Unset existing key
- **WHEN** user executes `openspec config unset <key>`
- **AND** the key exists in the config
- **THEN** remove the key from the config file
- **AND** the value reverts to its default
- **AND** display confirmation message
#### Scenario: Unset non-existent key
- **WHEN** user executes `openspec config unset <key>`
- **AND** the key does not exist in the config
- **THEN** display message indicating key was not set
- **AND** exit with code 0
### Requirement: Config Reset
The config command SHALL reset configuration to defaults.
#### Scenario: Reset all with confirmation
- **WHEN** user executes `openspec config reset --all`
- **THEN** prompt for confirmation before proceeding
- **AND** if confirmed, delete the config file or reset to defaults
- **AND** display confirmation message
#### Scenario: Reset all with -y flag
- **WHEN** user executes `openspec config reset --all -y`
- **THEN** reset without prompting for confirmation
#### Scenario: Reset without --all flag
- **WHEN** user executes `openspec config reset` without `--all`
- **THEN** display error indicating `--all` is required
- **AND** exit with code 1
### Requirement: Config Edit
The config command SHALL open the config file in the user's editor.
#### Scenario: Open editor successfully
- **WHEN** user executes `openspec config edit`
- **AND** `$EDITOR` or `$VISUAL` environment variable is set
- **THEN** open the config file in that editor
- **AND** create the config file with defaults if it doesn't exist
- **AND** wait for the editor to close before returning
#### Scenario: No editor configured
- **WHEN** user executes `openspec config edit`
- **AND** neither `$EDITOR` nor `$VISUAL` is set
- **THEN** display error message suggesting to set `$EDITOR`
- **AND** exit with code 1
### Requirement: Key Naming Convention
The config command SHALL use camelCase keys matching the JSON structure.
#### Scenario: Keys match JSON structure
- **WHEN** accessing configuration keys via CLI
- **THEN** use camelCase matching the actual JSON property names
- **AND** support dot notation for nested access (e.g., `featureFlags.someFlag`)
### Requirement: Schema Validation
The config command SHALL validate configuration writes against the config schema using zod, while rejecting unknown keys for `config set` unless explicitly overridden.
#### Scenario: Unknown key rejected by default
- **WHEN** user executes `openspec config set someFutureKey 123`
- **THEN** display a descriptive error message indicating the key is invalid
- **AND** do not modify the config file
- **AND** exit with code 1
#### Scenario: Unknown key accepted with override
- **WHEN** user executes `openspec config set someFutureKey 123 --allow-unknown`
- **THEN** the value is saved successfully
- **AND** exit with code 0
#### Scenario: Invalid feature flag value rejected
- **WHEN** user executes `openspec config set featureFlags.someFlag notABoolean`
- **THEN** display a descriptive error message
- **AND** do not modify the config file
- **AND** exit with code 1
### Requirement: Reserved Scope Flag
The config command SHALL reserve the `--scope` flag for future extensibility.
#### Scenario: Scope flag defaults to global
- **WHEN** user executes any config command without `--scope`
- **THEN** operate on global configuration (default behavior)
#### Scenario: Project scope not yet implemented
- **WHEN** user executes `openspec config --scope project <subcommand>`
- **THEN** display error message: "Project-local config is not yet implemented"
- **AND** exit with code 1
+74
View File
@@ -64,6 +64,24 @@ The command SHALL properly configure selected AI tools with OpenSpec-specific in
- **THEN** create or update `CLAUDE.md` in the project root directory (not inside openspec/)
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
#### Scenario: Configuring CodeBuddy Code
- **WHEN** CodeBuddy Code is selected
- **THEN** create or update `CODEBUDDY.md` in the project root directory (not inside openspec/)
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
#### Scenario: Configuring Cline
- **WHEN** Cline is selected
- **THEN** create or update `CLINE.md` in the project root directory (not inside openspec/)
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
#### Scenario: Configuring iFlow CLI
- **WHEN** iFlow CLI is selected
- **THEN** create or update `IFLOW.md` in the project root directory (not inside openspec/)
- **AND** populate the managed block with a short stub that points teammates to `@/openspec/AGENTS.md`
#### Scenario: Creating new CLAUDE.md
- **WHEN** CLAUDE.md does not exist
@@ -106,6 +124,12 @@ The command SHALL provide clear, actionable next steps upon successful initializ
- **WHEN** initialization completes successfully
- **THEN** include prompt: "Please explain the OpenSpec workflow from openspec/AGENTS.md and how I should work with you on this project"
#### Scenario: Displaying restart instruction
- **WHEN** initialization completes successfully and tools were created or refreshed
- **THEN** display a prominent restart instruction before the "Next steps" section
- **AND** inform users that slash commands are loaded at startup
- **AND** instruct users to restart their coding assistant to ensure /openspec commands appear
### Requirement: Exit Codes
The command SHALL use consistent exit codes to indicate different failure modes.
@@ -154,12 +178,39 @@ The init command SHALL generate slash command files for supported editors using
- **AND** populate each file from shared templates so command text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for CodeBuddy Code
- **WHEN** the user selects CodeBuddy Code during initialization
- **THEN** create `.codebuddy/commands/openspec/proposal.md`, `.codebuddy/commands/openspec/apply.md`, and `.codebuddy/commands/openspec/archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Cline
- **WHEN** the user selects Cline during initialization
- **THEN** create `.clinerules/openspec-proposal.md`, `.clinerules/openspec-apply.md`, and `.clinerules/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** include Cline-specific Markdown heading frontmatter
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Crush
- **WHEN** the user selects Crush during initialization
- **THEN** create `.crush/commands/openspec/proposal.md`, `.crush/commands/openspec/apply.md`, and `.crush/commands/openspec/archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** include Crush-specific frontmatter with OpenSpec category and tags
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Cursor
- **WHEN** the user selects Cursor during initialization
- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Factory Droid
- **WHEN** the user selects Factory Droid during initialization
- **THEN** create `.factory/commands/openspec-proposal.md`, `.factory/commands/openspec-apply.md`, and `.factory/commands/openspec-archive.md`
- **AND** populate each file from shared templates that include Factory-compatible YAML frontmatter for the `description` and `argument-hint` fields
- **AND** include the `$ARGUMENTS` placeholder in the template body so droid receives any user-supplied input
- **AND** wrap the generated content in OpenSpec managed markers so `openspec update` can safely refresh the commands
#### Scenario: Generating slash commands for OpenCode
- **WHEN** the user selects OpenCode during initialization
- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md`
@@ -192,6 +243,29 @@ The init command SHALL generate slash command files for supported editors using
- **AND** wrap the shared template body with OpenSpec markers so `openspec update` can refresh the content
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for Gemini CLI
- **WHEN** the user selects Gemini CLI during initialization
- **THEN** create `.gemini/commands/openspec/proposal.toml`, `.gemini/commands/openspec/apply.toml`, and `.gemini/commands/openspec/archive.toml`
- **AND** populate each file as TOML that sets a stage-specific `description = "<summary>"` and a multi-line `prompt = """` block with the shared OpenSpec template
- **AND** wrap the OpenSpec managed markers (`<!-- OPENSPEC:START -->` / `<!-- OPENSPEC:END -->`) inside the `prompt` value so `openspec update` can safely refresh the body between markers without touching the TOML framing
- **AND** ensure the slash-command copy matches the existing proposal/apply/archive templates used by other tools
#### Scenario: Generating slash commands for iFlow CLI
- **WHEN** the user selects iFlow CLI during initialization
- **THEN** create `.iflow/commands/openspec-proposal.md`, `.iflow/commands/openspec-apply.md`, and `.iflow/commands/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** include YAML frontmatter with `name`, `id`, `category`, and `description` fields for each command
- **AND** wrap the generated content in OpenSpec managed markers so `openspec update` can safely refresh the commands
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
#### Scenario: Generating slash commands for RooCode
- **WHEN** the user selects RooCode during initialization
- **THEN** create `.roo/commands/openspec-proposal.md`, `.roo/commands/openspec-apply.md`, and `.roo/commands/openspec-archive.md`
- **AND** populate each file from shared templates so command text matches other tools
- **AND** include simple Markdown headings (e.g., `# OpenSpec: Proposal`) without YAML frontmatter
- **AND** wrap the generated content in OpenSpec managed markers where applicable so `openspec update` can safely refresh the commands
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
### Requirement: Non-Interactive Mode
The command SHALL support non-interactive operation through command-line options for automation and CI/CD use cases.
+59 -1
View File
@@ -50,22 +50,47 @@ The update command SHALL always update the core OpenSpec files and display an AS
- **AND** if a root-level stub exists, refresh it so it still directs contributors to `@/openspec/AGENTS.md`
### Requirement: Slash Command Updates
The update command SHALL refresh existing slash command files for configured tools without creating new ones.
The update command SHALL refresh existing slash command files for configured tools without creating new ones, and ensure the OpenCode archive command accepts change ID arguments.
#### Scenario: Updating slash commands for Claude Code
- **WHEN** `.claude/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`
- **THEN** refresh each file using shared templates
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Updating slash commands for CodeBuddy Code
- **WHEN** `.codebuddy/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md`
- **THEN** refresh each file using shared templates
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Updating slash commands for Cline
- **WHEN** `.clinerules/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using shared templates
- **AND** include Cline-specific Markdown heading frontmatter
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Updating slash commands for Crush
- **WHEN** `.crush/commands/` contains `openspec/proposal.md`, `openspec/apply.md`, and `openspec/archive.md`
- **THEN** refresh each file using shared templates
- **AND** include Crush-specific frontmatter with OpenSpec category and tags
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Updating slash commands for Cursor
- **WHEN** `.cursor/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using shared templates
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Updating slash commands for Factory Droid
- **WHEN** `.factory/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using the shared Factory templates that include YAML frontmatter for the `description` and `argument-hint` fields
- **AND** ensure the template body retains the `$ARGUMENTS` placeholder so user input keeps flowing into droid
- **AND** update only the content inside the OpenSpec managed markers, leaving any unmanaged notes untouched
- **AND** skip creating missing files during update
#### Scenario: Updating slash commands for OpenCode
- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using shared templates
- **AND** ensure templates include instructions for the relevant workflow stage
- **AND** ensure the archive command includes `$ARGUMENTS` placeholder in frontmatter for accepting change ID arguments
#### Scenario: Updating slash commands for Windsurf
- **WHEN** `.windsurf/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
@@ -92,10 +117,43 @@ The update command SHALL refresh existing slash command files for configured too
- **AND** update only the OpenSpec-managed block between markers
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Updating slash commands for Gemini CLI
- **WHEN** `.gemini/commands/openspec/` contains `proposal.toml`, `apply.toml`, and `archive.toml`
- **THEN** refresh the body of each file using the shared proposal/apply/archive templates
- **AND** replace only the content between `<!-- OPENSPEC:START -->` and `<!-- OPENSPEC:END -->` markers inside the `prompt = """` block so the TOML framing (`description`, `prompt`) stays intact
- **AND** skip creating any missing `.toml` files during update; only pre-existing Gemini commands are refreshed
#### Scenario: Updating slash commands for iFlow CLI
- **WHEN** `.iflow/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
- **THEN** refresh each file using shared templates
- **AND** preserve the YAML frontmatter with `name`, `id`, `category`, and `description` fields
- **AND** update only the OpenSpec-managed block between markers
- **AND** ensure templates include instructions for the relevant workflow stage
#### Scenario: Missing slash command file
- **WHEN** a tool lacks a slash command file
- **THEN** do not create a new file during update
### Requirement: Archive Command Argument Support
The archive slash command template SHALL support optional change ID arguments for tools that support `$ARGUMENTS` placeholder.
#### Scenario: Archive command with change ID argument
- **WHEN** a user invokes `/openspec:archive <change-id>` with a change ID
- **THEN** the template SHALL instruct the AI to validate the provided change ID against `openspec list`
- **AND** use the provided change ID for archiving if valid
- **AND** fail fast if the provided change ID doesn't match an archivable change
#### Scenario: Archive command without argument (backward compatibility)
- **WHEN** a user invokes `/openspec:archive` without providing a change ID
- **THEN** the template SHALL instruct the AI to identify the change ID from context or by running `openspec list`
- **AND** proceed with the existing behavior (maintaining backward compatibility)
#### Scenario: OpenCode archive template generation
- **WHEN** generating the OpenCode archive slash command file
- **THEN** include the `$ARGUMENTS` placeholder in the frontmatter
- **AND** wrap it in a clear structure like `<ChangeId>\n $ARGUMENTS\n</ChangeId>` to indicate the expected argument
- **AND** include validation steps in the template body to check if the change ID is valid
## Edge Cases
### Requirement: Error Handling
+81
View File
@@ -0,0 +1,81 @@
# global-config Specification
## Purpose
This spec defines how OpenSpec resolves, reads, and writes user-level global configuration. It governs the `src/core/global-config.ts` module, which provides the foundation for storing user preferences, feature flags, and settings that persist across projects. The spec ensures cross-platform compatibility by following XDG Base Directory Specification with platform-specific fallbacks, and guarantees forward/backward compatibility through schema evolution rules.
## Requirements
### Requirement: Global Config Directory Path
The system SHALL resolve the global configuration directory path following XDG Base Directory Specification with platform-specific fallbacks.
#### Scenario: Unix/macOS with XDG_CONFIG_HOME set
- **WHEN** `$XDG_CONFIG_HOME` environment variable is set to `/custom/config`
- **THEN** `getGlobalConfigDir()` returns `/custom/config/openspec`
#### Scenario: Unix/macOS without XDG_CONFIG_HOME
- **WHEN** `$XDG_CONFIG_HOME` environment variable is not set
- **AND** the platform is Unix or macOS
- **THEN** `getGlobalConfigDir()` returns `~/.config/openspec` (expanded to absolute path)
#### Scenario: Windows platform
- **WHEN** the platform is Windows
- **AND** `%APPDATA%` is set to `C:\Users\User\AppData\Roaming`
- **THEN** `getGlobalConfigDir()` returns `C:\Users\User\AppData\Roaming\openspec`
### Requirement: Global Config Loading
The system SHALL load global configuration from the config directory with sensible defaults when the config file does not exist or cannot be parsed.
#### Scenario: Config file exists and is valid
- **WHEN** `config.json` exists in the global config directory
- **AND** the file contains valid JSON matching the config schema
- **THEN** `getGlobalConfig()` returns the parsed configuration
#### Scenario: Config file does not exist
- **WHEN** `config.json` does not exist in the global config directory
- **THEN** `getGlobalConfig()` returns the default configuration
- **AND** no directory or file is created
#### Scenario: Config file is invalid JSON
- **WHEN** `config.json` exists but contains invalid JSON
- **THEN** `getGlobalConfig()` returns the default configuration
- **AND** a warning is logged to stderr
### Requirement: Global Config Saving
The system SHALL save global configuration to the config directory, creating the directory if it does not exist.
#### Scenario: Save config to new directory
- **WHEN** `saveGlobalConfig(config)` is called
- **AND** the global config directory does not exist
- **THEN** the directory is created
- **AND** `config.json` is written with the provided configuration
#### Scenario: Save config to existing directory
- **WHEN** `saveGlobalConfig(config)` is called
- **AND** the global config directory already exists
- **THEN** `config.json` is written (overwriting if exists)
### Requirement: Default Configuration
The system SHALL provide a default configuration that is used when no config file exists.
#### Scenario: Default config structure
- **WHEN** no config file exists
- **THEN** the default configuration includes an empty `featureFlags` object
### Requirement: Config Schema Evolution
The system SHALL merge loaded configuration with default values to ensure new config fields are available even when loading older config files.
#### Scenario: Config file missing new fields
- **WHEN** `config.json` exists with `{ "featureFlags": {} }`
- **AND** the current schema includes a new field `defaultAiTool`
- **THEN** `getGlobalConfig()` returns `{ featureFlags: {}, defaultAiTool: <default> }`
- **AND** the loaded values take precedence over defaults for fields that exist in both
#### Scenario: Config file has extra unknown fields
- **WHEN** `config.json` contains fields not in the current schema
- **THEN** the unknown fields are preserved in the returned configuration
- **AND** no error or warning is raised
+10 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@fission-ai/openspec",
"version": "0.12.0",
"version": "0.17.2",
"description": "AI-native system for spec-driven development",
"keywords": [
"openspec",
@@ -32,11 +32,13 @@
"files": [
"dist",
"bin",
"scripts/postinstall.js",
"!dist/**/*.test.js",
"!dist/**/__tests__",
"!dist/**/*.map"
],
"scripts": {
"lint": "eslint src/",
"build": "node build.js",
"dev": "tsc --watch",
"dev:cli": "pnpm build && node bin/openspec.js",
@@ -44,8 +46,10 @@
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:postinstall": "node scripts/postinstall.js",
"prepare": "pnpm run build",
"prepublishOnly": "pnpm run build",
"postinstall": "node scripts/postinstall.js",
"check:pack-version": "node scripts/pack-version-check.mjs",
"release": "pnpm run release:ci",
"release:ci": "pnpm run check:pack-version && pnpm exec changeset publish",
@@ -59,7 +63,9 @@
"@changesets/cli": "^2.27.7",
"@types/node": "^24.2.0",
"@vitest/ui": "^3.2.4",
"typescript": "^5.9.2",
"eslint": "^9.39.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.50.1",
"vitest": "^3.2.4"
},
"dependencies": {
@@ -67,7 +73,9 @@
"@inquirer/prompts": "^7.8.0",
"chalk": "^5.5.0",
"commander": "^14.0.0",
"fast-glob": "^3.3.3",
"ora": "^8.2.0",
"yaml": "^2.8.2",
"zod": "^4.0.17"
}
}
+792 -16
View File
File diff suppressed because it is too large Load Diff
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env node
/**
* Postinstall script for auto-installing shell completions
*
* This script runs automatically after npm install unless:
* - CI=true environment variable is set
* - OPENSPEC_NO_COMPLETIONS=1 environment variable is set
* - dist/ directory doesn't exist (dev setup scenario)
*
* The script never fails npm install - all errors are caught and handled gracefully.
*/
import { promises as fs } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* Check if we should skip installation
*/
function shouldSkipInstallation() {
// Skip in CI environments
if (process.env.CI === 'true' || process.env.CI === '1') {
return { skip: true, reason: 'CI environment detected' };
}
// Skip if user opted out
if (process.env.OPENSPEC_NO_COMPLETIONS === '1') {
return { skip: true, reason: 'OPENSPEC_NO_COMPLETIONS=1 set' };
}
return { skip: false };
}
/**
* Check if dist/ directory exists
*/
async function distExists() {
const distPath = path.join(__dirname, '..', 'dist');
try {
const stat = await fs.stat(distPath);
return stat.isDirectory();
} catch {
return false;
}
}
/**
* Detect the user's shell
*/
async function detectShell() {
try {
const { detectShell } = await import('../dist/utils/shell-detection.js');
const result = detectShell();
return result.shell;
} catch (error) {
// Fail silently if detection module doesn't exist
return undefined;
}
}
/**
* Install completions for the detected shell
*/
async function installCompletions(shell) {
try {
const { CompletionFactory } = await import('../dist/core/completions/factory.js');
const { COMMAND_REGISTRY } = await import('../dist/core/completions/command-registry.js');
// Check if shell is supported
if (!CompletionFactory.isSupported(shell)) {
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
return;
}
// Generate completion script
const generator = CompletionFactory.createGenerator(shell);
const script = generator.generate(COMMAND_REGISTRY);
// Install completion script
const installer = CompletionFactory.createInstaller(shell);
const result = await installer.install(script);
if (result.success) {
// Show success message based on installation type
if (result.isOhMyZsh) {
console.log(`✓ Shell completions installed`);
console.log(` Restart shell: exec zsh`);
} else if (result.zshrcConfigured) {
console.log(`✓ Shell completions installed and configured`);
console.log(` Restart shell: exec zsh`);
} else {
console.log(`✓ Shell completions installed to ~/.zsh/completions/`);
console.log(` Add to ~/.zshrc: fpath=(~/.zsh/completions $fpath)`);
console.log(` Then: exec zsh`);
}
} else {
// Installation failed, show tip for manual install
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
}
} catch (error) {
// Fail gracefully - show tip for manual install
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
}
}
/**
* Main function
*/
async function main() {
try {
// Check if we should skip
const skipCheck = shouldSkipInstallation();
if (skipCheck.skip) {
// Silent skip - no output
return;
}
// Check if dist/ exists (skip silently if not - expected during dev setup)
if (!(await distExists())) {
return;
}
// Detect shell
const shell = await detectShell();
if (!shell) {
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
return;
}
// Install completions
await installCompletions(shell);
} catch (error) {
// Fail gracefully - never break npm install
// Show tip for manual install
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
}
}
// Run main and handle any unhandled errors
main().catch(() => {
// Silent failure - never break npm install
process.exit(0);
});
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
# Test script for postinstall.js
# Tests different scenarios: normal install, CI, opt-out
set -e
echo "======================================"
echo "Testing OpenSpec Postinstall Script"
echo "======================================"
echo ""
# Save original environment
ORIGINAL_CI="${CI:-}"
ORIGINAL_OPENSPEC_NO_COMPLETIONS="${OPENSPEC_NO_COMPLETIONS:-}"
# Test 1: Normal install
echo "Test 1: Normal install (should attempt to install completions)"
echo "--------------------------------------"
unset CI
unset OPENSPEC_NO_COMPLETIONS
node scripts/postinstall.js
echo ""
# Test 2: CI environment (should skip silently)
echo "Test 2: CI=true (should skip silently)"
echo "--------------------------------------"
export CI=true
node scripts/postinstall.js
echo "[No output expected - skipped due to CI]"
echo ""
# Test 3: Opt-out flag (should skip silently)
echo "Test 3: OPENSPEC_NO_COMPLETIONS=1 (should skip silently)"
echo "--------------------------------------"
unset CI
export OPENSPEC_NO_COMPLETIONS=1
node scripts/postinstall.js
echo "[No output expected - skipped due to opt-out]"
echo ""
# Restore original environment
if [ -n "$ORIGINAL_CI" ]; then
export CI="$ORIGINAL_CI"
else
unset CI
fi
if [ -n "$ORIGINAL_OPENSPEC_NO_COMPLETIONS" ]; then
export OPENSPEC_NO_COMPLETIONS="$ORIGINAL_OPENSPEC_NO_COMPLETIONS"
else
unset OPENSPEC_NO_COMPLETIONS
fi
echo "======================================"
echo "All tests completed successfully!"
echo "======================================"
+68 -2
View File
@@ -3,7 +3,6 @@ import { createRequire } from 'module';
import ora from 'ora';
import path from 'path';
import { promises as fs } from 'fs';
import { InitCommand } from '../core/init.js';
import { AI_TOOLS } from '../core/config.js';
import { UpdateCommand } from '../core/update.js';
import { ListCommand } from '../core/list.js';
@@ -13,6 +12,8 @@ import { registerSpecCommand } from '../commands/spec.js';
import { ChangeCommand } from '../commands/change.js';
import { ValidateCommand } from '../commands/validate.js';
import { ShowCommand } from '../commands/show.js';
import { CompletionCommand } from '../commands/completion.js';
import { registerConfigCommand } from '../commands/config.js';
const program = new Command();
const require = createRequire(import.meta.url);
@@ -29,7 +30,7 @@ program.option('--no-color', 'Disable color output');
// Apply global flags before any command runs
program.hook('preAction', (thisCommand) => {
const opts = thisCommand.opts();
if (opts.noColor) {
if (opts.color === false) {
process.env.NO_COLOR = '1';
}
});
@@ -62,6 +63,7 @@ program
}
}
const { InitCommand } = await import('../core/init.js');
const initCommand = new InitCommand({
tools: options?.tools,
});
@@ -199,6 +201,7 @@ program
});
registerSpecCommand(program);
registerConfigCommand(program);
// Top-level validate command
program
@@ -250,4 +253,67 @@ program
}
});
// Completion command with subcommands
const completionCmd = program
.command('completion')
.description('Manage shell completions for OpenSpec CLI');
completionCmd
.command('generate [shell]')
.description('Generate completion script for a shell (outputs to stdout)')
.action(async (shell?: string) => {
try {
const completionCommand = new CompletionCommand();
await completionCommand.generate({ shell });
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
completionCmd
.command('install [shell]')
.description('Install completion script for a shell')
.option('--verbose', 'Show detailed installation output')
.action(async (shell?: string, options?: { verbose?: boolean }) => {
try {
const completionCommand = new CompletionCommand();
await completionCommand.install({ shell, verbose: options?.verbose });
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
completionCmd
.command('uninstall [shell]')
.description('Uninstall completion script for a shell')
.option('-y, --yes', 'Skip confirmation prompts')
.action(async (shell?: string, options?: { yes?: boolean }) => {
try {
const completionCommand = new CompletionCommand();
await completionCommand.uninstall({ shell, yes: options?.yes });
} catch (error) {
console.log();
ora().fail(`Error: ${(error as Error).message}`);
process.exit(1);
}
});
// Hidden command for machine-readable completion data
program
.command('__complete <type>', { hidden: true })
.description('Output completion data in machine-readable format (internal use)')
.action(async (type: string) => {
try {
const completionCommand = new CompletionCommand();
await completionCommand.complete({ type });
} catch (error) {
// Silently fail for graceful shell completion experience
process.exitCode = 1;
}
});
program.parse();
+15 -14
View File
@@ -1,6 +1,5 @@
import { promises as fs } from 'fs';
import path from 'path';
import { select } from '@inquirer/prompts';
import { JsonConverter } from '../core/converters/json-converter.js';
import { Validator } from '../core/validation/validator.js';
import { ChangeParser } from '../core/parsers/change-parser.js';
@@ -28,11 +27,12 @@ export class ChangeCommand {
*/
async show(changeName?: string, options?: { json?: boolean; requirementsOnly?: boolean; deltasOnly?: boolean; noInteractive?: boolean }): Promise<void> {
const changesPath = path.join(process.cwd(), 'openspec', 'changes');
if (!changeName) {
const canPrompt = isInteractive(options?.noInteractive);
const canPrompt = isInteractive(options);
const changes = await this.getActiveChanges(changesPath);
if (canPrompt && changes.length > 0) {
const { select } = await import('@inquirer/prompts');
const selected = await select({
message: 'Select a change to show',
choices: changes.map(id => ({ name: id, value: id })),
@@ -49,25 +49,25 @@ export class ChangeCommand {
return;
}
}
const proposalPath = path.join(changesPath, changeName, 'proposal.md');
try {
await fs.access(proposalPath);
} catch {
throw new Error(`Change "${changeName}" not found at ${proposalPath}`);
}
if (options?.json) {
const jsonOutput = await this.converter.convertChangeToJson(proposalPath);
if (options.requirementsOnly) {
console.error('Flag --requirements-only is deprecated; use --deltas-only instead.');
}
const parsed: Change = JSON.parse(jsonOutput);
const contentForTitle = await fs.readFile(proposalPath, 'utf-8');
const title = this.extractTitle(contentForTitle);
const title = this.extractTitle(contentForTitle, changeName);
const id = parsed.name;
const deltas = parsed.deltas || [];
@@ -124,7 +124,7 @@ export class ChangeCommand {
return {
id: changeName,
title: this.extractTitle(content),
title: this.extractTitle(content, changeName),
deltaCount: change.deltas.length,
taskStatus,
};
@@ -159,7 +159,7 @@ export class ChangeCommand {
const tasksPath = path.join(changesPath, changeName, 'tasks.md');
try {
const content = await fs.readFile(proposalPath, 'utf-8');
const title = this.extractTitle(content);
const title = this.extractTitle(content, changeName);
let taskStatusText = '';
try {
const tasksContent = await fs.readFile(tasksPath, 'utf-8');
@@ -186,9 +186,10 @@ export class ChangeCommand {
const changesPath = path.join(process.cwd(), 'openspec', 'changes');
if (!changeName) {
const canPrompt = isInteractive(options?.noInteractive);
const canPrompt = isInteractive(options);
const changes = await getActiveChangeIds();
if (canPrompt && changes.length > 0) {
const { select } = await import('@inquirer/prompts');
const selected = await select({
message: 'Select a change to validate',
choices: changes.map(id => ({ name: id, value: id })),
@@ -258,9 +259,9 @@ export class ChangeCommand {
}
}
private extractTitle(content: string): string {
const match = content.match(/^#\s+(?:Change:\s+)?(.+)$/m);
return match ? match[1].trim() : 'Untitled Change';
private extractTitle(content: string, changeName: string): string {
const match = content.match(/^#\s+(?:Change:\s+)?(.+)$/im);
return match ? match[1].trim() : changeName;
}
private countTasks(content: string): { total: number; completed: number } {
+262
View File
@@ -0,0 +1,262 @@
import ora from 'ora';
import { CompletionFactory } from '../core/completions/factory.js';
import { COMMAND_REGISTRY } from '../core/completions/command-registry.js';
import { detectShell, SupportedShell } from '../utils/shell-detection.js';
import { CompletionProvider } from '../core/completions/completion-provider.js';
import { getArchivedChangeIds } from '../utils/item-discovery.js';
interface GenerateOptions {
shell?: string;
}
interface InstallOptions {
shell?: string;
verbose?: boolean;
}
interface UninstallOptions {
shell?: string;
yes?: boolean;
}
interface CompleteOptions {
type: string;
}
/**
* Command for managing shell completions for OpenSpec CLI
*/
export class CompletionCommand {
private completionProvider: CompletionProvider;
constructor() {
this.completionProvider = new CompletionProvider();
}
/**
* Resolve shell parameter or exit with error
*
* @param shell - The shell parameter (may be undefined)
* @param operationName - Name of the operation (for error messages)
* @returns Resolved shell or null if should exit
*/
private resolveShellOrExit(shell: string | undefined, operationName: string): SupportedShell | null {
const normalizedShell = this.normalizeShell(shell);
if (!normalizedShell) {
const detectionResult = detectShell();
if (detectionResult.shell && CompletionFactory.isSupported(detectionResult.shell)) {
return detectionResult.shell;
}
// Shell was detected but not supported
if (detectionResult.detected && !detectionResult.shell) {
console.error(`Error: Shell '${detectionResult.detected}' is not supported yet. Currently supported: ${CompletionFactory.getSupportedShells().join(', ')}`);
process.exitCode = 1;
return null;
}
// No shell specified and cannot auto-detect
console.error('Error: Could not auto-detect shell. Please specify shell explicitly.');
console.error(`Usage: openspec completion ${operationName} [shell]`);
console.error(`Currently supported: ${CompletionFactory.getSupportedShells().join(', ')}`);
process.exitCode = 1;
return null;
}
if (!CompletionFactory.isSupported(normalizedShell)) {
console.error(`Error: Shell '${normalizedShell}' is not supported yet. Currently supported: ${CompletionFactory.getSupportedShells().join(', ')}`);
process.exitCode = 1;
return null;
}
return normalizedShell;
}
/**
* Generate completion script and output to stdout
*
* @param options - Options for generation (shell type)
*/
async generate(options: GenerateOptions = {}): Promise<void> {
const shell = this.resolveShellOrExit(options.shell, 'generate');
if (!shell) return;
await this.generateForShell(shell);
}
/**
* Install completion script to the appropriate location
*
* @param options - Options for installation (shell type, verbose output)
*/
async install(options: InstallOptions = {}): Promise<void> {
const shell = this.resolveShellOrExit(options.shell, 'install');
if (!shell) return;
await this.installForShell(shell, options.verbose || false);
}
/**
* Uninstall completion script from the installation location
*
* @param options - Options for uninstallation (shell type, yes flag)
*/
async uninstall(options: UninstallOptions = {}): Promise<void> {
const shell = this.resolveShellOrExit(options.shell, 'uninstall');
if (!shell) return;
await this.uninstallForShell(shell, options.yes || false);
}
/**
* Generate completion script for a specific shell
*/
private async generateForShell(shell: SupportedShell): Promise<void> {
const generator = CompletionFactory.createGenerator(shell);
const script = generator.generate(COMMAND_REGISTRY);
console.log(script);
}
/**
* Install completion script for a specific shell
*/
private async installForShell(shell: SupportedShell, verbose: boolean): Promise<void> {
const generator = CompletionFactory.createGenerator(shell);
const installer = CompletionFactory.createInstaller(shell);
const spinner = ora(`Installing ${shell} completion script...`).start();
try {
// Generate the completion script
const script = generator.generate(COMMAND_REGISTRY);
// Install it
const result = await installer.install(script);
spinner.stop();
if (result.success) {
console.log(`✓ ${result.message}`);
if (verbose && result.installedPath) {
console.log(` Installed to: ${result.installedPath}`);
if (result.backupPath) {
console.log(` Backup created: ${result.backupPath}`);
}
if (result.zshrcConfigured) {
console.log(` ~/.zshrc configured automatically`);
}
}
// Print instructions (only shown if .zshrc wasn't auto-configured)
if (result.instructions && result.instructions.length > 0) {
console.log('');
for (const instruction of result.instructions) {
console.log(instruction);
}
} else if (result.zshrcConfigured) {
console.log('');
console.log('Restart your shell or run: exec zsh');
}
} else {
console.error(`✗ ${result.message}`);
process.exitCode = 1;
}
} catch (error) {
spinner.stop();
console.error(`✗ Failed to install completion script: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
}
}
/**
* Uninstall completion script for a specific shell
*/
private async uninstallForShell(shell: SupportedShell, skipConfirmation: boolean): Promise<void> {
const installer = CompletionFactory.createInstaller(shell);
// Prompt for confirmation unless --yes flag is provided
if (!skipConfirmation) {
const { confirm } = await import('@inquirer/prompts');
const confirmed = await confirm({
message: 'Remove OpenSpec configuration from ~/.zshrc?',
default: false,
});
if (!confirmed) {
console.log('Uninstall cancelled.');
return;
}
}
const spinner = ora(`Uninstalling ${shell} completion script...`).start();
try {
const result = await installer.uninstall();
spinner.stop();
if (result.success) {
console.log(`✓ ${result.message}`);
} else {
console.error(`✗ ${result.message}`);
process.exitCode = 1;
}
} catch (error) {
spinner.stop();
console.error(`✗ Failed to uninstall completion script: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
}
}
/**
* Output machine-readable completion data for shell consumption
* Format: tab-separated "id\tdescription" per line
*
* @param options - Options specifying completion type
*/
async complete(options: CompleteOptions): Promise<void> {
const type = options.type.toLowerCase();
try {
switch (type) {
case 'changes': {
const changeIds = await this.completionProvider.getChangeIds();
for (const id of changeIds) {
console.log(`${id}\tactive change`);
}
break;
}
case 'specs': {
const specIds = await this.completionProvider.getSpecIds();
for (const id of specIds) {
console.log(`${id}\tspecification`);
}
break;
}
case 'archived-changes': {
const archivedIds = await getArchivedChangeIds();
for (const id of archivedIds) {
console.log(`${id}\tarchived change`);
}
break;
}
default:
// Invalid type - silently exit with no output for graceful shell completion failure
process.exitCode = 1;
break;
}
} catch {
// Silently fail for graceful shell completion experience
process.exitCode = 1;
}
}
/**
* Normalize shell parameter to lowercase
*/
private normalizeShell(shell?: string): string | undefined {
return shell?.toLowerCase();
}
}
+233
View File
@@ -0,0 +1,233 @@
import { Command } from 'commander';
import { spawn } from 'node:child_process';
import * as fs from 'node:fs';
import {
getGlobalConfigPath,
getGlobalConfig,
saveGlobalConfig,
GlobalConfig,
} from '../core/global-config.js';
import {
getNestedValue,
setNestedValue,
deleteNestedValue,
coerceValue,
formatValueYaml,
validateConfigKeyPath,
validateConfig,
DEFAULT_CONFIG,
} from '../core/config-schema.js';
/**
* Register the config command and all its subcommands.
*
* @param program - The Commander program instance
*/
export function registerConfigCommand(program: Command): void {
const configCmd = program
.command('config')
.description('View and modify global OpenSpec configuration')
.option('--scope <scope>', 'Config scope (only "global" supported currently)')
.hook('preAction', (thisCommand) => {
const opts = thisCommand.opts();
if (opts.scope && opts.scope !== 'global') {
console.error('Error: Project-local config is not yet implemented');
process.exit(1);
}
});
// config path
configCmd
.command('path')
.description('Show config file location')
.action(() => {
console.log(getGlobalConfigPath());
});
// config list
configCmd
.command('list')
.description('Show all current settings')
.option('--json', 'Output as JSON')
.action((options: { json?: boolean }) => {
const config = getGlobalConfig();
if (options.json) {
console.log(JSON.stringify(config, null, 2));
} else {
console.log(formatValueYaml(config));
}
});
// config get
configCmd
.command('get <key>')
.description('Get a specific value (raw, scriptable)')
.action((key: string) => {
const config = getGlobalConfig();
const value = getNestedValue(config as Record<string, unknown>, key);
if (value === undefined) {
process.exitCode = 1;
return;
}
if (typeof value === 'object' && value !== null) {
console.log(JSON.stringify(value));
} else {
console.log(String(value));
}
});
// config set
configCmd
.command('set <key> <value>')
.description('Set a value (auto-coerce types)')
.option('--string', 'Force value to be stored as string')
.option('--allow-unknown', 'Allow setting unknown keys')
.action((key: string, value: string, options: { string?: boolean; allowUnknown?: boolean }) => {
const allowUnknown = Boolean(options.allowUnknown);
const keyValidation = validateConfigKeyPath(key);
if (!keyValidation.valid && !allowUnknown) {
const reason = keyValidation.reason ? ` ${keyValidation.reason}.` : '';
console.error(`Error: Invalid configuration key "${key}".${reason}`);
console.error('Use "openspec config list" to see available keys.');
console.error('Pass --allow-unknown to bypass this check.');
process.exitCode = 1;
return;
}
const config = getGlobalConfig() as Record<string, unknown>;
const coercedValue = coerceValue(value, options.string || false);
// Create a copy to validate before saving
const newConfig = JSON.parse(JSON.stringify(config));
setNestedValue(newConfig, key, coercedValue);
// Validate the new config
const validation = validateConfig(newConfig);
if (!validation.success) {
console.error(`Error: Invalid configuration - ${validation.error}`);
process.exitCode = 1;
return;
}
// Apply changes and save
setNestedValue(config, key, coercedValue);
saveGlobalConfig(config as GlobalConfig);
const displayValue =
typeof coercedValue === 'string' ? `"${coercedValue}"` : String(coercedValue);
console.log(`Set ${key} = ${displayValue}`);
});
// config unset
configCmd
.command('unset <key>')
.description('Remove a key (revert to default)')
.action((key: string) => {
const config = getGlobalConfig() as Record<string, unknown>;
const existed = deleteNestedValue(config, key);
if (existed) {
saveGlobalConfig(config as GlobalConfig);
console.log(`Unset ${key} (reverted to default)`);
} else {
console.log(`Key "${key}" was not set`);
}
});
// config reset
configCmd
.command('reset')
.description('Reset configuration to defaults')
.option('--all', 'Reset all configuration (required)')
.option('-y, --yes', 'Skip confirmation prompts')
.action(async (options: { all?: boolean; yes?: boolean }) => {
if (!options.all) {
console.error('Error: --all flag is required for reset');
console.error('Usage: openspec config reset --all [-y]');
process.exitCode = 1;
return;
}
if (!options.yes) {
const { confirm } = await import('@inquirer/prompts');
const confirmed = await confirm({
message: 'Reset all configuration to defaults?',
default: false,
});
if (!confirmed) {
console.log('Reset cancelled.');
return;
}
}
saveGlobalConfig({ ...DEFAULT_CONFIG });
console.log('Configuration reset to defaults');
});
// config edit
configCmd
.command('edit')
.description('Open config in $EDITOR')
.action(async () => {
const editor = process.env.EDITOR || process.env.VISUAL;
if (!editor) {
console.error('Error: No editor configured');
console.error('Set the EDITOR or VISUAL environment variable to your preferred editor');
console.error('Example: export EDITOR=vim');
process.exitCode = 1;
return;
}
const configPath = getGlobalConfigPath();
// Ensure config file exists with defaults
if (!fs.existsSync(configPath)) {
saveGlobalConfig({ ...DEFAULT_CONFIG });
}
// Spawn editor and wait for it to close
// Avoid shell parsing to correctly handle paths with spaces in both
// the editor path and config path
const child = spawn(editor, [configPath], {
stdio: 'inherit',
shell: false,
});
await new Promise<void>((resolve, reject) => {
child.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Editor exited with code ${code}`));
}
});
child.on('error', reject);
});
try {
const rawConfig = fs.readFileSync(configPath, 'utf-8');
const parsedConfig = JSON.parse(rawConfig);
const validation = validateConfig(parsedConfig);
if (!validation.success) {
console.error(`Error: Invalid configuration - ${validation.error}`);
process.exitCode = 1;
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
console.error(`Error: Config file not found at ${configPath}`);
} else if (error instanceof SyntaxError) {
console.error(`Error: Invalid JSON in ${configPath}`);
console.error(error.message);
} else {
console.error(`Error: Unable to validate configuration - ${error instanceof Error ? error.message : String(error)}`);
}
process.exitCode = 1;
}
});
}
+3 -4
View File
@@ -1,4 +1,3 @@
import { select } from '@inquirer/prompts';
import path from 'path';
import { isInteractive } from '../utils/interactive.js';
import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js';
@@ -13,11 +12,12 @@ const SPEC_FLAG_KEYS = new Set(['requirements', 'scenarios', 'requirement']);
export class ShowCommand {
async execute(itemName?: string, options: { json?: boolean; type?: string; noInteractive?: boolean; [k: string]: any } = {}): Promise<void> {
const interactive = isInteractive(options.noInteractive);
const interactive = isInteractive(options);
const typeOverride = this.normalizeType(options.type);
if (!itemName) {
if (interactive) {
const { select } = await import('@inquirer/prompts');
const type = await select<ItemType>({
message: 'What would you like to show?',
choices: [
@@ -44,6 +44,7 @@ export class ShowCommand {
}
private async runInteractiveByType(type: ItemType, options: { json?: boolean; noInteractive?: boolean; [k: string]: any }): Promise<void> {
const { select } = await import('@inquirer/prompts');
if (type === 'change') {
const changes = await getActiveChangeIds();
if (changes.length === 0) {
@@ -135,5 +136,3 @@ export class ShowCommand {
return false;
}
}
+5 -4
View File
@@ -4,7 +4,6 @@ import { join } from 'path';
import { MarkdownParser } from '../core/parsers/markdown-parser.js';
import { Validator } from '../core/validation/validator.js';
import type { Spec } from '../core/schemas/index.js';
import { select } from '@inquirer/prompts';
import { isInteractive } from '../utils/interactive.js';
import { getSpecIds } from '../utils/item-discovery.js';
@@ -70,9 +69,10 @@ export class SpecCommand {
async show(specId?: string, options: ShowOptions = {}): Promise<void> {
if (!specId) {
const canPrompt = isInteractive(options?.noInteractive);
const canPrompt = isInteractive(options);
const specIds = await getSpecIds();
if (canPrompt && specIds.length > 0) {
const { select } = await import('@inquirer/prompts');
specId = await select({
message: 'Select a spec to show',
choices: specIds.map(id => ({ name: id, value: id })),
@@ -204,9 +204,10 @@ export function registerSpecCommand(rootProgram: typeof program) {
.action(async (specId: string | undefined, options: { strict?: boolean; json?: boolean; noInteractive?: boolean }) => {
try {
if (!specId) {
const canPrompt = isInteractive(options?.noInteractive);
const canPrompt = isInteractive(options);
const specIds = await getSpecIds();
if (canPrompt && specIds.length > 0) {
const { select } = await import('@inquirer/prompts');
specId = await select({
message: 'Select a spec to validate',
choices: specIds.map(id => ({ name: id, value: id })),
@@ -247,4 +248,4 @@ export function registerSpecCommand(rootProgram: typeof program) {
});
return specCommand;
}
}
+29 -8
View File
@@ -1,8 +1,7 @@
import { select } from '@inquirer/prompts';
import ora from 'ora';
import path from 'path';
import { Validator } from '../core/validation/validator.js';
import { isInteractive } from '../utils/interactive.js';
import { isInteractive, resolveNoInteractive } from '../utils/interactive.js';
import { getActiveChangeIds, getSpecIds } from '../utils/item-discovery.js';
import { nearestMatches } from '../utils/match.js';
@@ -16,6 +15,7 @@ interface ExecuteOptions {
strict?: boolean;
json?: boolean;
noInteractive?: boolean;
interactive?: boolean; // Commander sets this to false when --no-interactive is used
concurrency?: string;
}
@@ -29,14 +29,14 @@ interface BulkItemResult {
export class ValidateCommand {
async execute(itemName: string | undefined, options: ExecuteOptions = {}): Promise<void> {
const interactive = isInteractive(options.noInteractive);
const interactive = isInteractive(options);
// Handle bulk flags first
if (options.all || options.changes || options.specs) {
await this.runBulkValidation({
changes: !!options.all || !!options.changes,
specs: !!options.all || !!options.specs,
}, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency });
}, { strict: !!options.strict, json: !!options.json, concurrency: options.concurrency, noInteractive: resolveNoInteractive(options) });
return;
}
@@ -64,6 +64,7 @@ export class ValidateCommand {
}
private async runInteractiveSelector(opts: { strict: boolean; json: boolean; concurrency?: string }): Promise<void> {
const { select } = await import('@inquirer/prompts');
const choice = await select({
message: 'What would you like to validate?',
choices: [
@@ -180,8 +181,8 @@ export class ValidateCommand {
bullets.forEach(b => console.error(` ${b}`));
}
private async runBulkValidation(scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string }): Promise<void> {
const spinner = !opts.json ? ora('Validating...').start() : undefined;
private async runBulkValidation(scope: { changes: boolean; specs: boolean }, opts: { strict: boolean; json: boolean; concurrency?: string; noInteractive?: boolean }): Promise<void> {
const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined;
const [changeIds, specIds] = await Promise.all([
scope.changes ? getActiveChangeIds() : Promise.resolve<string[]>([]),
scope.specs ? getSpecIds() : Promise.resolve<string[]>([]),
@@ -212,6 +213,28 @@ export class ValidateCommand {
});
}
if (queue.length === 0) {
spinner?.stop();
const summary = {
totals: { items: 0, passed: 0, failed: 0 },
byType: {
...(scope.changes ? { change: { items: 0, passed: 0, failed: 0 } } : {}),
...(scope.specs ? { spec: { items: 0, passed: 0, failed: 0 } } : {}),
},
} as const;
if (opts.json) {
const out = { items: [] as BulkItemResult[], summary, version: '1.0' };
console.log(JSON.stringify(out, null, 2));
} else {
console.log('No items found to validate.');
}
process.exitCode = 0;
return;
}
const results: BulkItemResult[] = [];
let index = 0;
let running = 0;
@@ -301,5 +324,3 @@ function getPlannedType(index: number, changeIds: string[], specIds: string[]):
if (specIndex >= 0 && specIndex < specIds.length) return 'spec';
return undefined;
}
+27 -8
View File
@@ -1,7 +1,5 @@
import { promises as fs } from 'fs';
import path from 'path';
import { select, confirm } from '@inquirer/prompts';
import { FileSystemUtils } from '../utils/file-system.js';
import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
import { Validator } from './validation/validator.js';
import chalk from 'chalk';
@@ -125,6 +123,7 @@ export class ArchiveCommand {
const timestamp = new Date().toISOString();
if (!options.yes) {
const { confirm } = await import('@inquirer/prompts');
const proceed = await confirm({
message: chalk.yellow('⚠️ WARNING: Skipping validation may archive invalid specs. Continue? (y/N)'),
default: false
@@ -149,6 +148,7 @@ export class ArchiveCommand {
const incompleteTasks = Math.max(progress.total - progress.completed, 0);
if (incompleteTasks > 0) {
if (!options.yes) {
const { confirm } = await import('@inquirer/prompts');
const proceed = await confirm({
message: `Warning: ${incompleteTasks} incomplete task(s) found. Continue?`,
default: false
@@ -179,6 +179,7 @@ export class ArchiveCommand {
let shouldUpdateSpecs = true;
if (!options.yes) {
const { confirm } = await import('@inquirer/prompts');
shouldUpdateSpecs = await confirm({
message: 'Proceed with spec updates?',
default: true
@@ -256,6 +257,7 @@ export class ArchiveCommand {
}
private async selectChange(changesDir: string): Promise<string | null> {
const { select } = await import('@inquirer/prompts');
// Get all directories in changes (excluding archive)
const entries = await fs.readdir(changesDir, { withFileTypes: true });
const changeDirs = entries
@@ -444,15 +446,26 @@ export class ArchiveCommand {
// Load or create base target content
let targetContent: string;
let isNewSpec = false;
try {
targetContent = await fs.readFile(update.target, 'utf-8');
} catch {
// Target spec does not exist; only ADDED operations are permitted
if (plan.modified.length > 0 || plan.removed.length > 0 || plan.renamed.length > 0) {
// Target spec does not exist; MODIFIED and RENAMED are not allowed for new specs
// REMOVED will be ignored with a warning since there's nothing to remove
if (plan.modified.length > 0 || plan.renamed.length > 0) {
throw new Error(
`${specName}: target spec does not exist; only ADDED requirements are allowed for new specs.`
`${specName}: target spec does not exist; only ADDED requirements are allowed for new specs. MODIFIED and RENAMED operations require an existing spec.`
);
}
// Warn about REMOVED requirements being ignored for new specs
if (plan.removed.length > 0) {
console.log(
chalk.yellow(
`⚠️ Warning: ${specName} - ${plan.removed.length} REMOVED requirement(s) ignored for new spec (nothing to remove).`
)
);
}
isNewSpec = true;
targetContent = this.buildSpecSkeleton(specName, changeName);
}
@@ -495,9 +508,15 @@ export class ArchiveCommand {
for (const name of plan.removed) {
const key = normalizeRequirementName(name);
if (!nameToBlock.has(key)) {
throw new Error(
`${specName} REMOVED failed for header "### Requirement: ${name}" - not found`
);
// For new specs, REMOVED requirements are already warned about and ignored
// For existing specs, missing requirements are an error
if (!isNewSpec) {
throw new Error(
`${specName} REMOVED failed for header "### Requirement: ${name}" - not found`
);
}
// Skip removal for new specs (already warned above)
continue;
}
nameToBlock.delete(key);
}
@@ -0,0 +1,84 @@
import type { SchemaYaml } from './types.js';
/**
* Built-in schema definitions.
* These are compiled into the package, avoiding runtime file resolution issues.
*/
export const SPEC_DRIVEN_SCHEMA: SchemaYaml = {
name: 'spec-driven',
version: 1,
description: 'Default OpenSpec workflow - proposal → specs → design → tasks',
artifacts: [
{
id: 'proposal',
generates: 'proposal.md',
description: 'Initial proposal document outlining the change',
template: 'templates/proposal.md',
requires: [],
},
{
id: 'specs',
generates: 'specs/*.md',
description: 'Detailed specifications for the change',
template: 'templates/spec.md',
requires: ['proposal'],
},
{
id: 'design',
generates: 'design.md',
description: 'Technical design document with implementation details',
template: 'templates/design.md',
requires: ['proposal'],
},
{
id: 'tasks',
generates: 'tasks.md',
description: 'Implementation tasks derived from specs and design',
template: 'templates/tasks.md',
requires: ['specs', 'design'],
},
],
};
export const TDD_SCHEMA: SchemaYaml = {
name: 'tdd',
version: 1,
description: 'Test-driven development workflow - tests → implementation → docs',
artifacts: [
{
id: 'spec',
generates: 'spec.md',
description: 'Feature specification defining requirements',
template: 'templates/spec.md',
requires: [],
},
{
id: 'tests',
generates: 'tests/*.test.ts',
description: 'Test files written before implementation',
template: 'templates/test.md',
requires: ['spec'],
},
{
id: 'implementation',
generates: 'src/*.ts',
description: 'Implementation code to pass the tests',
template: 'templates/implementation.md',
requires: ['tests'],
},
{
id: 'docs',
generates: 'docs/*.md',
description: 'Documentation for the implemented feature',
template: 'templates/docs.md',
requires: ['implementation'],
},
],
};
/** Map of built-in schema names to their definitions */
export const BUILTIN_SCHEMAS: Record<string, SchemaYaml> = {
'spec-driven': SPEC_DRIVEN_SCHEMA,
'tdd': TDD_SCHEMA,
};
+167
View File
@@ -0,0 +1,167 @@
import type { Artifact, SchemaYaml, CompletedSet, BlockedArtifacts } from './types.js';
import { loadSchema, parseSchema } from './schema.js';
/**
* Represents an artifact dependency graph.
* Provides methods for querying build order, ready artifacts, and completion status.
*/
export class ArtifactGraph {
private artifacts: Map<string, Artifact>;
private schema: SchemaYaml;
private constructor(schema: SchemaYaml) {
this.schema = schema;
this.artifacts = new Map(schema.artifacts.map(a => [a.id, a]));
}
/**
* Creates an ArtifactGraph from a YAML file path.
*/
static fromYaml(filePath: string): ArtifactGraph {
const schema = loadSchema(filePath);
return new ArtifactGraph(schema);
}
/**
* Creates an ArtifactGraph from YAML content string.
*/
static fromYamlContent(yamlContent: string): ArtifactGraph {
const schema = parseSchema(yamlContent);
return new ArtifactGraph(schema);
}
/**
* Creates an ArtifactGraph from a pre-validated schema object.
*/
static fromSchema(schema: SchemaYaml): ArtifactGraph {
return new ArtifactGraph(schema);
}
/**
* Gets a single artifact by ID.
*/
getArtifact(id: string): Artifact | undefined {
return this.artifacts.get(id);
}
/**
* Gets all artifacts in the graph.
*/
getAllArtifacts(): Artifact[] {
return Array.from(this.artifacts.values());
}
/**
* Gets the schema name.
*/
getName(): string {
return this.schema.name;
}
/**
* Gets the schema version.
*/
getVersion(): number {
return this.schema.version;
}
/**
* Computes the topological build order using Kahn's algorithm.
* Returns artifact IDs in the order they should be built.
*/
getBuildOrder(): string[] {
const inDegree = new Map<string, number>();
const dependents = new Map<string, string[]>();
// Initialize all artifacts
for (const artifact of this.artifacts.values()) {
inDegree.set(artifact.id, artifact.requires.length);
dependents.set(artifact.id, []);
}
// Build reverse adjacency (who depends on whom)
for (const artifact of this.artifacts.values()) {
for (const req of artifact.requires) {
dependents.get(req)!.push(artifact.id);
}
}
// Start with roots (in-degree 0), sorted for determinism
const queue = [...this.artifacts.keys()]
.filter(id => inDegree.get(id) === 0)
.sort();
const result: string[] = [];
while (queue.length > 0) {
const current = queue.shift()!;
result.push(current);
// Collect newly ready artifacts, then sort before adding
const newlyReady: string[] = [];
for (const dep of dependents.get(current)!) {
const newDegree = inDegree.get(dep)! - 1;
inDegree.set(dep, newDegree);
if (newDegree === 0) {
newlyReady.push(dep);
}
}
queue.push(...newlyReady.sort());
}
return result;
}
/**
* Gets artifacts that are ready to be created (all dependencies completed).
*/
getNextArtifacts(completed: CompletedSet): string[] {
const ready: string[] = [];
for (const artifact of this.artifacts.values()) {
if (completed.has(artifact.id)) {
continue; // Already completed
}
const allDepsCompleted = artifact.requires.every(req => completed.has(req));
if (allDepsCompleted) {
ready.push(artifact.id);
}
}
// Sort for deterministic ordering
return ready.sort();
}
/**
* Checks if all artifacts in the graph are completed.
*/
isComplete(completed: CompletedSet): boolean {
for (const artifact of this.artifacts.values()) {
if (!completed.has(artifact.id)) {
return false;
}
}
return true;
}
/**
* Gets blocked artifacts and their unmet dependencies.
*/
getBlocked(completed: CompletedSet): BlockedArtifacts {
const blocked: BlockedArtifacts = {};
for (const artifact of this.artifacts.values()) {
if (completed.has(artifact.id)) {
continue; // Already completed
}
const unmetDeps = artifact.requires.filter(req => !completed.has(req));
if (unmetDeps.length > 0) {
blocked[artifact.id] = unmetDeps.sort();
}
}
return blocked;
}
}
+24
View File
@@ -0,0 +1,24 @@
// Types
export {
ArtifactSchema,
SchemaYamlSchema,
type Artifact,
type SchemaYaml,
type CompletedSet,
type BlockedArtifacts,
} from './types.js';
// Schema loading and validation
export { loadSchema, parseSchema, SchemaValidationError } from './schema.js';
// Graph operations
export { ArtifactGraph } from './graph.js';
// State detection
export { detectCompleted } from './state.js';
// Schema resolution
export { resolveSchema, listSchemas } from './resolver.js';
// Built-in schemas
export { BUILTIN_SCHEMAS, SPEC_DRIVEN_SCHEMA, TDD_SCHEMA } from './builtin-schemas.js';
+121
View File
@@ -0,0 +1,121 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { getGlobalDataDir } from '../global-config.js';
import { BUILTIN_SCHEMAS } from './builtin-schemas.js';
import { parseSchema, SchemaValidationError } from './schema.js';
import type { SchemaYaml } from './types.js';
/**
* Error thrown when loading a global schema override fails.
*/
export class SchemaLoadError extends Error {
constructor(
message: string,
public readonly schemaPath: string,
public readonly cause?: Error
) {
super(message);
this.name = 'SchemaLoadError';
}
}
/**
* Resolves a schema name to a SchemaYaml object.
*
* Resolution order:
* 1. Global user override: ${XDG_DATA_HOME}/openspec/schemas/<name>.yaml
* 2. Built-in schema
*
* @param name - Schema name (e.g., "spec-driven")
* @returns The resolved schema object
* @throws Error if schema is not found in any location
*/
export function resolveSchema(name: string): SchemaYaml {
// Normalize name (remove .yaml extension if provided)
const normalizedName = name.replace(/\.ya?ml$/, '');
const builtinNames = Object.keys(BUILTIN_SCHEMAS).join(', ');
// 1. Check global user override (returns path if found)
const globalPath = getGlobalSchemaPath(normalizedName);
if (globalPath) {
// User override found - load and validate through the same pipeline as other schemas
let content: string;
try {
content = fs.readFileSync(globalPath, 'utf-8');
} catch (err) {
const ioError = err instanceof Error ? err : new Error(String(err));
throw new SchemaLoadError(
`Failed to read global schema override at '${globalPath}': ${ioError.message}`,
globalPath,
ioError
);
}
try {
return parseSchema(content);
} catch (err) {
if (err instanceof SchemaValidationError) {
// Re-wrap validation errors to include the file path for context
throw new SchemaLoadError(
`Invalid global schema override at '${globalPath}': ${err.message}`,
globalPath,
err
);
}
// Handle unexpected parse errors (e.g., YAML syntax errors)
const parseError = err instanceof Error ? err : new Error(String(err));
throw new SchemaLoadError(
`Failed to parse global schema override at '${globalPath}': ${parseError.message}`,
globalPath,
parseError
);
}
}
// 2. Check built-in schemas
const builtin = BUILTIN_SCHEMAS[normalizedName];
if (builtin) {
return builtin;
}
throw new Error(
`Schema '${normalizedName}' not found. Checked global overrides and built-in schemas. Available built-ins: ${builtinNames}`
);
}
/**
* Gets the path to a global user override schema, if it exists.
*/
function getGlobalSchemaPath(name: string): string | null {
const globalDir = path.join(getGlobalDataDir(), 'schemas');
// Check both .yaml and .yml extensions
for (const ext of ['.yaml', '.yml']) {
const schemaPath = path.join(globalDir, `${name}${ext}`);
if (fs.existsSync(schemaPath)) {
return schemaPath;
}
}
return null;
}
/**
* Lists all available schema names.
* Combines built-in and user override schemas.
*/
export function listSchemas(): string[] {
const schemas = new Set<string>(Object.keys(BUILTIN_SCHEMAS));
// Add user override schemas
const globalDir = path.join(getGlobalDataDir(), 'schemas');
if (fs.existsSync(globalDir)) {
for (const file of fs.readdirSync(globalDir)) {
if (file.endsWith('.yaml') || file.endsWith('.yml')) {
schemas.add(file.replace(/\.ya?ml$/, ''));
}
}
}
return Array.from(schemas).sort();
}
+124
View File
@@ -0,0 +1,124 @@
import * as fs from 'node:fs';
import { parse as parseYaml } from 'yaml';
import { SchemaYamlSchema, type SchemaYaml, type Artifact } from './types.js';
export class SchemaValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'SchemaValidationError';
}
}
/**
* Loads and validates an artifact schema from a YAML file.
*/
export function loadSchema(filePath: string): SchemaYaml {
const content = fs.readFileSync(filePath, 'utf-8');
return parseSchema(content);
}
/**
* Parses and validates an artifact schema from YAML content.
*/
export function parseSchema(yamlContent: string): SchemaYaml {
const parsed = parseYaml(yamlContent);
// Validate with Zod
const result = SchemaYamlSchema.safeParse(parsed);
if (!result.success) {
const errors = result.error.issues.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
throw new SchemaValidationError(`Invalid schema: ${errors}`);
}
const schema = result.data;
// Check for duplicate artifact IDs
validateNoDuplicateIds(schema.artifacts);
// Check that all requires references are valid
validateRequiresReferences(schema.artifacts);
// Check for cycles
validateNoCycles(schema.artifacts);
return schema;
}
/**
* Validates that there are no duplicate artifact IDs.
*/
function validateNoDuplicateIds(artifacts: Artifact[]): void {
const seen = new Set<string>();
for (const artifact of artifacts) {
if (seen.has(artifact.id)) {
throw new SchemaValidationError(`Duplicate artifact ID: ${artifact.id}`);
}
seen.add(artifact.id);
}
}
/**
* Validates that all `requires` references point to valid artifact IDs.
*/
function validateRequiresReferences(artifacts: Artifact[]): void {
const validIds = new Set(artifacts.map(a => a.id));
for (const artifact of artifacts) {
for (const req of artifact.requires) {
if (!validIds.has(req)) {
throw new SchemaValidationError(
`Invalid dependency reference in artifact '${artifact.id}': '${req}' does not exist`
);
}
}
}
}
/**
* Validates that there are no cyclic dependencies.
* Uses DFS to detect cycles and reports the full cycle path.
*/
function validateNoCycles(artifacts: Artifact[]): void {
const artifactMap = new Map(artifacts.map(a => [a.id, a]));
const visited = new Set<string>();
const inStack = new Set<string>();
const parent = new Map<string, string>();
function dfs(id: string): string | null {
visited.add(id);
inStack.add(id);
const artifact = artifactMap.get(id);
if (!artifact) return null;
for (const dep of artifact.requires) {
if (!visited.has(dep)) {
parent.set(dep, id);
const cycle = dfs(dep);
if (cycle) return cycle;
} else if (inStack.has(dep)) {
// Found a cycle - reconstruct the path
const cyclePath = [dep];
let current = id;
while (current !== dep) {
cyclePath.unshift(current);
current = parent.get(current)!;
}
cyclePath.unshift(dep);
return cyclePath.join(' → ');
}
}
inStack.delete(id);
return null;
}
for (const artifact of artifacts) {
if (!visited.has(artifact.id)) {
const cycle = dfs(artifact.id);
if (cycle) {
throw new SchemaValidationError(`Cyclic dependency detected: ${cycle}`);
}
}
}
}
+64
View File
@@ -0,0 +1,64 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import fg from 'fast-glob';
import type { CompletedSet } from './types.js';
import type { ArtifactGraph } from './graph.js';
import { FileSystemUtils } from '../../utils/file-system.js';
/**
* Detects which artifacts are completed by checking file existence in the change directory.
* Returns a Set of completed artifact IDs.
*
* @param graph - The artifact graph to check
* @param changeDir - The change directory to scan for files
* @returns Set of artifact IDs whose generated files exist
*/
export function detectCompleted(graph: ArtifactGraph, changeDir: string): CompletedSet {
const completed = new Set<string>();
// Handle missing change directory gracefully
if (!fs.existsSync(changeDir)) {
return completed;
}
for (const artifact of graph.getAllArtifacts()) {
if (isArtifactComplete(artifact.generates, changeDir)) {
completed.add(artifact.id);
}
}
return completed;
}
/**
* Checks if an artifact is complete by checking if its generated file(s) exist.
* Supports both simple paths and glob patterns.
*/
function isArtifactComplete(generates: string, changeDir: string): boolean {
const fullPattern = path.join(changeDir, generates);
// Check if it's a glob pattern
if (isGlobPattern(generates)) {
return hasGlobMatches(fullPattern);
}
// Simple file path - check if file exists
return fs.existsSync(fullPattern);
}
/**
* Checks if a path contains glob pattern characters.
*/
function isGlobPattern(pattern: string): boolean {
return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');
}
/**
* Checks if a glob pattern has any matches.
* Normalizes Windows backslashes to forward slashes for cross-platform glob compatibility.
*/
function hasGlobMatches(pattern: string): boolean {
const normalizedPattern = FileSystemUtils.toPosixPath(pattern);
const matches = fg.sync(normalizedPattern, { onlyFiles: true });
return matches.length > 0;
}
+33
View File
@@ -0,0 +1,33 @@
import { z } from 'zod';
// Artifact definition schema
export const ArtifactSchema = z.object({
id: z.string().min(1, { error: 'Artifact ID is required' }),
generates: z.string().min(1, { error: 'generates field is required' }),
description: z.string(),
template: z.string().min(1, { error: 'template field is required' }),
requires: z.array(z.string()).default([]),
});
// Full schema YAML structure
export const SchemaYamlSchema = z.object({
name: z.string().min(1, { error: 'Schema name is required' }),
version: z.number().int().positive({ error: 'Version must be a positive integer' }),
description: z.string().optional(),
artifacts: z.array(ArtifactSchema).min(1, { error: 'At least one artifact required' }),
});
// Derived TypeScript types
export type Artifact = z.infer<typeof ArtifactSchema>;
export type SchemaYaml = z.infer<typeof SchemaYamlSchema>;
// Runtime state types (not Zod - internal only)
// Slice 1: Simple completion tracking via filesystem
export type CompletedSet = Set<string>;
// Return type for blocked query
export interface BlockedArtifacts {
[artifactId: string]: string[];
}
+364
View File
@@ -0,0 +1,364 @@
import { CommandDefinition, FlagDefinition } from './types.js';
/**
* Common flags used across multiple commands
*/
const COMMON_FLAGS = {
json: {
name: 'json',
description: 'Output as JSON',
} as FlagDefinition,
jsonValidation: {
name: 'json',
description: 'Output validation results as JSON',
} as FlagDefinition,
strict: {
name: 'strict',
description: 'Enable strict validation mode',
} as FlagDefinition,
noInteractive: {
name: 'no-interactive',
description: 'Disable interactive prompts',
} as FlagDefinition,
type: {
name: 'type',
description: 'Specify item type when ambiguous',
takesValue: true,
values: ['change', 'spec'],
} as FlagDefinition,
} as const;
/**
* Registry of all OpenSpec CLI commands with their flags and metadata.
* This registry is used to generate shell completion scripts.
*/
export const COMMAND_REGISTRY: CommandDefinition[] = [
{
name: 'init',
description: 'Initialize OpenSpec in your project',
acceptsPositional: true,
positionalType: 'path',
flags: [
{
name: 'tools',
description: 'Configure AI tools non-interactively (e.g., "all", "none", or comma-separated tool IDs)',
takesValue: true,
},
],
},
{
name: 'update',
description: 'Update OpenSpec instruction files',
acceptsPositional: true,
positionalType: 'path',
flags: [],
},
{
name: 'list',
description: 'List items (changes by default, or specs with --specs)',
flags: [
{
name: 'specs',
description: 'List specs instead of changes',
},
{
name: 'changes',
description: 'List changes explicitly (default)',
},
],
},
{
name: 'view',
description: 'Display an interactive dashboard of specs and changes',
flags: [],
},
{
name: 'validate',
description: 'Validate changes and specs',
acceptsPositional: true,
positionalType: 'change-or-spec-id',
flags: [
{
name: 'all',
description: 'Validate all changes and specs',
},
{
name: 'changes',
description: 'Validate all changes',
},
{
name: 'specs',
description: 'Validate all specs',
},
COMMON_FLAGS.type,
COMMON_FLAGS.strict,
COMMON_FLAGS.jsonValidation,
{
name: 'concurrency',
description: 'Max concurrent validations (defaults to env OPENSPEC_CONCURRENCY or 6)',
takesValue: true,
},
COMMON_FLAGS.noInteractive,
],
},
{
name: 'show',
description: 'Show a change or spec',
acceptsPositional: true,
positionalType: 'change-or-spec-id',
flags: [
COMMON_FLAGS.json,
COMMON_FLAGS.type,
COMMON_FLAGS.noInteractive,
{
name: 'deltas-only',
description: 'Show only deltas (JSON only, change-specific)',
},
{
name: 'requirements-only',
description: 'Alias for --deltas-only (deprecated, change-specific)',
},
{
name: 'requirements',
description: 'Show only requirements, exclude scenarios (JSON only, spec-specific)',
},
{
name: 'no-scenarios',
description: 'Exclude scenario content (JSON only, spec-specific)',
},
{
name: 'requirement',
short: 'r',
description: 'Show specific requirement by ID (JSON only, spec-specific)',
takesValue: true,
},
],
},
{
name: 'archive',
description: 'Archive a completed change and update main specs',
acceptsPositional: true,
positionalType: 'change-id',
flags: [
{
name: 'yes',
short: 'y',
description: 'Skip confirmation prompts',
},
{
name: 'skip-specs',
description: 'Skip spec update operations',
},
{
name: 'no-validate',
description: 'Skip validation (not recommended)',
},
],
},
{
name: 'change',
description: 'Manage OpenSpec change proposals (deprecated)',
flags: [],
subcommands: [
{
name: 'show',
description: 'Show a change proposal',
acceptsPositional: true,
positionalType: 'change-id',
flags: [
COMMON_FLAGS.json,
{
name: 'deltas-only',
description: 'Show only deltas (JSON only)',
},
{
name: 'requirements-only',
description: 'Alias for --deltas-only (deprecated)',
},
COMMON_FLAGS.noInteractive,
],
},
{
name: 'list',
description: 'List all active changes (deprecated)',
flags: [
COMMON_FLAGS.json,
{
name: 'long',
description: 'Show id and title with counts',
},
],
},
{
name: 'validate',
description: 'Validate a change proposal',
acceptsPositional: true,
positionalType: 'change-id',
flags: [
COMMON_FLAGS.strict,
COMMON_FLAGS.jsonValidation,
COMMON_FLAGS.noInteractive,
],
},
],
},
{
name: 'spec',
description: 'Manage OpenSpec specifications',
flags: [],
subcommands: [
{
name: 'show',
description: 'Show a specification',
acceptsPositional: true,
positionalType: 'spec-id',
flags: [
COMMON_FLAGS.json,
{
name: 'requirements',
description: 'Show only requirements, exclude scenarios (JSON only)',
},
{
name: 'no-scenarios',
description: 'Exclude scenario content (JSON only)',
},
{
name: 'requirement',
short: 'r',
description: 'Show specific requirement by ID (JSON only)',
takesValue: true,
},
COMMON_FLAGS.noInteractive,
],
},
{
name: 'list',
description: 'List all specifications',
flags: [
COMMON_FLAGS.json,
{
name: 'long',
description: 'Show id and title with counts',
},
],
},
{
name: 'validate',
description: 'Validate a specification',
acceptsPositional: true,
positionalType: 'spec-id',
flags: [
COMMON_FLAGS.strict,
COMMON_FLAGS.jsonValidation,
COMMON_FLAGS.noInteractive,
],
},
],
},
{
name: 'completion',
description: 'Manage shell completions for OpenSpec CLI',
flags: [],
subcommands: [
{
name: 'generate',
description: 'Generate completion script for a shell (outputs to stdout)',
acceptsPositional: true,
positionalType: 'shell',
flags: [],
},
{
name: 'install',
description: 'Install completion script for a shell',
acceptsPositional: true,
positionalType: 'shell',
flags: [
{
name: 'verbose',
description: 'Show detailed installation output',
},
],
},
{
name: 'uninstall',
description: 'Uninstall completion script for a shell',
acceptsPositional: true,
positionalType: 'shell',
flags: [],
},
],
},
{
name: 'config',
description: 'View and modify global OpenSpec configuration',
flags: [
{
name: 'scope',
description: 'Config scope (only "global" supported currently)',
takesValue: true,
values: ['global'],
},
],
subcommands: [
{
name: 'path',
description: 'Show config file location',
flags: [],
},
{
name: 'list',
description: 'Show all current settings',
flags: [
COMMON_FLAGS.json,
],
},
{
name: 'get',
description: 'Get a specific value (raw, scriptable)',
acceptsPositional: true,
flags: [],
},
{
name: 'set',
description: 'Set a value (auto-coerce types)',
acceptsPositional: true,
flags: [
{
name: 'string',
description: 'Force value to be stored as string',
},
{
name: 'allow-unknown',
description: 'Allow setting unknown keys',
},
],
},
{
name: 'unset',
description: 'Remove a key (revert to default)',
acceptsPositional: true,
flags: [],
},
{
name: 'reset',
description: 'Reset configuration to defaults',
flags: [
{
name: 'all',
description: 'Reset all configuration (required)',
},
{
name: 'yes',
short: 'y',
description: 'Skip confirmation prompts',
},
],
},
{
name: 'edit',
description: 'Open config in $EDITOR',
flags: [],
},
],
},
];
+128
View File
@@ -0,0 +1,128 @@
import { getActiveChangeIds, getSpecIds } from '../../utils/item-discovery.js';
/**
* Cache entry for completion data
*/
interface CacheEntry<T> {
data: T;
timestamp: number;
}
/**
* Provides dynamic completion suggestions for OpenSpec items (changes and specs).
* Implements a 2-second cache to avoid excessive file system operations during
* tab completion.
*/
export class CompletionProvider {
private readonly cacheTTL: number;
private changeCache: CacheEntry<string[]> | null = null;
private specCache: CacheEntry<string[]> | null = null;
/**
* Creates a new completion provider
*
* @param cacheTTLMs - Cache time-to-live in milliseconds (default: 2000ms)
* @param projectRoot - Project root directory (default: process.cwd())
*/
constructor(
private readonly cacheTTLMs: number = 2000,
private readonly projectRoot: string = process.cwd()
) {
this.cacheTTL = cacheTTLMs;
}
/**
* Get all active change IDs for completion
*
* @returns Array of change IDs
*/
async getChangeIds(): Promise<string[]> {
const now = Date.now();
// Check if cache is valid
if (this.changeCache && now - this.changeCache.timestamp < this.cacheTTL) {
return this.changeCache.data;
}
// Fetch fresh data
const changeIds = await getActiveChangeIds(this.projectRoot);
// Update cache
this.changeCache = {
data: changeIds,
timestamp: now,
};
return changeIds;
}
/**
* Get all spec IDs for completion
*
* @returns Array of spec IDs
*/
async getSpecIds(): Promise<string[]> {
const now = Date.now();
// Check if cache is valid
if (this.specCache && now - this.specCache.timestamp < this.cacheTTL) {
return this.specCache.data;
}
// Fetch fresh data
const specIds = await getSpecIds(this.projectRoot);
// Update cache
this.specCache = {
data: specIds,
timestamp: now,
};
return specIds;
}
/**
* Get both change and spec IDs for completion
*
* @returns Object with changeIds and specIds arrays
*/
async getAllIds(): Promise<{ changeIds: string[]; specIds: string[] }> {
const [changeIds, specIds] = await Promise.all([
this.getChangeIds(),
this.getSpecIds(),
]);
return { changeIds, specIds };
}
/**
* Clear all cached data
*/
clearCache(): void {
this.changeCache = null;
this.specCache = null;
}
/**
* Get cache statistics for debugging
*
* @returns Cache status information
*/
getCacheStats(): {
changeCache: { valid: boolean; age?: number };
specCache: { valid: boolean; age?: number };
} {
const now = Date.now();
return {
changeCache: {
valid: this.changeCache !== null && now - this.changeCache.timestamp < this.cacheTTL,
age: this.changeCache ? now - this.changeCache.timestamp : undefined,
},
specCache: {
valid: this.specCache !== null && now - this.specCache.timestamp < this.cacheTTL,
age: this.specCache ? now - this.specCache.timestamp : undefined,
},
};
}
}
+74
View File
@@ -0,0 +1,74 @@
import { CompletionGenerator } from './types.js';
import { ZshGenerator } from './generators/zsh-generator.js';
import { ZshInstaller, InstallationResult } from './installers/zsh-installer.js';
import { SupportedShell } from '../../utils/shell-detection.js';
/**
* Interface for completion installers
*/
export interface CompletionInstaller {
install(script: string): Promise<InstallationResult>;
uninstall(): Promise<{ success: boolean; message: string }>;
}
// Re-export InstallationResult for convenience
export type { InstallationResult };
/**
* Factory for creating completion generators and installers
* This design makes it easy to add support for additional shells
*/
export class CompletionFactory {
private static readonly SUPPORTED_SHELLS: SupportedShell[] = ['zsh'];
/**
* Create a completion generator for the specified shell
*
* @param shell - The target shell
* @returns CompletionGenerator instance
* @throws Error if shell is not supported
*/
static createGenerator(shell: SupportedShell): CompletionGenerator {
switch (shell) {
case 'zsh':
return new ZshGenerator();
default:
throw new Error(`Unsupported shell: ${shell}`);
}
}
/**
* Create a completion installer for the specified shell
*
* @param shell - The target shell
* @returns CompletionInstaller instance
* @throws Error if shell is not supported
*/
static createInstaller(shell: SupportedShell): CompletionInstaller {
switch (shell) {
case 'zsh':
return new ZshInstaller();
default:
throw new Error(`Unsupported shell: ${shell}`);
}
}
/**
* Check if a shell is supported
*
* @param shell - The shell to check
* @returns true if the shell is supported
*/
static isSupported(shell: string): shell is SupportedShell {
return this.SUPPORTED_SHELLS.includes(shell as SupportedShell);
}
/**
* Get list of all supported shells
*
* @returns Array of supported shell names
*/
static getSupportedShells(): SupportedShell[] {
return [...this.SUPPORTED_SHELLS];
}
}
@@ -0,0 +1,374 @@
import { CompletionGenerator, CommandDefinition, FlagDefinition } from '../types.js';
/**
* Generates Zsh completion scripts for the OpenSpec CLI.
* Follows Zsh completion system conventions using the _openspec function.
*/
export class ZshGenerator implements CompletionGenerator {
readonly shell = 'zsh' as const;
/**
* Generate a Zsh completion script
*
* @param commands - Command definitions to generate completions for
* @returns Zsh completion script as a string
*/
generate(commands: CommandDefinition[]): string {
const script: string[] = [];
// Header comment
script.push('#compdef openspec');
script.push('');
script.push('# Zsh completion script for OpenSpec CLI');
script.push('# Auto-generated - do not edit manually');
script.push('');
// Main completion function
script.push('_openspec() {');
script.push(' local context state line');
script.push(' typeset -A opt_args');
script.push('');
// Generate main command argument specification
script.push(' local -a commands');
script.push(' commands=(');
for (const cmd of commands) {
const escapedDesc = this.escapeDescription(cmd.description);
script.push(` '${cmd.name}:${escapedDesc}'`);
}
script.push(' )');
script.push('');
// Main _arguments call
script.push(' _arguments -C \\');
script.push(' "1: :->command" \\');
script.push(' "*::arg:->args"');
script.push('');
// Command dispatch logic
script.push(' case $state in');
script.push(' command)');
script.push(' _describe "openspec command" commands');
script.push(' ;;');
script.push(' args)');
script.push(' case $words[1] in');
// Generate completion for each command
for (const cmd of commands) {
script.push(` ${cmd.name})`);
script.push(` _openspec_${this.sanitizeFunctionName(cmd.name)}`);
script.push(' ;;');
}
script.push(' esac');
script.push(' ;;');
script.push(' esac');
script.push('}');
script.push('');
// Generate individual command completion functions
for (const cmd of commands) {
script.push(...this.generateCommandFunction(cmd));
script.push('');
}
// Add dynamic completion helper functions
script.push(...this.generateDynamicCompletionHelpers());
// Register the completion function
script.push('compdef _openspec openspec');
script.push('');
return script.join('\n');
}
/**
* Generate a single completion function
*
* @param functionName - Name of the completion function
* @param varName - Name of the local array variable
* @param varLabel - Label for the completion items
* @param commandLines - Command line(s) to populate the array
* @param comment - Optional comment describing the function
*/
private generateCompletionFunction(
functionName: string,
varName: string,
varLabel: string,
commandLines: string[],
comment?: string
): string[] {
const lines: string[] = [];
if (comment) {
lines.push(comment);
}
lines.push(`${functionName}() {`);
lines.push(` local -a ${varName}`);
if (commandLines.length === 1) {
lines.push(` ${commandLines[0]}`);
} else {
lines.push(` ${varName}=(`);
for (let i = 0; i < commandLines.length; i++) {
const suffix = i < commandLines.length - 1 ? ' \\' : '';
lines.push(` ${commandLines[i]}${suffix}`);
}
lines.push(' )');
}
lines.push(` _describe "${varLabel}" ${varName}`);
lines.push('}');
lines.push('');
return lines;
}
/**
* Generate dynamic completion helper functions for change and spec IDs
*/
private generateDynamicCompletionHelpers(): string[] {
const lines: string[] = [];
lines.push('# Dynamic completion helpers');
lines.push('');
// Helper function for completing change IDs
lines.push('# Use openspec __complete to get available changes');
lines.push('_openspec_complete_changes() {');
lines.push(' local -a changes');
lines.push(' while IFS=$\'\\t\' read -r id desc; do');
lines.push(' changes+=("$id:$desc")');
lines.push(' done < <(openspec __complete changes 2>/dev/null)');
lines.push(' _describe "change" changes');
lines.push('}');
lines.push('');
// Helper function for completing spec IDs
lines.push('# Use openspec __complete to get available specs');
lines.push('_openspec_complete_specs() {');
lines.push(' local -a specs');
lines.push(' while IFS=$\'\\t\' read -r id desc; do');
lines.push(' specs+=("$id:$desc")');
lines.push(' done < <(openspec __complete specs 2>/dev/null)');
lines.push(' _describe "spec" specs');
lines.push('}');
lines.push('');
// Helper function for completing both changes and specs
lines.push('# Get both changes and specs');
lines.push('_openspec_complete_items() {');
lines.push(' local -a items');
lines.push(' while IFS=$\'\\t\' read -r id desc; do');
lines.push(' items+=("$id:$desc")');
lines.push(' done < <(openspec __complete changes 2>/dev/null)');
lines.push(' while IFS=$\'\\t\' read -r id desc; do');
lines.push(' items+=("$id:$desc")');
lines.push(' done < <(openspec __complete specs 2>/dev/null)');
lines.push(' _describe "item" items');
lines.push('}');
lines.push('');
return lines;
}
/**
* Generate completion function for a specific command
*/
private generateCommandFunction(cmd: CommandDefinition): string[] {
const funcName = `_openspec_${this.sanitizeFunctionName(cmd.name)}`;
const lines: string[] = [];
lines.push(`${funcName}() {`);
// If command has subcommands, handle them
if (cmd.subcommands && cmd.subcommands.length > 0) {
lines.push(' local context state line');
lines.push(' typeset -A opt_args');
lines.push('');
lines.push(' local -a subcommands');
lines.push(' subcommands=(');
for (const subcmd of cmd.subcommands) {
const escapedDesc = this.escapeDescription(subcmd.description);
lines.push(` '${subcmd.name}:${escapedDesc}'`);
}
lines.push(' )');
lines.push('');
lines.push(' _arguments -C \\');
// Add command flags
for (const flag of cmd.flags) {
lines.push(' ' + this.generateFlagSpec(flag) + ' \\');
}
lines.push(' "1: :->subcommand" \\');
lines.push(' "*::arg:->args"');
lines.push('');
lines.push(' case $state in');
lines.push(' subcommand)');
lines.push(' _describe "subcommand" subcommands');
lines.push(' ;;');
lines.push(' args)');
lines.push(' case $words[1] in');
for (const subcmd of cmd.subcommands) {
lines.push(` ${subcmd.name})`);
lines.push(` _openspec_${this.sanitizeFunctionName(cmd.name)}_${this.sanitizeFunctionName(subcmd.name)}`);
lines.push(' ;;');
}
lines.push(' esac');
lines.push(' ;;');
lines.push(' esac');
} else {
// Command without subcommands
lines.push(' _arguments \\');
// Add flags
for (const flag of cmd.flags) {
lines.push(' ' + this.generateFlagSpec(flag) + ' \\');
}
// Add positional argument completion
if (cmd.acceptsPositional) {
const positionalSpec = this.generatePositionalSpec(cmd.positionalType);
lines.push(' ' + positionalSpec);
} else {
// Remove trailing backslash from last flag
if (lines[lines.length - 1].endsWith(' \\')) {
lines[lines.length - 1] = lines[lines.length - 1].slice(0, -2);
}
}
}
lines.push('}');
// Generate subcommand functions if they exist
if (cmd.subcommands) {
for (const subcmd of cmd.subcommands) {
lines.push('');
lines.push(...this.generateSubcommandFunction(cmd.name, subcmd));
}
}
return lines;
}
/**
* Generate completion function for a subcommand
*/
private generateSubcommandFunction(parentName: string, subcmd: CommandDefinition): string[] {
const funcName = `_openspec_${this.sanitizeFunctionName(parentName)}_${this.sanitizeFunctionName(subcmd.name)}`;
const lines: string[] = [];
lines.push(`${funcName}() {`);
lines.push(' _arguments \\');
// Add flags
for (const flag of subcmd.flags) {
lines.push(' ' + this.generateFlagSpec(flag) + ' \\');
}
// Add positional argument completion
if (subcmd.acceptsPositional) {
const positionalSpec = this.generatePositionalSpec(subcmd.positionalType);
lines.push(' ' + positionalSpec);
} else {
// Remove trailing backslash from last flag
if (lines[lines.length - 1].endsWith(' \\')) {
lines[lines.length - 1] = lines[lines.length - 1].slice(0, -2);
}
}
lines.push('}');
return lines;
}
/**
* Generate flag specification for _arguments
*/
private generateFlagSpec(flag: FlagDefinition): string {
const parts: string[] = [];
// Handle mutually exclusive short and long forms
if (flag.short) {
parts.push(`'(-${flag.short} --${flag.name})'{-${flag.short},--${flag.name}}'`);
} else {
parts.push(`'--${flag.name}`);
}
// Add description
const escapedDesc = this.escapeDescription(flag.description);
parts.push(`[${escapedDesc}]`);
// Add value completion if flag takes a value
if (flag.takesValue) {
if (flag.values && flag.values.length > 0) {
// Provide specific value completions
const valueList = flag.values.map(v => this.escapeValue(v)).join(' ');
parts.push(`:value:(${valueList})`);
} else {
// Generic value placeholder
parts.push(':value:');
}
}
// Close the quote (needed for both short and long forms)
parts.push("'");
return parts.join('');
}
/**
* Generate positional argument specification
*/
private generatePositionalSpec(positionalType?: string): string {
switch (positionalType) {
case 'change-id':
return "'*: :_openspec_complete_changes'";
case 'spec-id':
return "'*: :_openspec_complete_specs'";
case 'change-or-spec-id':
return "'*: :_openspec_complete_items'";
case 'path':
return "'*:path:_files'";
case 'shell':
return "'*:shell:(zsh)'";
default:
return "'*: :_default'";
}
}
/**
* Escape special characters in descriptions
*/
private escapeDescription(desc: string): string {
return desc
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/\[/g, '\\[')
.replace(/]/g, '\\]')
.replace(/:/g, '\\:');
}
/**
* Escape special characters in values
*/
private escapeValue(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/ /g, '\\ ');
}
/**
* Sanitize command names for use in function names
*/
private sanitizeFunctionName(name: string): string {
return name.replace(/-/g, '_');
}
}
@@ -0,0 +1,507 @@
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { FileSystemUtils } from '../../../utils/file-system.js';
/**
* Installation result information
*/
export interface InstallationResult {
success: boolean;
installedPath?: string;
backupPath?: string;
isOhMyZsh: boolean;
zshrcConfigured?: boolean;
message: string;
instructions?: string[];
}
/**
* Installer for Zsh completion scripts.
* Supports both Oh My Zsh and standard Zsh configurations.
*/
export class ZshInstaller {
private readonly homeDir: string;
/**
* Markers for .zshrc configuration management
*/
private readonly ZSHRC_MARKERS = {
start: '# OPENSPEC:START',
end: '# OPENSPEC:END',
};
constructor(homeDir: string = os.homedir()) {
this.homeDir = homeDir;
}
/**
* Check if Oh My Zsh is installed
*
* @returns true if Oh My Zsh is detected via $ZSH env var or directory exists
*/
async isOhMyZshInstalled(): Promise<boolean> {
// First check for $ZSH environment variable (standard OMZ setup)
if (process.env.ZSH) {
return true;
}
// Fall back to checking for ~/.oh-my-zsh directory
const ohMyZshPath = path.join(this.homeDir, '.oh-my-zsh');
try {
const stat = await fs.stat(ohMyZshPath);
return stat.isDirectory();
} catch {
return false;
}
}
/**
* Get the appropriate installation path for the completion script
*
* @returns Object with installation path and whether it's Oh My Zsh
*/
async getInstallationPath(): Promise<{ path: string; isOhMyZsh: boolean }> {
const isOhMyZsh = await this.isOhMyZshInstalled();
if (isOhMyZsh) {
// Oh My Zsh custom completions directory
return {
path: path.join(this.homeDir, '.oh-my-zsh', 'custom', 'completions', '_openspec'),
isOhMyZsh: true,
};
} else {
// Standard Zsh completions directory
return {
path: path.join(this.homeDir, '.zsh', 'completions', '_openspec'),
isOhMyZsh: false,
};
}
}
/**
* Backup an existing completion file if it exists
*
* @param targetPath - Path to the file to backup
* @returns Path to the backup file, or undefined if no backup was needed
*/
async backupExistingFile(targetPath: string): Promise<string | undefined> {
try {
await fs.access(targetPath);
// File exists, create a backup
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `${targetPath}.backup-${timestamp}`;
await fs.copyFile(targetPath, backupPath);
return backupPath;
} catch {
// File doesn't exist, no backup needed
return undefined;
}
}
/**
* Get the path to .zshrc file
*
* @returns Path to .zshrc
*/
private getZshrcPath(): string {
return path.join(this.homeDir, '.zshrc');
}
/**
* Generate .zshrc configuration content
*
* @param completionsDir - Directory containing completion scripts
* @returns Configuration content
*/
private generateZshrcConfig(completionsDir: string): string {
return [
'# OpenSpec shell completions configuration',
`fpath=("${completionsDir}" $fpath)`,
'autoload -Uz compinit',
'compinit',
].join('\n');
}
/**
* Configure .zshrc to enable completions
* Only applies to standard Zsh (not Oh My Zsh)
*
* @param completionsDir - Directory containing completion scripts
* @returns true if configured successfully, false otherwise
*/
async configureZshrc(completionsDir: string): Promise<boolean> {
// Check if auto-configuration is disabled
if (process.env.OPENSPEC_NO_AUTO_CONFIG === '1') {
return false;
}
try {
const zshrcPath = this.getZshrcPath();
const config = this.generateZshrcConfig(completionsDir);
// Check write permissions
const canWrite = await FileSystemUtils.canWriteFile(zshrcPath);
if (!canWrite) {
return false;
}
// Use marker-based update
await FileSystemUtils.updateFileWithMarkers(
zshrcPath,
config,
this.ZSHRC_MARKERS.start,
this.ZSHRC_MARKERS.end
);
return true;
} catch (error: any) {
// Fail gracefully - don't break installation
console.debug(`Unable to configure .zshrc for completions: ${error.message}`);
return false;
}
}
/**
* Check if .zshrc has OpenSpec configuration markers
*
* @returns true if .zshrc exists and has markers
*/
private async hasZshrcConfig(): Promise<boolean> {
try {
const zshrcPath = this.getZshrcPath();
const content = await fs.readFile(zshrcPath, 'utf-8');
return content.includes(this.ZSHRC_MARKERS.start) && content.includes(this.ZSHRC_MARKERS.end);
} catch {
return false;
}
}
/**
* Check if fpath configuration is needed for a given directory
* Used to verify if Oh My Zsh (or other) completions directory is already in fpath
*
* @param completionsDir - Directory to check for in fpath
* @returns true if configuration is needed, false if directory is already referenced
*/
private async needsFpathConfig(completionsDir: string): Promise<boolean> {
try {
const zshrcPath = this.getZshrcPath();
const content = await fs.readFile(zshrcPath, 'utf-8');
// Check if fpath already includes this directory
return !content.includes(completionsDir);
} catch (error) {
// If we can't read .zshrc, assume config is needed
console.debug(`Unable to read .zshrc to check fpath config: ${error instanceof Error ? error.message : String(error)}`);
return true;
}
}
/**
* Remove .zshrc configuration
* Used during uninstallation
*
* @returns true if removed successfully, false otherwise
*/
async removeZshrcConfig(): Promise<boolean> {
try {
const zshrcPath = this.getZshrcPath();
// Check if file exists
try {
await fs.access(zshrcPath);
} catch {
// File doesn't exist, nothing to remove
return true;
}
// Read file content
const content = await fs.readFile(zshrcPath, 'utf-8');
// Check if markers exist
if (!content.includes(this.ZSHRC_MARKERS.start) || !content.includes(this.ZSHRC_MARKERS.end)) {
// Markers don't exist, nothing to remove
return true;
}
// Remove content between markers (including markers)
const lines = content.split('\n');
const startIndex = lines.findIndex((line) => line.trim() === this.ZSHRC_MARKERS.start);
const endIndex = lines.findIndex((line) => line.trim() === this.ZSHRC_MARKERS.end);
if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) {
// Invalid marker placement
return false;
}
// Remove lines between markers (inclusive)
lines.splice(startIndex, endIndex - startIndex + 1);
// Remove trailing empty lines at the start if the markers were at the top
while (lines.length > 0 && lines[0].trim() === '') {
lines.shift();
}
// Write back
await fs.writeFile(zshrcPath, lines.join('\n'), 'utf-8');
return true;
} catch (error: any) {
// Fail gracefully
console.debug(`Unable to remove .zshrc configuration: ${error.message}`);
return false;
}
}
/**
* Install the completion script
*
* @param completionScript - The completion script content to install
* @returns Installation result with status and instructions
*/
async install(completionScript: string): Promise<InstallationResult> {
try {
const { path: targetPath, isOhMyZsh } = await this.getInstallationPath();
// Check if already installed with same content
let isUpdate = false;
try {
const existingContent = await fs.readFile(targetPath, 'utf-8');
if (existingContent === completionScript) {
// Already installed and up to date
return {
success: true,
installedPath: targetPath,
isOhMyZsh,
message: 'Completion script is already installed (up to date)',
instructions: [
'The completion script is already installed and up to date.',
'If completions are not working, try: exec zsh',
],
};
}
// File exists but content is different - this is an update
isUpdate = true;
} catch (error: any) {
// File doesn't exist or can't be read, proceed with installation
console.debug(`Unable to read existing completion file at ${targetPath}: ${error.message}`);
}
// Ensure the directory exists
const targetDir = path.dirname(targetPath);
await fs.mkdir(targetDir, { recursive: true });
// Backup existing file if updating
const backupPath = isUpdate ? await this.backupExistingFile(targetPath) : undefined;
// Write the completion script
await fs.writeFile(targetPath, completionScript, 'utf-8');
// Auto-configure .zshrc
let zshrcConfigured = false;
if (isOhMyZsh) {
// For Oh My Zsh, verify that custom/completions is in fpath
// If not, add it to .zshrc
const needsConfig = await this.needsFpathConfig(targetDir);
if (needsConfig) {
zshrcConfigured = await this.configureZshrc(targetDir);
}
} else {
// Standard Zsh always needs .zshrc configuration
zshrcConfigured = await this.configureZshrc(targetDir);
}
// Generate instructions (only if .zshrc wasn't auto-configured)
let instructions = zshrcConfigured ? undefined : this.generateInstructions(isOhMyZsh, targetPath);
// Add fpath guidance for Oh My Zsh installations
if (isOhMyZsh) {
const fpathGuidance = this.generateOhMyZshFpathGuidance(targetDir);
if (fpathGuidance) {
instructions = instructions ? [...instructions, '', ...fpathGuidance] : fpathGuidance;
}
}
// Determine appropriate message based on update status
let message: string;
if (isUpdate) {
message = backupPath
? 'Completion script updated successfully (previous version backed up)'
: 'Completion script updated successfully';
} else {
message = isOhMyZsh
? 'Completion script installed successfully for Oh My Zsh'
: zshrcConfigured
? 'Completion script installed and .zshrc configured successfully'
: 'Completion script installed successfully for Zsh';
}
return {
success: true,
installedPath: targetPath,
backupPath,
isOhMyZsh,
zshrcConfigured,
message,
instructions,
};
} catch (error) {
return {
success: false,
isOhMyZsh: false,
message: `Failed to install completion script: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
/**
* Generate Oh My Zsh fpath verification guidance
*
* @param completionsDir - Custom completions directory path
* @returns Array of guidance strings, or undefined if not needed
*/
private generateOhMyZshFpathGuidance(completionsDir: string): string[] | undefined {
return [
'Note: Oh My Zsh typically auto-loads completions from custom/completions.',
`Verify that ${completionsDir} is in your fpath by running:`,
' echo $fpath | grep "custom/completions"',
'',
'If not found, completions may not work. Restart your shell to ensure changes take effect.',
];
}
/**
* Generate user instructions for enabling completions
*
* @param isOhMyZsh - Whether Oh My Zsh is being used
* @param installedPath - Path where the script was installed
* @returns Array of instruction strings
*/
private generateInstructions(isOhMyZsh: boolean, installedPath: string): string[] {
if (isOhMyZsh) {
return [
'Completion script installed to Oh My Zsh completions directory.',
'Restart your shell or run: exec zsh',
'Completions should activate automatically.',
];
} else {
const completionsDir = path.dirname(installedPath);
const zshrcPath = path.join(this.homeDir, '.zshrc');
return [
'Completion script installed to ~/.zsh/completions/',
'',
'To enable completions, add the following to your ~/.zshrc file:',
'',
` # Add completions directory to fpath`,
` fpath=(${completionsDir} $fpath)`,
'',
' # Initialize completion system',
' autoload -Uz compinit',
' compinit',
'',
'Then restart your shell or run: exec zsh',
'',
`Check if these lines already exist in ${zshrcPath} before adding.`,
];
}
}
/**
* Uninstall the completion script
*
* @returns true if uninstalled successfully, false otherwise
*/
async uninstall(): Promise<{ success: boolean; message: string }> {
try {
const { path: targetPath, isOhMyZsh } = await this.getInstallationPath();
// Try to remove completion script
let scriptRemoved = false;
try {
await fs.access(targetPath);
await fs.unlink(targetPath);
scriptRemoved = true;
} catch {
// Script not installed
}
// Try to remove .zshrc configuration (only for standard Zsh)
let zshrcWasPresent = false;
let zshrcCleaned = false;
if (!isOhMyZsh) {
zshrcWasPresent = await this.hasZshrcConfig();
if (zshrcWasPresent) {
zshrcCleaned = await this.removeZshrcConfig();
}
}
if (!scriptRemoved && !zshrcWasPresent) {
return {
success: false,
message: 'Completion script is not installed',
};
}
const messages: string[] = [];
if (scriptRemoved) {
messages.push(`Completion script removed from ${targetPath}`);
}
if (zshrcCleaned && !isOhMyZsh) {
messages.push('Removed OpenSpec configuration from ~/.zshrc');
}
return {
success: true,
message: messages.join('. '),
};
} catch (error) {
return {
success: false,
message: `Failed to uninstall completion script: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
/**
* Check if completion script is currently installed
*
* @returns true if the completion script exists
*/
async isInstalled(): Promise<boolean> {
try {
const { path: targetPath } = await this.getInstallationPath();
await fs.access(targetPath);
return true;
} catch {
return false;
}
}
/**
* Get information about the current installation
*
* @returns Installation status information
*/
async getInstallationInfo(): Promise<{
installed: boolean;
path?: string;
isOhMyZsh?: boolean;
}> {
const installed = await this.isInstalled();
if (!installed) {
return { installed: false };
}
const { path: targetPath, isOhMyZsh } = await this.getInstallationPath();
return {
installed: true,
path: targetPath,
isOhMyZsh,
};
}
}
+90
View File
@@ -0,0 +1,90 @@
import { SupportedShell } from '../../utils/shell-detection.js';
/**
* Definition of a command-line flag/option
*/
export interface FlagDefinition {
/**
* Flag name without dashes (e.g., "json", "strict", "no-interactive")
*/
name: string;
/**
* Short flag name without dash (e.g., "y" for "-y")
*/
short?: string;
/**
* Human-readable description of what the flag does
*/
description: string;
/**
* Whether the flag takes an argument value
*/
takesValue?: boolean;
/**
* Possible values for the flag (for completion suggestions)
*/
values?: string[];
}
/**
* Definition of a CLI command
*/
export interface CommandDefinition {
/**
* Command name (e.g., "init", "validate", "show")
*/
name: string;
/**
* Human-readable description of the command
*/
description: string;
/**
* Flags/options supported by this command
*/
flags: FlagDefinition[];
/**
* Subcommands (e.g., "change show", "spec validate")
*/
subcommands?: CommandDefinition[];
/**
* Whether this command accepts a positional argument (e.g., item name, path)
*/
acceptsPositional?: boolean;
/**
* Type of positional argument for dynamic completion
* - 'change-id': Complete with active change IDs
* - 'spec-id': Complete with spec IDs
* - 'change-or-spec-id': Complete with both changes and specs
* - 'path': Complete with file paths
* - 'shell': Complete with supported shell names
* - undefined: No specific completion
*/
positionalType?: 'change-id' | 'spec-id' | 'change-or-spec-id' | 'path' | 'shell';
}
/**
* Interface for shell-specific completion script generators
*/
export interface CompletionGenerator {
/**
* The shell type this generator targets
*/
readonly shell: SupportedShell;
/**
* Generate the completion script content
*
* @param commands - Command definitions to generate completions for
* @returns The shell-specific completion script as a string
*/
generate(commands: CommandDefinition[]): string;
}
+230
View File
@@ -0,0 +1,230 @@
import { z } from 'zod';
/**
* Zod schema for global OpenSpec configuration.
* Uses passthrough() to preserve unknown fields for forward compatibility.
*/
export const GlobalConfigSchema = z
.object({
featureFlags: z
.record(z.string(), z.boolean())
.optional()
.default({}),
})
.passthrough();
export type GlobalConfigType = z.infer<typeof GlobalConfigSchema>;
/**
* Default configuration values.
*/
export const DEFAULT_CONFIG: GlobalConfigType = {
featureFlags: {},
};
const KNOWN_TOP_LEVEL_KEYS = new Set(Object.keys(DEFAULT_CONFIG));
/**
* Validate a config key path for CLI set operations.
* Unknown top-level keys are rejected unless explicitly allowed by the caller.
*/
export function validateConfigKeyPath(path: string): { valid: boolean; reason?: string } {
const rawKeys = path.split('.');
if (rawKeys.length === 0 || rawKeys.some((key) => key.trim() === '')) {
return { valid: false, reason: 'Key path must not be empty' };
}
const rootKey = rawKeys[0];
if (!KNOWN_TOP_LEVEL_KEYS.has(rootKey)) {
return { valid: false, reason: `Unknown top-level key "${rootKey}"` };
}
if (rootKey === 'featureFlags') {
if (rawKeys.length > 2) {
return { valid: false, reason: 'featureFlags values are booleans and do not support nested keys' };
}
return { valid: true };
}
if (rawKeys.length > 1) {
return { valid: false, reason: `"${rootKey}" does not support nested keys` };
}
return { valid: true };
}
/**
* Get a nested value from an object using dot notation.
*
* @param obj - The object to access
* @param path - Dot-separated path (e.g., "featureFlags.someFlag")
* @returns The value at the path, or undefined if not found
*/
export function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
const keys = path.split('.');
let current: unknown = obj;
for (const key of keys) {
if (current === null || current === undefined) {
return undefined;
}
if (typeof current !== 'object') {
return undefined;
}
current = (current as Record<string, unknown>)[key];
}
return current;
}
/**
* Set a nested value in an object using dot notation.
* Creates intermediate objects as needed.
*
* @param obj - The object to modify (mutated in place)
* @param path - Dot-separated path (e.g., "featureFlags.someFlag")
* @param value - The value to set
*/
export function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): void {
const keys = path.split('.');
let current: Record<string, unknown> = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (current[key] === undefined || current[key] === null || typeof current[key] !== 'object') {
current[key] = {};
}
current = current[key] as Record<string, unknown>;
}
const lastKey = keys[keys.length - 1];
current[lastKey] = value;
}
/**
* Delete a nested value from an object using dot notation.
*
* @param obj - The object to modify (mutated in place)
* @param path - Dot-separated path (e.g., "featureFlags.someFlag")
* @returns true if the key existed and was deleted, false otherwise
*/
export function deleteNestedValue(obj: Record<string, unknown>, path: string): boolean {
const keys = path.split('.');
let current: Record<string, unknown> = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (current[key] === undefined || current[key] === null || typeof current[key] !== 'object') {
return false;
}
current = current[key] as Record<string, unknown>;
}
const lastKey = keys[keys.length - 1];
if (lastKey in current) {
delete current[lastKey];
return true;
}
return false;
}
/**
* Coerce a string value to its appropriate type.
* - "true" / "false" -> boolean
* - Numeric strings -> number
* - Everything else -> string
*
* @param value - The string value to coerce
* @param forceString - If true, always return the value as a string
* @returns The coerced value
*/
export function coerceValue(value: string, forceString: boolean = false): string | number | boolean {
if (forceString) {
return value;
}
// Boolean coercion
if (value === 'true') {
return true;
}
if (value === 'false') {
return false;
}
// Number coercion - must be a valid finite number
const num = Number(value);
if (!isNaN(num) && isFinite(num) && value.trim() !== '') {
return num;
}
return value;
}
/**
* Format a value for YAML-like display.
*
* @param value - The value to format
* @param indent - Current indentation level
* @returns Formatted string
*/
export function formatValueYaml(value: unknown, indent: number = 0): string {
const indentStr = ' '.repeat(indent);
if (value === null || value === undefined) {
return 'null';
}
if (typeof value === 'boolean' || typeof value === 'number') {
return String(value);
}
if (typeof value === 'string') {
return value;
}
if (Array.isArray(value)) {
if (value.length === 0) {
return '[]';
}
return value.map((item) => `${indentStr}- ${formatValueYaml(item, indent + 1)}`).join('\n');
}
if (typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) {
return '{}';
}
return entries
.map(([key, val]) => {
const formattedVal = formatValueYaml(val, indent + 1);
if (typeof val === 'object' && val !== null && Object.keys(val).length > 0) {
return `${indentStr}${key}:\n${formattedVal}`;
}
return `${indentStr}${key}: ${formattedVal}`;
})
.join('\n');
}
return String(value);
}
/**
* Validate a configuration object against the schema.
*
* @param config - The configuration to validate
* @returns Validation result with success status and optional error message
*/
export function validateConfig(config: unknown): { success: boolean; error?: string } {
try {
GlobalConfigSchema.parse(config);
return { success: true };
} catch (error) {
if (error instanceof z.ZodError) {
const zodError = error as z.ZodError;
const messages = zodError.issues.map((e) => `${e.path.join('.')}: ${e.message}`);
return { success: false, error: messages.join('; ') };
}
return { success: false, error: 'Unknown validation error' };
}
}
+13 -5
View File
@@ -17,17 +17,25 @@ export interface AIToolOption {
}
export const AI_TOOLS: AIToolOption[] = [
{ name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer' },
{ name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity' },
{ name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie' },
{ name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code' },
{ name: 'Cline', value: 'cline', available: true, successLabel: 'Cline' },
{ name: 'Codex', value: 'codex', available: true, successLabel: 'Codex' },
{ name: 'CodeBuddy Code (CLI)', value: 'codebuddy', available: true, successLabel: 'CodeBuddy Code' },
{ name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict' },
{ name: 'Crush', value: 'crush', available: true, successLabel: 'Crush' },
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor' },
{ name: 'Factory Droid', value: 'factory', available: true, successLabel: 'Factory Droid' },
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode' },
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code' },
{ name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf' },
{ name: 'Codex', value: 'codex', available: true, successLabel: 'Codex' },
{ name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI' },
{ name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot' },
{ name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer' },
{ name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow' },
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code' },
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode' },
{ name: 'Qoder (CLI)', value: 'qoder', available: true, successLabel: 'Qoder' },
{ name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code' },
{ name: 'RooCode', value: 'roocode', available: true, successLabel: 'RooCode' },
{ name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf' },
{ name: 'AGENTS.md (works with Amp, VS Code, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' }
];
+24
View File
@@ -0,0 +1,24 @@
import path from 'path';
import { ToolConfigurator } from './base.js';
import { FileSystemUtils } from '../../utils/file-system.js';
import { TemplateManager } from '../templates/index.js';
import { OPENSPEC_MARKERS } from '../config.js';
export class CodeBuddyConfigurator implements ToolConfigurator {
name = 'CodeBuddy';
configFileName = 'CODEBUDDY.md';
isAvailable = true;
async configure(projectPath: string, openspecDir: string): Promise<void> {
const filePath = path.join(projectPath, this.configFileName);
const content = TemplateManager.getClaudeTemplate();
await FileSystemUtils.updateFileWithMarkers(
filePath,
content,
OPENSPEC_MARKERS.start,
OPENSPEC_MARKERS.end
);
}
}
+23
View File
@@ -0,0 +1,23 @@
import path from 'path';
import { ToolConfigurator } from './base.js';
import { FileSystemUtils } from '../../utils/file-system.js';
import { TemplateManager } from '../templates/index.js';
import { OPENSPEC_MARKERS } from '../config.js';
export class CostrictConfigurator implements ToolConfigurator {
name = 'CoStrict';
configFileName = 'COSTRICT.md';
isAvailable = true;
async configure(projectPath: string, openspecDir: string): Promise<void> {
const filePath = path.join(projectPath, this.configFileName);
const content = TemplateManager.getCostrictTemplate();
await FileSystemUtils.updateFileWithMarkers(
filePath,
content,
OPENSPEC_MARKERS.start,
OPENSPEC_MARKERS.end
);
}
}
+23
View File
@@ -0,0 +1,23 @@
import path from "path";
import { ToolConfigurator } from "./base.js";
import { FileSystemUtils } from "../../utils/file-system.js";
import { TemplateManager } from "../templates/index.js";
import { OPENSPEC_MARKERS } from "../config.js";
export class IflowConfigurator implements ToolConfigurator {
name = "iFlow";
configFileName = "IFLOW.md";
isAvailable = true;
async configure(projectPath: string, openspecDir: string): Promise<void> {
const filePath = path.join(projectPath, this.configFileName);
const content = TemplateManager.getClaudeTemplate();
await FileSystemUtils.updateFileWithMarkers(
filePath,
content,
OPENSPEC_MARKERS.start,
OPENSPEC_MARKERS.end
);
}
}

Some files were not shown because too many files have changed in this diff Show More