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
25 changed files with 545 additions and 103 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' },
};
+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 = {}
+1
View File
@@ -250,6 +250,7 @@ Old instructions content
expect(exists).toBe(false);
}
});
});
describe('multi-tool support', () => {