mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-04 14:38:54 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92c6f1f729 | ||
|
|
11c50ab4d1 | ||
|
|
c4a54a8d54 | ||
|
|
38d2356836 | ||
|
|
3f67debf65 | ||
|
|
533cb0fa87 | ||
|
|
8dfd824477 | ||
|
|
3ed1270316 |
@@ -1,5 +1,36 @@
|
||||
# @fission-ai/openspec
|
||||
|
||||
## 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
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# 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 new way to work with OpenSpec changes. Instead of one big proposal, you build **artifacts** step-by-step:
|
||||
|
||||
```
|
||||
proposal → specs → design → tasks → implementation → archive
|
||||
```
|
||||
|
||||
Each artifact has dependencies. Can't write tasks until you have specs. Can't implement until you have tasks. The system tracks what's ready and what's blocked.
|
||||
|
||||
## 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:new` | Start a new change |
|
||||
| `/opsx:continue` | Create the next artifact |
|
||||
| `/opsx:ff` | Fast-forward (create all artifacts at once) |
|
||||
| `/opsx:apply` | Implement the tasks |
|
||||
| `/opsx:sync` | Sync delta specs to main specs |
|
||||
| `/opsx:archive` | Archive when done |
|
||||
|
||||
## Usage
|
||||
|
||||
### Start a new change
|
||||
```
|
||||
/opsx:new
|
||||
```
|
||||
You'll be asked what you want to build and which workflow schema to use.
|
||||
|
||||
### Build artifacts step-by-step
|
||||
```
|
||||
/opsx:continue
|
||||
```
|
||||
Creates one artifact at a time. Good for reviewing each step.
|
||||
|
||||
### Or fast-forward
|
||||
```
|
||||
/opsx:ff add-dark-mode
|
||||
```
|
||||
Creates all artifacts in one go. Good when you know what you want.
|
||||
|
||||
### Implement
|
||||
```
|
||||
/opsx:apply
|
||||
```
|
||||
Works through tasks, checking them off as you go.
|
||||
|
||||
### Sync specs and archive
|
||||
```
|
||||
/opsx:sync # Update main specs with your delta specs
|
||||
/opsx:archive # Move to archive when done
|
||||
```
|
||||
|
||||
## What's Different?
|
||||
|
||||
**Standard workflow** (`/openspec:proposal`):
|
||||
- One big proposal document
|
||||
- Linear phases: plan → implement → archive
|
||||
- All-or-nothing artifact creation
|
||||
|
||||
**Experimental workflow** (`/opsx:*`):
|
||||
- Discrete artifacts with dependencies
|
||||
- Fluid actions (not phases) - update artifacts anytime
|
||||
- Step-by-step or fast-forward
|
||||
- Schema-driven (can customize the workflow)
|
||||
|
||||
The key insight: work isn't linear. You implement, realize the design is wrong, update it, continue. OPSX supports this.
|
||||
|
||||
## 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:ff` when you have a clear idea, `/opsx:continue` when exploring
|
||||
- Tasks track progress via checkboxes in `tasks.md`
|
||||
- Delta specs (in `specs/`) get synced to main specs with `/opsx:sync`
|
||||
- If you get stuck, the status command shows what's blocked: `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).
|
||||
@@ -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,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`
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@fission-ai/openspec",
|
||||
"version": "0.17.2",
|
||||
"version": "0.18.0",
|
||||
"description": "AI-native system for spec-driven development",
|
||||
"keywords": [
|
||||
"openspec",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -284,7 +284,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,175 @@
|
||||
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) {
|
||||
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,191 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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)}"},`);
|
||||
}
|
||||
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) {
|
||||
// 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)}"},`);
|
||||
}
|
||||
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)}"},`);
|
||||
}
|
||||
}
|
||||
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
|
||||
}`;
|
||||
@@ -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]"
|
||||
---`
|
||||
};
|
||||
|
||||
|
||||
@@ -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,525 @@
|
||||
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 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,532 @@
|
||||
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 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 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');
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user