Compare commits

...
Author SHA1 Message Date
openspec-release-bot[bot]andgithub-actions[bot] 9b5007dbc3 Version Packages (#953)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-11 15:43:55 +00:00
Tabish Bidiwale cce787ec40 chore: add changeset for v1.3.0 (#952)
* Add changeset for new tool integrations and bug fixes

* Update changeset with IBM Bob support and pi.dev fix
2026-04-11 15:39:42 +00:00
94d651de8c feat: add support for IBM Bob coding assistant (#886)
* test: add comprehensive tests for Bob Shell adapter

- Add 7 tests covering toolId, file paths, formatting, and edge cases
- Include Bob Shell adapter in cross-platform path handling tests
- All 89 adapter tests now passing
- Ensures Bob Shell adapter works correctly with all 11 workflows

* feat: add Bob Shell adapter support

- Implement Bob Shell command adapter with YAML frontmatter
- Register adapter in CommandAdapterRegistry
- Export adapter from adapters/index.ts
- Add Bob Shell to AI_TOOLS configuration
- Generates commands in .bob/commands/opsx-<id>.md format
- Supports all 11 workflows via custom profile system

* docs: add Bob Shell to supported tools documentation

- Add Bob Shell to README.md supported tools list
- Update docs/supported-tools.md with Bob Shell entry
- Document .bob/commands/opsx-<id>.md command path pattern
- Note that Bob Shell uses commands, not Agent Skills spec

* docs: add Bob Shell support proposal and design documentation

- Add comprehensive proposal for Bob Shell integration
- Document command structure and file format
- Include implementation plan and success criteria
- Preserve change documentation for future reference

* chore: update dependencies and gitignore

- Update package-lock.json with latest dependencies
- Add .bob/ to gitignore (test output directory)

* chore: update gitignore to exclude .bob directory

* openspec change not needed for project, should be kept local

* Update reference from Bob Shell to IBM Bob Shell

* fix: transform command references and add argument-hint for Bob adapter

Bob derives command names from filenames (opsx-apply.md → /opsx-apply),
so body text referencing /opsx:apply is incorrect. Apply the same
transformToHyphenCommands rewrite that opencode.ts uses. Also add
argument-hint frontmatter to match peer adapters (auggie, codebuddy, etc).

---------

Co-authored-by: TabishB <tabishbidiwale@gmail.com>
Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
2026-04-11 15:31:45 +00:00
Tabish Bidiwale 040e382d64 test: fix windows powershell ci (#951) 2026-04-11 15:17:00 +00:00
Tabish Bidiwale caafd7c9bf fix: pi.dev command reference transforms and template args passing (#950)
* fix: pi.dev prompt naming and template args passing (#912)

Fix two pi.dev integration bugs:
- Use colon-based filenames (opsx:explore.md) so CLI commands render as
  /opsx:explore instead of /opsx-explore
- Inject $@ into template body so user arguments are passed through

Adds getLegacyFilePaths to ToolCommandAdapter for migration-safe cleanup
of old hyphenated files during init/update.

* fix: pi.dev command references and template args passing (#912)

- Transform /opsx: references to /opsx- in Pi command bodies and skills,
  matching the hyphenated filename convention (same approach as OpenCode)
- Inject $@ into template body so user arguments are passed through

Pi uses the filename (minus .md) as the slash command name, so
opsx-propose.md becomes /opsx-propose. This keeps filenames
cross-platform safe while ensuring command references in the body
match the actual command names.
2026-04-11 15:00:46 +00:00
Tabish Bidiwale 144528257d fix: make completion install opt-in, fix PowerShell encoding corruption (#949)
* fix: make shell completion install opt-in and fix PowerShell profile encoding corruption (#948)

The postinstall hook silently modified users' shell profiles and corrupted
UTF-16 LE PowerShell profiles by forcing all reads/writes through UTF-8.
Now postinstall only prints a tip, and the PowerShell installer preserves
file encoding via BOM detection on read/write.

* Address review: skip profile on any read error, log warnings, clean up UTF-16 BE handling

- configureProfile: skip profile on any non-ENOENT error instead of falling
  through with empty content (could overwrite real profile)
- removeProfileConfig: log warning on unexpected read errors instead of
  silently swallowing
- detectEncoding: throw directly for UTF-16 BE instead of using sentinel value
- Add test for UTF-16 BE profile rejection
2026-04-11 13:58:24 +00:00
Irina ChichikovaandTabishB af0b3418d0 Add support for Junie from JetBrains tool and command generation (#853)
* Add support for Junie from JetBrains tool and command generation

* Expand Junie support to include `opsx` file patterns and update documentation accordingly

---------

Co-authored-by: TabishB <tabishbidiwale@gmail.com>
2026-04-10 01:01:38 +00:00
7fd5417ed0 style: Fix formatting of user facing and agent facing diagrams and markdown tables (#892)
* style: Fix formatting of user facing and agent facing diagrams and markdown tables

* test: update template parity hashes for formatting changes

---------

Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
Co-authored-by: TabishB <tabishbidiwale@gmail.com>
2026-04-09 07:26:45 +00:00
mrack688andMarck 5ac1e12b83 feat: add Lingma IDE support to configuration (#864)
feat: add Lingma IDE support to configuration
feat: add Lingma IDE support to configuration

Co-authored-by: Marck <6992917@qq.com>
2026-04-09 06:25:27 +00:00
29 changed files with 605 additions and 163 deletions
@@ -1,5 +0,0 @@
---
"@fission-ai/openspec": patch
---
fix: OpenCode adapter now uses `.opencode/commands/` (plural) to match OpenCode's official directory convention. Fixes #748.
-5
View File
@@ -1,5 +0,0 @@
---
"@fission-ai/openspec": patch
---
fix: `openspec status` now exits gracefully when no changes exist instead of throwing a fatal error. Fixes #714.
+3
View File
@@ -156,3 +156,6 @@ opencode.json
# Codex
.codex/
# Bob
.bob/
+23
View File
@@ -1,5 +1,28 @@
# @fission-ai/openspec
## 1.3.0
### Minor Changes
- [#952](https://github.com/Fission-AI/OpenSpec/pull/952) [`cce787e`](https://github.com/Fission-AI/OpenSpec/commit/cce787ec4083da2b27781f6786f5ce0002909a7b) Thanks [@TabishB](https://github.com/TabishB)! - ### New Features
- **Junie support** — Added tool and command generation for JetBrains Junie
- **Lingma IDE support** — Added configuration support for Lingma IDE
- **ForgeCode support** — Added tool support for ForgeCode
- **IBM Bob support** — Added support for IBM Bob coding assistant
### Bug Fixes
- **Shell completions opt-in** — Completion install is now opt-in, fixing PowerShell encoding corruption
- **Copilot auto-detection** — Prevented false GitHub Copilot detection from a bare `.github/` directory
- **pi.dev command generation** — Fixed command reference transforms and template argument passing
### Patch Changes
- [#760](https://github.com/Fission-AI/OpenSpec/pull/760) [`61eb999`](https://github.com/Fission-AI/OpenSpec/commit/61eb999f7c6c0fc98d2e7f3678756fce6a3f4378) Thanks [@fsilvaortiz](https://github.com/fsilvaortiz)! - fix: OpenCode adapter now uses `.opencode/commands/` (plural) to match OpenCode's official directory convention. Fixes #748.
- [#759](https://github.com/Fission-AI/OpenSpec/pull/759) [`afdca0d`](https://github.com/Fission-AI/OpenSpec/commit/afdca0d5dab1aa109cfd8848b2512333ccad60c3) Thanks [@fsilvaortiz](https://github.com/fsilvaortiz)! - fix: `openspec status` now exits gracefully when no changes exist instead of throwing a fatal error. Fixes #714.
## 1.2.0
### Minor Changes
+1 -1
View File
@@ -103,7 +103,7 @@ Now tell your AI: `/opsx:propose <what-you-want-to-build>`
If you want the expanded workflow (`/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/opsx:verify`, `/opsx:sync`, `/opsx:bulk-archive`, `/opsx:onboard`), select it with `openspec config profile` and apply with `openspec update`.
> [!NOTE]
> Not sure if your tool is supported? [View the full list](docs/supported-tools.md) – we support 20+ tools and growing.
> Not sure if your tool is supported? [View the full list](docs/supported-tools.md) – we support 25+ tools and growing.
>
> Also works with pnpm, yarn, bun, and nix. [See installation options](docs/installation.md).
+3 -1
View File
@@ -24,6 +24,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `sync`, `b
| Amazon Q Developer (`amazon-q`) | `.amazonq/skills/openspec-*/SKILL.md` | `.amazonq/prompts/opsx-<id>.md` |
| Antigravity (`antigravity`) | `.agent/skills/openspec-*/SKILL.md` | `.agent/workflows/opsx-<id>.md` |
| Auggie (`auggie`) | `.augment/skills/openspec-*/SKILL.md` | `.augment/commands/opsx-<id>.md` |
| IBM Bob Shell (`bob`) | `.bob/skills/openspec-*/SKILL.md` | `.bob/commands/opsx-<id>.md` |
| Claude Code (`claude`) | `.claude/skills/openspec-*/SKILL.md` | `.claude/commands/opsx/<id>.md` |
| Cline (`cline`) | `.cline/skills/openspec-*/SKILL.md` | `.clinerules/workflows/opsx-<id>.md` |
| CodeBuddy (`codebuddy`) | `.codebuddy/skills/openspec-*/SKILL.md` | `.codebuddy/commands/opsx/<id>.md` |
@@ -37,6 +38,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `sync`, `b
| Gemini CLI (`gemini`) | `.gemini/skills/openspec-*/SKILL.md` | `.gemini/commands/opsx/<id>.toml` |
| GitHub Copilot (`github-copilot`) | `.github/skills/openspec-*/SKILL.md` | `.github/prompts/opsx-<id>.prompt.md`\*\* |
| iFlow (`iflow`) | `.iflow/skills/openspec-*/SKILL.md` | `.iflow/commands/opsx-<id>.md` |
| Junie (`junie`) | `.junie/skills/openspec-*/SKILL.md` | `.junie/commands/opsx-<id>.md` |
| Kilo Code (`kilocode`) | `.kilocode/skills/openspec-*/SKILL.md` | `.kilocode/workflows/opsx-<id>.md` |
| Kiro (`kiro`) | `.kiro/skills/openspec-*/SKILL.md` | `.kiro/prompts/opsx-<id>.prompt.md` |
| OpenCode (`opencode`) | `.opencode/skills/openspec-*/SKILL.md` | `.opencode/commands/opsx-<id>.md` |
@@ -69,7 +71,7 @@ openspec init --tools none
openspec init --profile core
```
**Available tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `claude`, `cline`, `codex`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `forgecode`, `gemini`, `github-copilot`, `iflow`, `kilocode`, `kiro`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf`
**Available tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codex`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `forgecode`, `gemini`, `github-copilot`, `iflow`, `junie`, `kilocode`, `kiro`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf`
## Workflow-Dependent Installation
+13 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@fission-ai/openspec",
"version": "1.1.1",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@fission-ai/openspec",
"version": "1.1.1",
"version": "1.2.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -1803,6 +1803,7 @@
"integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -1852,6 +1853,7 @@
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/types": "8.56.0",
@@ -2182,6 +2184,7 @@
"integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/utils": "3.2.4",
"fflate": "^0.8.2",
@@ -2219,6 +2222,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2685,6 +2689,7 @@
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4448,6 +4453,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -4546,6 +4552,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4611,6 +4618,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -4727,6 +4735,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -4740,6 +4749,7 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -4919,6 +4929,7 @@
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@fission-ai/openspec",
"version": "1.2.0",
"version": "1.3.0",
"description": "AI-native system for spec-driven development",
"keywords": [
"openspec",
+8 -72
View File
@@ -1,9 +1,13 @@
#!/usr/bin/env node
/**
* Postinstall script for auto-installing shell completions
* Postinstall script that hints about shell completions
*
* This script runs automatically after npm install unless:
* Completion installation is opt-in: the user must run
* `openspec completion install` explicitly. This script only
* prints a one-line tip after npm install.
*
* The tip is suppressed when:
* - CI=true environment variable is set
* - OPENSPEC_NO_COMPLETIONS=1 environment variable is set
* - dist/ directory doesn't exist (dev setup scenario)
@@ -48,65 +52,6 @@ async function distExists() {
}
}
/**
* Detect the user's shell
*/
async function detectShell() {
try {
const { detectShell } = await import('../dist/utils/shell-detection.js');
const result = detectShell();
return result.shell;
} catch (error) {
// Fail silently if detection module doesn't exist
return undefined;
}
}
/**
* Install completions for the detected shell
*/
async function installCompletions(shell) {
try {
const { CompletionFactory } = await import('../dist/core/completions/factory.js');
const { COMMAND_REGISTRY } = await import('../dist/core/completions/command-registry.js');
// Check if shell is supported
if (!CompletionFactory.isSupported(shell)) {
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
return;
}
// Generate completion script
const generator = CompletionFactory.createGenerator(shell);
const script = generator.generate(COMMAND_REGISTRY);
// Install completion script
const installer = CompletionFactory.createInstaller(shell);
const result = await installer.install(script);
if (result.success) {
// Show success message based on installation type
if (result.isOhMyZsh) {
console.log(`✓ Shell completions installed`);
console.log(` Restart shell: exec zsh`);
} else if (result.zshrcConfigured) {
console.log(`✓ Shell completions installed and configured`);
console.log(` Restart shell: exec zsh`);
} else {
console.log(`✓ Shell completions installed to ~/.zsh/completions/`);
console.log(` Add to ~/.zshrc: fpath=(~/.zsh/completions $fpath)`);
console.log(` Then: exec zsh`);
}
} else {
// Installation failed, show tip for manual install
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
}
} catch (error) {
// Fail gracefully - show tip for manual install
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
}
}
/**
* Main function
*/
@@ -124,19 +69,10 @@ async function main() {
return;
}
// Detect shell
const shell = await detectShell();
if (!shell) {
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
return;
}
// Install completions
await installCompletions(shell);
// Completions are opt-in — just print a hint
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
} catch (error) {
// Fail gracefully - never break npm install
// Show tip for manual install
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ ORIGINAL_CI="${CI:-}"
ORIGINAL_OPENSPEC_NO_COMPLETIONS="${OPENSPEC_NO_COMPLETIONS:-}"
# Test 1: Normal install
echo "Test 1: Normal install (should attempt to install completions)"
echo "Test 1: Normal install (should print tip about completions)"
echo "--------------------------------------"
unset CI
unset OPENSPEC_NO_COMPLETIONS
@@ -0,0 +1,51 @@
/**
* Bob Shell Command Adapter
*
* Formats commands for Bob Shell following its markdown specification.
* Commands are stored in .bob/commands/ directory.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
import { transformToHyphenCommands } from '../../../utils/command-references.js';
/**
* Escapes a string value for safe YAML output.
* Quotes the string if it contains special YAML characters.
*/
function escapeYamlValue(value: string): string {
// Check if value needs quoting (contains special YAML characters or starts/ends with whitespace)
const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value);
if (needsQuoting) {
// Use double quotes and escape internal double quotes and backslashes
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
return `"${escaped}"`;
}
return value;
}
/**
* Bob Shell adapter for command generation.
* File path: .bob/commands/opsx-<id>.md
* Frontmatter: description, argument-hint
*/
export const bobAdapter: ToolCommandAdapter = {
toolId: 'bob',
getFilePath(commandId: string): string {
return path.join('.bob', 'commands', `opsx-${commandId}.md`);
},
formatFile(content: CommandContent): string {
// Transform command references from colon to hyphen format for Bob
const transformedBody = transformToHyphenCommands(content.body);
return `---
description: ${escapeYamlValue(content.description)}
argument-hint: command arguments
---
${transformedBody}
`;
},
};
@@ -7,6 +7,7 @@
export { amazonQAdapter } from './amazon-q.js';
export { antigravityAdapter } from './antigravity.js';
export { auggieAdapter } from './auggie.js';
export { bobAdapter } from './bob.js';
export { claudeAdapter } from './claude.js';
export { clineAdapter } from './cline.js';
export { codexAdapter } from './codex.js';
@@ -19,11 +20,13 @@ export { factoryAdapter } from './factory.js';
export { geminiAdapter } from './gemini.js';
export { githubCopilotAdapter } from './github-copilot.js';
export { iflowAdapter } from './iflow.js';
export { junieAdapter } from './junie.js';
export { kilocodeAdapter } from './kilocode.js';
export { kiroAdapter } from './kiro.js';
export { opencodeAdapter } from './opencode.js';
export { piAdapter } from './pi.js';
export { qoderAdapter } from './qoder.js';
export { lingmaAdapter } from './lingma.js';
export { qwenAdapter } from './qwen.js';
export { roocodeAdapter } from './roocode.js';
export { windsurfAdapter } from './windsurf.js';
@@ -0,0 +1,30 @@
/**
* Junie Command Adapter
*
* Formats commands for Junie following its frontmatter specification.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* Junie adapter for command generation.
* File path: .junie/commands/opsx-<id>.md
* Frontmatter: description
*/
export const junieAdapter: ToolCommandAdapter = {
toolId: 'junie',
getFilePath(commandId: string): string {
return path.join('.junie', 'commands', `opsx-${commandId}.md`);
},
formatFile(content: CommandContent): string {
return `---
description: ${content.description}
---
${content.body}
`;
},
};
@@ -0,0 +1,34 @@
/**
* Lingma Command Adapter
*
* Formats commands for Lingma following its frontmatter specification.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* Lingma adapter for command generation.
* File path: .lingma/commands/opsx/<id>.md
* Frontmatter: name, description, category, tags
*/
export const lingmaAdapter: ToolCommandAdapter = {
toolId: 'lingma',
getFilePath(commandId: string): string {
return path.join('.lingma', 'commands', 'opsx', `${commandId}.md`);
},
formatFile(content: CommandContent): string {
const tagsStr = content.tags.join(', ');
return `---
name: ${content.name}
description: ${content.description}
category: ${content.category}
tags: [${tagsStr}]
---
${content.body}
`;
},
};
+22 -1
View File
@@ -7,6 +7,20 @@
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
import { transformToHyphenCommands } from '../../../utils/command-references.js';
const PI_INPUT_HEADING = /^\*\*Input\*\*:[^\n]*$/m;
function injectPiArgs(body: string): string {
if (body.includes('$@') || body.includes('$ARGUMENTS')) {
return body;
}
return body.replace(
PI_INPUT_HEADING,
(heading) => `${heading}\n**Provided arguments**: $@`
);
}
/**
* Escapes a string value for safe YAML output.
@@ -27,6 +41,10 @@ function escapeYamlValue(value: string): string {
* Pi adapter for prompt template generation.
* File path: .pi/prompts/opsx-<id>.md
* Frontmatter: description
*
* Pi uses the filename (minus .md) as the slash command name, so
* opsx-propose.md → /opsx-propose. Command references in the body
* are transformed from /opsx: to /opsx- for consistency.
*/
export const piAdapter: ToolCommandAdapter = {
toolId: 'pi',
@@ -36,11 +54,14 @@ export const piAdapter: ToolCommandAdapter = {
},
formatFile(content: CommandContent): string {
// Transform /opsx: references to /opsx- and inject $@ for template args
const transformedBody = transformToHyphenCommands(content.body);
return `---
description: ${escapeYamlValue(content.description)}
---
${content.body}
${injectPiArgs(transformedBody)}
`;
},
};
+6
View File
@@ -9,6 +9,7 @@ import type { ToolCommandAdapter } from './types.js';
import { amazonQAdapter } from './adapters/amazon-q.js';
import { antigravityAdapter } from './adapters/antigravity.js';
import { auggieAdapter } from './adapters/auggie.js';
import { bobAdapter } from './adapters/bob.js';
import { claudeAdapter } from './adapters/claude.js';
import { clineAdapter } from './adapters/cline.js';
import { codexAdapter } from './adapters/codex.js';
@@ -21,11 +22,13 @@ import { factoryAdapter } from './adapters/factory.js';
import { geminiAdapter } from './adapters/gemini.js';
import { githubCopilotAdapter } from './adapters/github-copilot.js';
import { iflowAdapter } from './adapters/iflow.js';
import { junieAdapter } from './adapters/junie.js';
import { kilocodeAdapter } from './adapters/kilocode.js';
import { kiroAdapter } from './adapters/kiro.js';
import { opencodeAdapter } from './adapters/opencode.js';
import { piAdapter } from './adapters/pi.js';
import { qoderAdapter } from './adapters/qoder.js';
import { lingmaAdapter } from './adapters/lingma.js';
import { qwenAdapter } from './adapters/qwen.js';
import { roocodeAdapter } from './adapters/roocode.js';
import { windsurfAdapter } from './adapters/windsurf.js';
@@ -41,6 +44,7 @@ export class CommandAdapterRegistry {
CommandAdapterRegistry.register(amazonQAdapter);
CommandAdapterRegistry.register(antigravityAdapter);
CommandAdapterRegistry.register(auggieAdapter);
CommandAdapterRegistry.register(bobAdapter);
CommandAdapterRegistry.register(claudeAdapter);
CommandAdapterRegistry.register(clineAdapter);
CommandAdapterRegistry.register(codexAdapter);
@@ -53,11 +57,13 @@ export class CommandAdapterRegistry {
CommandAdapterRegistry.register(geminiAdapter);
CommandAdapterRegistry.register(githubCopilotAdapter);
CommandAdapterRegistry.register(iflowAdapter);
CommandAdapterRegistry.register(junieAdapter);
CommandAdapterRegistry.register(kilocodeAdapter);
CommandAdapterRegistry.register(kiroAdapter);
CommandAdapterRegistry.register(opencodeAdapter);
CommandAdapterRegistry.register(piAdapter);
CommandAdapterRegistry.register(qoderAdapter);
CommandAdapterRegistry.register(lingmaAdapter);
CommandAdapterRegistry.register(qwenAdapter);
CommandAdapterRegistry.register(roocodeAdapter);
CommandAdapterRegistry.register(windsurfAdapter);
@@ -23,6 +23,49 @@ export class PowerShellInstaller {
this.homeDir = homeDir;
}
/**
* Detect the encoding of a file by inspecting its BOM (Byte Order Mark).
* Returns the Node.js BufferEncoding and the raw BOM bytes to preserve on write.
*/
private detectEncoding(buffer: Buffer): { encoding: BufferEncoding; bom: Buffer } {
// UTF-16 LE BOM: FF FE
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
return { encoding: 'utf16le', bom: Buffer.from([0xff, 0xfe]) };
}
// UTF-16 BE BOM: FE FF — not natively supported by Node
if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) {
throw new Error(
'File is encoded as UTF-16 BE which is not supported. ' +
'Please re-save as UTF-8 or UTF-16 LE, then retry.',
);
}
// UTF-8 BOM: EF BB BF
if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) {
return { encoding: 'utf-8', bom: Buffer.from([0xef, 0xbb, 0xbf]) };
}
// No BOM → default UTF-8
return { encoding: 'utf-8', bom: Buffer.alloc(0) };
}
/**
* Read a profile file, preserving its encoding metadata for round-trip writes.
* Throws if the file uses UTF-16 BE (unsupported by Node).
*/
private async readProfileFile(filePath: string): Promise<{ content: string; encoding: BufferEncoding; bom: Buffer }> {
const raw = await fs.readFile(filePath);
const { encoding, bom } = this.detectEncoding(raw);
const content = raw.subarray(bom.length).toString(encoding);
return { content, encoding, bom };
}
/**
* Write a profile file, preserving the original BOM and encoding.
*/
private async writeProfileFile(filePath: string, content: string, encoding: BufferEncoding, bom: Buffer): Promise<void> {
const body = Buffer.from(content, encoding);
await fs.writeFile(filePath, Buffer.concat([bom, body]));
}
/**
* Get PowerShell profile path
* Prefers $PROFILE environment variable, falls back to platform defaults
@@ -132,10 +175,22 @@ export class PowerShellInstaller {
await fs.mkdir(profileDir, { recursive: true });
let profileContent = '';
let fileEncoding: BufferEncoding = 'utf-8';
let fileBom: Buffer = Buffer.alloc(0);
try {
profileContent = await fs.readFile(profilePath, 'utf-8');
} catch {
// Profile doesn't exist yet, that's fine
const file = await this.readProfileFile(profilePath);
profileContent = file.content;
fileEncoding = file.encoding;
fileBom = file.bom;
} catch (err: any) {
// If the file doesn't exist that's fine — we'll create it as UTF-8.
// Any other read error (permissions, unsupported encoding, etc.) → skip this profile.
if (err?.code === 'ENOENT') {
// keep defaults
} else {
console.warn(`Warning: Skipping ${profilePath}: ${err?.message ?? String(err)}`);
continue;
}
}
// Check if already configured
@@ -154,7 +209,7 @@ export class PowerShellInstaller {
].join('\n');
const newContent = profileContent + openspecBlock;
await fs.writeFile(profilePath, newContent, 'utf-8');
await this.writeProfileFile(profilePath, newContent, fileEncoding, fileBom);
anyConfigured = true;
} catch (error) {
// Continue to next profile if this one fails
@@ -177,12 +232,21 @@ export class PowerShellInstaller {
for (const profilePath of profilePaths) {
try {
// Read profile content
// Read profile content with encoding detection
let profileContent: string;
let fileEncoding: BufferEncoding = 'utf-8';
let fileBom: Buffer = Buffer.alloc(0);
try {
profileContent = await fs.readFile(profilePath, 'utf-8');
} catch {
continue; // Profile doesn't exist, nothing to remove
const file = await this.readProfileFile(profilePath);
profileContent = file.content;
fileEncoding = file.encoding;
fileBom = file.bom;
} catch (err: any) {
if (err?.code === 'ENOENT') {
continue; // Profile doesn't exist, nothing to remove
}
console.warn(`Warning: Could not read ${profilePath}: ${err?.message ?? String(err)}`);
continue;
}
// Remove OPENSPEC:START -> OPENSPEC:END block
@@ -207,7 +271,7 @@ export class PowerShellInstaller {
// Clean up extra newlines
const newContent = (beforeBlock.trimEnd() + '\n' + afterBlock.trimStart()).trim() + '\n';
await fs.writeFile(profilePath, newContent, 'utf-8');
await this.writeProfileFile(profilePath, newContent, fileEncoding, fileBom);
anyRemoved = true;
} catch (error) {
console.warn(`Warning: Could not clean ${profilePath}: ${error}`);
+3
View File
@@ -22,6 +22,7 @@ export const AI_TOOLS: AIToolOption[] = [
{ name: 'Amazon Q Developer', value: 'amazon-q', available: true, successLabel: 'Amazon Q Developer', skillsDir: '.amazonq' },
{ name: 'Antigravity', value: 'antigravity', available: true, successLabel: 'Antigravity', skillsDir: '.agent' },
{ name: 'Auggie (Augment CLI)', value: 'auggie', available: true, successLabel: 'Auggie', skillsDir: '.augment' },
{ name: 'Bob Shell', value: 'bob', available: true, successLabel: 'Bob Shell', skillsDir: '.bob' },
{ name: 'Claude Code', value: 'claude', available: true, successLabel: 'Claude Code', skillsDir: '.claude' },
{ name: 'Cline', value: 'cline', available: true, successLabel: 'Cline', skillsDir: '.cline' },
{ name: 'Codex', value: 'codex', available: true, successLabel: 'Codex', skillsDir: '.codex' },
@@ -35,11 +36,13 @@ export const AI_TOOLS: AIToolOption[] = [
{ name: 'Gemini CLI', value: 'gemini', available: true, successLabel: 'Gemini CLI', skillsDir: '.gemini' },
{ name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot', skillsDir: '.github', detectionPaths: ['.github/copilot-instructions.md', '.github/instructions', '.github/workflows/copilot-setup-steps.yml', '.github/prompts', '.github/agents', '.github/skills', '.github/.mcp.json'] },
{ name: 'iFlow', value: 'iflow', available: true, successLabel: 'iFlow', skillsDir: '.iflow' },
{ name: 'Junie', value: 'junie', available: true, successLabel: 'Junie', skillsDir: '.junie' },
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code', skillsDir: '.kilocode' },
{ name: 'Kiro', value: 'kiro', available: true, successLabel: 'Kiro', skillsDir: '.kiro' },
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode', skillsDir: '.opencode' },
{ name: 'Pi', value: 'pi', available: true, successLabel: 'Pi', skillsDir: '.pi' },
{ name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder' },
{ name: 'Lingma', value: 'lingma', available: true, successLabel: 'Lingma', skillsDir: '.lingma' },
{ name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code', skillsDir: '.qwen' },
{ name: 'RooCode', value: 'roocode', available: true, successLabel: 'RooCode', skillsDir: '.roo' },
{ name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae' },
+2 -2
View File
@@ -537,8 +537,8 @@ export class InitCommand {
const skillFile = path.join(skillDir, 'SKILL.md');
// Generate SKILL.md content with YAML frontmatter including generatedBy
// Use hyphen-based command references for OpenCode
const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined;
// Use hyphen-based command references for tools where filename = command name
const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined;
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
// Write the skill file
+2
View File
@@ -34,6 +34,7 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPatter
'claude': { type: 'directory', path: '.claude/commands/openspec' },
'codebuddy': { type: 'directory', path: '.codebuddy/commands/openspec' },
'qoder': { type: 'directory', path: '.qoder/commands/openspec' },
'lingma': { type: 'directory', path: '.lingma/commands/openspec' },
'crush': { type: 'directory', path: '.crush/commands/openspec' },
'gemini': { type: 'directory', path: '.gemini/commands/openspec' },
'costrict': { type: 'directory', path: '.cospec/openspec/commands' },
@@ -53,6 +54,7 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPatter
'continue': { type: 'files', pattern: '.continue/prompts/openspec-*.prompt' },
'antigravity': { type: 'files', pattern: '.agent/workflows/openspec-*.md' },
'iflow': { type: 'files', pattern: '.iflow/commands/openspec-*.md' },
'junie': { type: 'files', pattern: ['.junie/commands/opsx-*.md', '.junie/commands/openspec-*.md'] },
'qwen': { type: 'files', pattern: '.qwen/commands/openspec-*.toml' },
'codex': { type: 'files', pattern: '.codex/prompts/openspec-*.md' },
};
@@ -85,7 +85,7 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
Display a table summarizing all changes:
\`\`\`
| Change | Artifacts | Tasks | Specs | Conflicts | Status |
| Change | Artifacts | Tasks | Specs | Conflicts | Status |
|---------------------|-----------|-------|---------|-----------|--------|
| schema-management | Done | 5/5 | 2 delta | None | Ready |
| project-config | Done | 3/3 | 1 delta | None | Ready |
@@ -332,7 +332,7 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
Display a table summarizing all changes:
\`\`\`
| Change | Artifacts | Tasks | Specs | Conflicts | Status |
| Change | Artifacts | Tasks | Specs | Conflicts | Status |
|---------------------|-----------|-------|---------|-----------|--------|
| schema-management | Done | 5/5 | 2 delta | None | Ready |
| project-config | Done | 3/3 | 1 delta | None | Ready |
+25 -25
View File
@@ -57,10 +57,10 @@ Depending on what the user brings, you might:
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
@@ -115,14 +115,14 @@ If the user mentions a change or you detect one is relevant:
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|--------------|------------------|
| New requirement discovered | \`specs/<capability>/spec.md\` |
| Requirement changed | \`specs/<capability>/spec.md\` |
| Design decision made | \`design.md\` |
| Scope changed | \`proposal.md\` |
| New work identified | \`tasks.md\` |
| Assumption invalidated | Relevant artifact |
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | \`specs/<capability>/spec.md\` |
| Requirement changed | \`specs/<capability>/spec.md\` |
| Design decision made | \`design.md\` |
| Scope changed | \`proposal.md\` |
| New work identified | \`tasks.md\` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
@@ -228,7 +228,7 @@ User: A CLI tool that tracks local dev environments
You: That changes everything.
┌─────────────────────────────────────────────────┐
│ CLI TOOL DATA STORAGE │
│ CLI TOOL DATA STORAGE │
└─────────────────────────────────────────────────┘
Key constraints:
@@ -353,10 +353,10 @@ Depending on what the user brings, you might:
│ Use ASCII diagrams liberally │
├─────────────────────────────────────────┤
│ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ ┌────────┐ ┌────────┐ │
│ │ State │────────▶│ State │ │
│ │ A │ │ B │ │
│ └────────┘ └────────┘ │
│ │
│ System diagrams, state machines, │
│ data flows, architecture sketches, │
@@ -413,14 +413,14 @@ If the user mentions a change or you detect one is relevant:
3. **Offer to capture when decisions are made**
| Insight Type | Where to Capture |
|--------------|------------------|
| New requirement discovered | \`specs/<capability>/spec.md\` |
| Requirement changed | \`specs/<capability>/spec.md\` |
| Design decision made | \`design.md\` |
| Scope changed | \`proposal.md\` |
| New work identified | \`tasks.md\` |
| Assumption invalidated | Relevant artifact |
| Insight Type | Where to Capture |
|----------------------------|--------------------------------|
| New requirement discovered | \`specs/<capability>/spec.md\` |
| Requirement changed | \`specs/<capability>/spec.md\` |
| Design decision made | \`design.md\` |
| Scope changed | \`proposal.md\` |
| New work identified | \`tasks.md\` |
| Assumption invalidated | Relevant artifact |
Example offers:
- "That's a design decision. Capture it in design.md?"
+24 -24
View File
@@ -477,21 +477,21 @@ This same rhythm works for any size change—a small fix or a major feature.
**Core workflow:**
| Command | What it does |
|---------|--------------|
| \`/opsx:propose\` | Create a change and generate all artifacts |
| \`/opsx:explore\` | Think through problems before/during work |
| \`/opsx:apply\` | Implement tasks from a change |
| \`/opsx:archive\` | Archive a completed change |
| Command | What it does |
|-------------------|--------------------------------------------|
| \`/opsx:propose\` | Create a change and generate all artifacts |
| \`/opsx:explore\` | Think through problems before/during work |
| \`/opsx:apply\` | Implement tasks from a change |
| \`/opsx:archive\` | Archive a completed change |
**Additional commands:**
| Command | What it does |
|---------|--------------|
| \`/opsx:new\` | Start a new change, step through artifacts one at a time |
| \`/opsx:continue\` | Continue working on an existing change |
| \`/opsx:ff\` | Fast-forward: create all artifacts at once |
| \`/opsx:verify\` | Verify implementation matches artifacts |
| Command | What it does |
|--------------------|----------------------------------------------------------|
| \`/opsx:new\` | Start a new change, step through artifacts one at a time |
| \`/opsx:continue\` | Continue working on an existing change |
| \`/opsx:ff\` | Fast-forward: create all artifacts at once |
| \`/opsx:verify\` | Verify implementation matches artifacts |
---
@@ -529,21 +529,21 @@ If the user says they just want to see the commands or skip the tutorial:
**Core workflow:**
| Command | What it does |
|---------|--------------|
| \`/opsx:propose <name>\` | Create a change and generate all artifacts |
| \`/opsx:explore\` | Think through problems (no code changes) |
| \`/opsx:apply <name>\` | Implement tasks |
| \`/opsx:archive <name>\` | Archive when done |
| Command | What it does |
|--------------------------|--------------------------------------------|
| \`/opsx:propose <name>\` | Create a change and generate all artifacts |
| \`/opsx:explore\` | Think through problems (no code changes) |
| \`/opsx:apply <name>\` | Implement tasks |
| \`/opsx:archive <name>\` | Archive when done |
**Additional commands:**
| Command | What it does |
|---------|--------------|
| \`/opsx:new <name>\` | Start a new change, step by step |
| \`/opsx:continue <name>\` | Continue an existing change |
| \`/opsx:ff <name>\` | Fast-forward: all artifacts at once |
| \`/opsx:verify <name>\` | Verify implementation |
| Command | What it does |
|---------------------------|-------------------------------------|
| \`/opsx:new <name>\` | Start a new change, step by step |
| \`/opsx:continue <name>\` | Continue an existing change |
| \`/opsx:ff <name>\` | Fast-forward: all artifacts at once |
| \`/opsx:verify <name>\` | Verify implementation |
Try \`/opsx:propose\` to start your first change.
\`\`\`
+2 -2
View File
@@ -195,7 +195,7 @@ export class UpdateCommand {
const skillFile = path.join(skillDir, 'SKILL.md');
// Use hyphen-based command references for OpenCode
const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined;
const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined;
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
await FileSystemUtils.writeFile(skillFile, skillContent);
}
@@ -666,7 +666,7 @@ export class UpdateCommand {
const skillFile = path.join(skillDir, 'SKILL.md');
// Use hyphen-based command references for OpenCode
const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined;
const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined;
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
await FileSystemUtils.writeFile(skillFile, skillContent);
}
+89 -1
View File
@@ -4,6 +4,7 @@ import path from 'path';
import { amazonQAdapter } from '../../../src/core/command-generation/adapters/amazon-q.js';
import { antigravityAdapter } from '../../../src/core/command-generation/adapters/antigravity.js';
import { auggieAdapter } from '../../../src/core/command-generation/adapters/auggie.js';
import { bobAdapter } from '../../../src/core/command-generation/adapters/bob.js';
import { claudeAdapter } from '../../../src/core/command-generation/adapters/claude.js';
import { clineAdapter } from '../../../src/core/command-generation/adapters/cline.js';
import { codexAdapter } from '../../../src/core/command-generation/adapters/codex.js';
@@ -183,6 +184,71 @@ describe('command-generation/adapters', () => {
});
});
describe('bobAdapter', () => {
it('should have correct toolId', () => {
expect(bobAdapter.toolId).toBe('bob');
});
it('should generate correct file path', () => {
const filePath = bobAdapter.getFilePath('explore');
expect(filePath).toBe(path.join('.bob', 'commands', 'opsx-explore.md'));
});
it('should generate correct file paths for different commands', () => {
expect(bobAdapter.getFilePath('new')).toBe(path.join('.bob', 'commands', 'opsx-new.md'));
expect(bobAdapter.getFilePath('bulk-archive')).toBe(path.join('.bob', 'commands', 'opsx-bulk-archive.md'));
});
it('should format file with description and argument-hint frontmatter', () => {
const output = bobAdapter.formatFile(sampleContent);
expect(output).toContain('---\n');
expect(output).toContain('description: Enter explore mode for thinking');
expect(output).toContain('argument-hint: command arguments');
expect(output).toContain('---\n\n');
expect(output).toContain('This is the command body.\n\nWith multiple lines.');
});
it('should transform colon command references to hyphen format', () => {
const contentWithRefs: CommandContent = {
...sampleContent,
body: 'Run /opsx:apply to implement. Then use /opsx:verify.',
};
const output = bobAdapter.formatFile(contentWithRefs);
expect(output).toContain('/opsx-apply');
expect(output).toContain('/opsx-verify');
expect(output).not.toContain('/opsx:apply');
expect(output).not.toContain('/opsx:verify');
});
it('should escape YAML special characters in description', () => {
const contentWithSpecialChars: CommandContent = {
...sampleContent,
description: 'Fix: regression in "auth" feature',
};
const output = bobAdapter.formatFile(contentWithSpecialChars);
expect(output).toContain('description: "Fix: regression in \\"auth\\" feature"');
});
it('should escape newlines in description', () => {
const contentWithNewline: CommandContent = {
...sampleContent,
description: 'Line 1\nLine 2',
};
const output = bobAdapter.formatFile(contentWithNewline);
expect(output).toContain('description: "Line 1\\nLine 2"');
});
it('should handle empty description', () => {
const contentEmptyDesc: CommandContent = {
...sampleContent,
description: '',
};
const output = bobAdapter.formatFile(contentEmptyDesc);
expect(output).toContain('description: \n');
});
});
describe('clineAdapter', () => {
it('should have correct toolId', () => {
expect(clineAdapter.toolId).toBe('cline');
@@ -547,6 +613,28 @@ describe('command-generation/adapters', () => {
expect(output).toContain('This is the command body.');
});
it('should transform command references from colon to hyphen format', () => {
const contentWithRefs: CommandContent = {
...sampleContent,
body: 'Run /opsx:apply to implement. Then /opsx:archive when done.',
};
const output = piAdapter.formatFile(contentWithRefs);
expect(output).toContain('/opsx-apply');
expect(output).toContain('/opsx-archive');
expect(output).not.toContain('/opsx:apply');
});
it('should inject template arguments into the input section', () => {
const contentWithInput: CommandContent = {
...sampleContent,
body: '**Input**: The argument after `/opsx:explore` is the topic.\n\n**Steps**\n1. Think.',
};
const output = piAdapter.formatFile(contentWithInput);
expect(output).toContain('**Provided arguments**: $@');
});
it('should escape YAML special characters in description', () => {
const contentWithSpecialChars: CommandContent = {
...sampleContent,
@@ -606,7 +694,7 @@ describe('command-generation/adapters', () => {
it('All adapters use path.join for paths', () => {
// Verify all adapters produce valid paths
const adapters = [
amazonQAdapter, antigravityAdapter, auggieAdapter, clineAdapter,
amazonQAdapter, antigravityAdapter, auggieAdapter, bobAdapter, clineAdapter,
codexAdapter, codebuddyAdapter, continueAdapter, costrictAdapter,
crushAdapter, factoryAdapter, geminiAdapter, githubCopilotAdapter,
iflowAdapter, kilocodeAdapter, opencodeAdapter, piAdapter, qoderAdapter,
@@ -21,6 +21,12 @@ describe('command-generation/registry', () => {
expect(adapter?.toolId).toBe('windsurf');
});
it('should return Junie adapter for "junie"', () => {
const adapter = CommandAdapterRegistry.get('junie');
expect(adapter).toBeDefined();
expect(adapter?.toolId).toBe('junie');
});
it('should return undefined for unregistered tool', () => {
const adapter = CommandAdapterRegistry.get('unknown-tool');
expect(adapter).toBeUndefined();
@@ -54,6 +60,7 @@ describe('command-generation/registry', () => {
expect(CommandAdapterRegistry.has('claude')).toBe(true);
expect(CommandAdapterRegistry.has('cursor')).toBe(true);
expect(CommandAdapterRegistry.has('windsurf')).toBe(true);
expect(CommandAdapterRegistry.has('junie')).toBe(true);
});
it('should return false for unregistered tools', () => {
@@ -544,6 +544,173 @@ Register-ArgumentCompleter -CommandName openspec -ScriptBlock $openspecCompleter
});
});
describe('encoding preservation', () => {
const mockScriptPath = '/path/to/OpenSpecCompletion.ps1';
const utf16leBom = Buffer.from([0xff, 0xfe]);
const utf8Bom = Buffer.from([0xef, 0xbb, 0xbf]);
/**
* Helper: write a file in UTF-16 LE with BOM, the way Windows PowerShell does.
*/
function writeUtf16LeFile(filePath: string, text: string): Promise<void> {
const body = Buffer.from(text, 'utf16le');
return fs.writeFile(filePath, Buffer.concat([utf16leBom, body]));
}
/**
* Helper: write a file in UTF-8 with BOM.
*/
function writeUtf8BomFile(filePath: string, text: string): Promise<void> {
const body = Buffer.from(text, 'utf-8');
return fs.writeFile(filePath, Buffer.concat([utf8Bom, body]));
}
it('should preserve UTF-16 LE BOM when configuring profile', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
const profilePath = installer.getProfilePath();
await fs.mkdir(path.dirname(profilePath), { recursive: true });
const originalText = '. "C:\\Code\\SystemConfig\\Powershell\\profile.ps1"\r\n';
await writeUtf16LeFile(profilePath, originalText);
const result = await installer.configureProfile(mockScriptPath);
expect(result).toBe(true);
// Read back raw bytes and verify BOM is preserved
const raw = await fs.readFile(profilePath);
expect(raw[0]).toBe(0xff);
expect(raw[1]).toBe(0xfe);
// Decode and verify content is intact
const content = raw.subarray(2).toString('utf16le');
expect(content).toContain('. "C:\\Code\\SystemConfig\\Powershell\\profile.ps1"');
expect(content).toContain('# OPENSPEC:START');
expect(content).toContain(`. "${mockScriptPath}"`);
expect(content).toContain('# OPENSPEC:END');
});
it('should preserve UTF-16 LE BOM when removing profile config', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
const profilePath = installer.getProfilePath();
await fs.mkdir(path.dirname(profilePath), { recursive: true });
const textWithBlock = [
'. "C:\\Code\\profile.ps1"',
'# OPENSPEC:START',
'. "/path/to/OpenSpecCompletion.ps1"',
'# OPENSPEC:END',
'',
].join('\n');
await writeUtf16LeFile(profilePath, textWithBlock);
const result = await installer.removeProfileConfig();
expect(result).toBe(true);
// Verify BOM is preserved
const raw = await fs.readFile(profilePath);
expect(raw[0]).toBe(0xff);
expect(raw[1]).toBe(0xfe);
// Verify content: original line kept, OpenSpec block removed
const content = raw.subarray(2).toString('utf16le');
expect(content).toContain('. "C:\\Code\\profile.ps1"');
expect(content).not.toContain('# OPENSPEC:START');
expect(content).not.toContain('# OPENSPEC:END');
});
it('should preserve UTF-8 BOM when configuring profile', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
const profilePath = installer.getProfilePath();
await fs.mkdir(path.dirname(profilePath), { recursive: true });
await writeUtf8BomFile(profilePath, '# My profile\n');
const result = await installer.configureProfile(mockScriptPath);
expect(result).toBe(true);
const raw = await fs.readFile(profilePath);
expect(raw[0]).toBe(0xef);
expect(raw[1]).toBe(0xbb);
expect(raw[2]).toBe(0xbf);
const content = raw.subarray(3).toString('utf-8');
expect(content).toContain('# My profile');
expect(content).toContain('# OPENSPEC:START');
});
it('should skip UTF-16 BE profile and leave it unchanged', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
process.env.PROFILE = path.join(testHomeDir, 'custom-profile.ps1');
const profilePath = installer.getProfilePath();
await fs.mkdir(path.dirname(profilePath), { recursive: true });
// Write a fake UTF-16 BE file (FE FF BOM + some bytes)
const utf16beBom = Buffer.from([0xfe, 0xff]);
const body = Buffer.from([0x00, 0x23]); // '#' in UTF-16 BE
const originalBytes = Buffer.concat([utf16beBom, body]);
await fs.writeFile(profilePath, originalBytes);
const result = await installer.configureProfile(mockScriptPath);
expect(result).toBe(false);
// File should be untouched
const raw = await fs.readFile(profilePath);
expect(Buffer.compare(raw, originalBytes)).toBe(0);
});
it('should handle plain UTF-8 files without BOM (no regression)', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
const profilePath = installer.getProfilePath();
await fs.mkdir(path.dirname(profilePath), { recursive: true });
await fs.writeFile(profilePath, '# Plain UTF-8\n', 'utf-8');
const result = await installer.configureProfile(mockScriptPath);
expect(result).toBe(true);
const raw = await fs.readFile(profilePath);
// Should NOT have any BOM
expect(raw[0]).not.toBe(0xff);
expect(raw[0]).not.toBe(0xfe);
expect(raw[0]).not.toBe(0xef);
const content = raw.toString('utf-8');
expect(content).toContain('# Plain UTF-8');
expect(content).toContain('# OPENSPEC:START');
});
it('should round-trip UTF-16 LE through install → uninstall without corruption', async () => {
delete process.env.OPENSPEC_NO_AUTO_CONFIG;
const profilePath = installer.getProfilePath();
await fs.mkdir(path.dirname(profilePath), { recursive: true });
const originalText = '. "C:\\Code\\SystemConfig\\Powershell\\profile.ps1"\r\n';
await writeUtf16LeFile(profilePath, originalText);
// Install adds the OpenSpec block
const mockScript = '# completion script';
await installer.install(mockScript);
// Verify the profile was modified but encoding preserved
let raw = await fs.readFile(profilePath);
expect(raw[0]).toBe(0xff);
expect(raw[1]).toBe(0xfe);
let content = raw.subarray(2).toString('utf16le');
expect(content).toContain('# OPENSPEC:START');
expect(content).toContain(originalText.trimEnd());
// Uninstall removes the OpenSpec block
await installer.uninstall();
raw = await fs.readFile(profilePath);
expect(raw[0]).toBe(0xff);
expect(raw[1]).toBe(0xfe);
content = raw.subarray(2).toString('utf16le');
expect(content).not.toContain('# OPENSPEC:START');
expect(content).toContain('. "C:\\Code\\SystemConfig\\Powershell\\profile.ps1"');
});
});
describe('uninstall', () => {
const mockCompletionScript = `# PowerShell completion script
$openspecCompleter = {}
@@ -30,25 +30,25 @@ import {
import { generateSkillContent } from '../../../src/core/shared/skill-generation.js';
const EXPECTED_FUNCTION_HASHES: Record<string, string> = {
getExploreSkillTemplate: '55a2a1afcba0af88c638e77e4e3870f65ed82c030b4a2056d39812ae13a616be',
getExploreSkillTemplate: '3f73b4d7ab189ef6367fccc9d99308bee35c6a89dae4c8044582a01cb01b335b',
getNewChangeSkillTemplate: '5989672758eccf54e3bb554ab97f2c129a192b12bbb7688cc1ffcf6bccb1ae9d',
getContinueChangeSkillTemplate: 'f2e413f0333dfd6641cc2bd1a189273fdea5c399eecdde98ef528b5216f097b3',
getApplyChangeSkillTemplate: '26e52e67693e93fbcdd40dcd3e20949c07ce019183d55a8149d0260c791cd7f4',
getFfChangeSkillTemplate: 'a7332fb14c8dc3f9dec71f5d332790b4a8488191e7db4ab6132ccbefecf9ded9',
getSyncSpecsSkillTemplate: 'bded184e4c345619148de2c0ad80a5b527d4ffe45c87cc785889b9329e0f465b',
getOnboardSkillTemplate: '819a2d117ad1386187975686839cb0584b41484013d0ca6a6691f7a439a11a4a',
getOpsxExploreCommandTemplate: '91353d9e8633a3a9ce7339e796f1283478fca279153f3807c92f4f8ece246b19',
getOnboardSkillTemplate: 'c9e719a02d2ae7f74a0e978f9ad4e767c1921248a9e3724c3321c58a15c38ba9',
getOpsxExploreCommandTemplate: 'b421b88c7a532385f7b1404736d7893eb35a05573b4a04a96f72379ac1bbf148',
getOpsxNewCommandTemplate: '62eee32d6d81a376e7be845d0891e28e6262ad07482f9bfe6af12a9f0366c364',
getOpsxContinueCommandTemplate: '8bbaedcc95287f9e822572608137df4f49ad54cedfb08d3342d0d1c4e9716caa',
getOpsxApplyCommandTemplate: 'a9d631a07fcd832b67d263ff3800b98604ab8d378baf1b0d545907ef3affa3b5',
getOpsxFfCommandTemplate: 'cdebe872cc8e0fcc25c8864b98ffd66a93484c0657db94bd1285b8113092702a',
getArchiveChangeSkillTemplate: '6f8ca383fdb5a4eb9872aca81e07bf0ba7f25e4de8617d7a047ca914ca7f14b9',
getBulkArchiveChangeSkillTemplate: 'b40fc44ea4e420bdc9c803985b10e5c091fc472cdfc69153b962be6be303bddd',
getBulkArchiveChangeSkillTemplate: '8049897ce1ddb2ff6c0d4b72e22636f9ecfd083b5f2c2a30cf3bb1cb828a2f93',
getOpsxSyncCommandTemplate: '378d035fe7cc30be3e027b66dcc4b8afc78ef1c8369c39479c9b05a582fb5ccf',
getVerifyChangeSkillTemplate: '63a213ba3b42af54a1cd56f5072234a03b265c3fe4a1da12cd6fbbef5ee46c4b',
getOpsxArchiveCommandTemplate: 'b44cc9748109f61687f9f596604b037bc3ea803abc143b22f09a76aebd98b493',
getOpsxOnboardCommandTemplate: '10052d05a4e2cdade7fdfa549b3444f7a92f55a39bf81ddd6af7e0e9e83a7302',
getOpsxBulkArchiveCommandTemplate: 'eaaba253a950b9e681d8427a5cbc6b50c4e91137fb37fd2360859e08f63a0c14',
getOpsxOnboardCommandTemplate: 'fce531f952e939ee85a41848fc21e4cc720b0f3eb62737adc3a51ee6ad2dfc57',
getOpsxBulkArchiveCommandTemplate: '0d77c82de43840a28c74f5181cb21e33b9a9d00454adf4bc92bdc9e69817d6f5',
getOpsxVerifyCommandTemplate: '9b4d3ca422553b7534764eb3a009da87a051612c5238e9baab294c7b1233e9a2',
getOpsxProposeSkillTemplate: 'd67f937d44650e9c61d2158c865309fbab23cb3f50a3d4868a640a97776e3999',
getOpsxProposeCommandTemplate: '41ad59b37eafd7a161bab5c6e41997a37368f9c90b194451295ede5cd42e4d46',
@@ -56,16 +56,16 @@ const EXPECTED_FUNCTION_HASHES: Record<string, string> = {
};
const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record<string, string> = {
'openspec-explore': '90463d00761417dfbca5cb09361adcf8bbdbbb24000b86dd03647869a4104479',
'openspec-explore': '08e1ec9958eb04653707dd3e198c3fd69cf1b3acd3cf95a1022693cca83c60fc',
'openspec-new-change': 'c324a7ace1f244aa3f534ac8e3370a2c11190d6d1b85a315f26a211398310f0f',
'openspec-continue-change': '463cf0b980ec9c3c24774414ef2a3e48e9faa8577bc8748990f45ab3d5efe960',
'openspec-apply-change': 'a0084442b59be9d7e22a0382a279d470501e1ecf74bdd5347e169951c9be191c',
'openspec-ff-change': '672c3a5b8df152d959b15bd7ae2be7a75ab7b8eaa2ec1e0daa15c02479b27937',
'openspec-sync-specs': 'b8859cf454379a19ca35dbf59eedca67306607f44a355327f9dc851114e50bde',
'openspec-archive-change': 'f83c85452bd47de0dee6b8efbcea6a62534f8a175480e9044f3043f887cebf0f',
'openspec-bulk-archive-change': 'a235a539f7729ab7669e45256905808789240ecd02820e044f4d0eef67b0c2ab',
'openspec-bulk-archive-change': '10477399bb07c7ba67f78e315bd68fb1901af8866720545baf4c62a6a679493b',
'openspec-verify-change': '30d07c6f7051965f624f5964db51844ec17c7dfd05f0da95281fe0ca73616326',
'openspec-onboard': 'dbce376cf895f3fe4f63b4bce66d258c35b7b8884ac746670e5e35fabcefd255',
'openspec-onboard': 'c1444e026028210efd699110f7e9079bcb486d85ccf27f743213a81cb1084303',
'openspec-propose': '20e36dabefb90e232bad0667292bd5007ec280f8fc4fc995dbc4282bf45a22e7',
};
+1
View File
@@ -250,6 +250,7 @@ Old instructions content
expect(exists).toBe(false);
}
});
});
describe('multi-tool support', () => {