mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-15 05:30:21 +08:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b5007dbc3 | ||
|
|
cce787ec40 | ||
|
|
94d651de8c | ||
|
|
040e382d64 | ||
|
|
caafd7c9bf | ||
|
|
144528257d | ||
|
|
af0b3418d0 | ||
|
|
7fd5417ed0 | ||
|
|
5ac1e12b83 |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -156,3 +156,6 @@ opencode.json
|
||||
|
||||
# Codex
|
||||
.codex/
|
||||
|
||||
# Bob
|
||||
.bob/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+13
-2
@@ -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
@@ -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
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
`;
|
||||
},
|
||||
};
|
||||
@@ -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)}
|
||||
`;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -250,6 +250,7 @@ Old instructions content
|
||||
expect(exists).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('multi-tool support', () => {
|
||||
|
||||
Reference in New Issue
Block a user