Compare commits

...
Author SHA1 Message Date
TabishB 7e5054f003 Add CLI path visibility guidance 2026-05-06 04:01:08 +10:00
8 changed files with 261 additions and 30 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@fission-ai/openspec": patch
---
### Bug Fixes
- **CLI path visibility**: OpenSpec now documents editor and agent PATH mismatches, warns during global installs when the detected CLI bin directory is not on PATH, and generates workflow skills with guidance for resolving `openspec` through `OPENSPEC_BIN` or an absolute path.
+38
View File
@@ -70,6 +70,44 @@ Or add to your development environment in `flake.nix`:
openspec --version
```
## Troubleshooting PATH Visibility
If `openspec --version` works in one terminal but fails in an editor, AI agent,
GUI app, or automation, OpenSpec is usually installed correctly but that process
started with a different `PATH`.
Global package managers create an executable shim in a bin directory, then your
shell or launcher must put that directory on `PATH`. Common ways to inspect the
directory are:
```bash
# npm
printf '%s/bin\n' "$(npm prefix -g)"
# pnpm
pnpm bin -g
# bun
bun pm bin -g
# current shell
command -v openspec
```
Make sure the environment that launches your editor, agent, GUI app, or
automation includes the package-manager bin directory. For shell startup files,
keep this to a minimal `PATH` export in a file that the target environment
actually reads. Do not move interactive setup such as prompts, themes,
completions, or commands that can block into always-loaded startup files.
To bypass global bin discovery while debugging, run OpenSpec through a package
manager:
```bash
npx -y @fission-ai/openspec@latest --version
pnpm dlx @fission-ai/openspec@latest --version
```
## Next Steps
After installing, initialize OpenSpec in your project:
+116 -4
View File
@@ -1,13 +1,13 @@
#!/usr/bin/env node
/**
* Postinstall script that hints about shell completions
* Postinstall script that hints about shell completions and CLI path visibility
*
* Completion installation is opt-in: the user must run
* `openspec completion install` explicitly. This script only
* prints a one-line tip after npm install.
* prints lightweight tips after npm install.
*
* The tip is suppressed when:
* The tips are suppressed when:
* - CI=true environment variable is set
* - OPENSPEC_NO_COMPLETIONS=1 environment variable is set
* - dist/ directory doesn't exist (dev setup scenario)
@@ -15,12 +15,121 @@
* The script never fails npm install - all errors are caught and handled gracefully.
*/
import { promises as fs } from 'fs';
import { constants as fsConstants, promises as fs } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const EXECUTABLE_NAMES = process.platform === 'win32'
? ['openspec.cmd', 'openspec.ps1', 'openspec']
: ['openspec'];
function getEnv(name) {
return process.env[name] || process.env[name.toUpperCase()];
}
function isTruthy(value) {
return value ? ['1', 'true', 'yes'].includes(value.toLowerCase()) : false;
}
function isLikelyGlobalInstall() {
return isTruthy(getEnv('npm_config_global')) || getEnv('npm_config_location') === 'global';
}
function normalizeForComparison(value) {
const resolved = path.resolve(value);
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
}
function pathEntries() {
return (process.env.PATH || '')
.split(path.delimiter)
.filter(Boolean)
.map(normalizeForComparison);
}
function isOnPath(dir) {
const normalizedDir = normalizeForComparison(dir);
return pathEntries().includes(normalizedDir);
}
function addCandidateDir(dirs, dir) {
if (!dir) return;
dirs.set(normalizeForComparison(dir), dir);
}
function getPrefixBinDir(prefix) {
return process.platform === 'win32' ? prefix : path.join(prefix, 'bin');
}
function getCandidateCliBinDirs() {
const dirs = new Map();
addCandidateDir(dirs, getEnv('npm_config_global_bin_dir'));
addCandidateDir(dirs, getEnv('npm_config_bin'));
addCandidateDir(dirs, getEnv('PNPM_HOME'));
const npmPrefix = getEnv('npm_config_prefix');
if (npmPrefix) {
addCandidateDir(dirs, getPrefixBinDir(npmPrefix));
}
const bunInstall = getEnv('BUN_INSTALL');
if (bunInstall) {
addCandidateDir(dirs, path.join(bunInstall, 'bin'));
}
return [...dirs.values()];
}
async function directoryHasOpenSpecBin(dir) {
for (const executableName of EXECUTABLE_NAMES) {
try {
await fs.access(path.join(dir, executableName), fsConstants.X_OK);
return true;
} catch {
// Continue checking other executable names.
}
}
return false;
}
async function getCliBinDirsMissingFromPath() {
if (!isLikelyGlobalInstall()) {
return [];
}
const missingDirs = [];
for (const dir of getCandidateCliBinDirs()) {
if (isOnPath(dir)) continue;
if (await directoryHasOpenSpecBin(dir)) {
missingDirs.push(dir);
}
}
return missingDirs;
}
function printPathVisibilityHint(missingDirs) {
if (missingDirs.length === 0) return;
console.log('');
console.log(
'OpenSpec was installed, but this shell may not find the CLI because these bin directories are not on PATH:'
);
for (const dir of missingDirs) {
console.log(` ${dir}`);
}
console.log('');
console.log(
'If `openspec --version` fails in an editor, agent, GUI app, or automation, add the relevant package-manager bin directory to the PATH used by that environment.'
);
console.log(
'See: https://github.com/Fission-AI/OpenSpec/blob/main/docs/installation.md#troubleshooting-path-visibility'
);
}
/**
* Check if we should skip installation
@@ -71,6 +180,9 @@ async function main() {
// Completions are opt-in — just print a hint
console.log(`\nTip: Run 'openspec completion install' for shell completions`);
const missingDirs = await getCliBinDirsMissingFromPath();
printPathVisibilityHint(missingDirs);
} catch (error) {
// Fail gracefully - never break npm install
}
+43 -5
View File
@@ -1,7 +1,7 @@
#!/bin/bash
# Test script for postinstall.js
# Tests different scenarios: normal install, CI, opt-out
# Tests different scenarios: normal install, CI, opt-out, PATH hints
set -e
@@ -13,6 +13,10 @@ echo ""
# Save original environment
ORIGINAL_CI="${CI:-}"
ORIGINAL_OPENSPEC_NO_COMPLETIONS="${OPENSPEC_NO_COMPLETIONS:-}"
ORIGINAL_NPM_CONFIG_GLOBAL="${npm_config_global:-}"
ORIGINAL_NPM_CONFIG_PREFIX="${npm_config_prefix:-}"
ORIGINAL_PATH="$PATH"
NODE_BIN="$(command -v node)"
# Test 1: Normal install
echo "Test 1: Normal install (should print tip about completions)"
@@ -22,16 +26,36 @@ unset OPENSPEC_NO_COMPLETIONS
node scripts/postinstall.js
echo ""
# Test 2: CI environment (should skip silently)
echo "Test 2: CI=true (should skip silently)"
# Test 2: Global install with CLI bin missing from PATH (should print PATH hint)
echo "Test 2: Global install with CLI bin missing from PATH (should print PATH hint)"
echo "--------------------------------------"
TMP_PREFIX="$(mktemp -d)"
TMP_HOME="$(mktemp -d)"
mkdir -p "$TMP_PREFIX/bin"
printf '#!/bin/sh\nexit 0\n' > "$TMP_PREFIX/bin/openspec"
chmod +x "$TMP_PREFIX/bin/openspec"
unset CI
unset OPENSPEC_NO_COMPLETIONS
export npm_config_global=true
export npm_config_prefix="$TMP_PREFIX"
HOME="$TMP_HOME" PNPM_HOME="$TMP_HOME/no-pnpm" PATH="/usr/bin:/bin" "$NODE_BIN" scripts/postinstall.js
rm -rf "$TMP_PREFIX"
rm -rf "$TMP_HOME"
unset npm_config_global
unset npm_config_prefix
export PATH="$ORIGINAL_PATH"
echo ""
# Test 3: CI environment (should skip silently)
echo "Test 3: CI=true (should skip silently)"
echo "--------------------------------------"
export CI=true
node scripts/postinstall.js
echo "[No output expected - skipped due to CI]"
echo ""
# Test 3: Opt-out flag (should skip silently)
echo "Test 3: OPENSPEC_NO_COMPLETIONS=1 (should skip silently)"
# Test 4: Opt-out flag (should skip silently)
echo "Test 4: OPENSPEC_NO_COMPLETIONS=1 (should skip silently)"
echo "--------------------------------------"
unset CI
export OPENSPEC_NO_COMPLETIONS=1
@@ -52,6 +76,20 @@ else
unset OPENSPEC_NO_COMPLETIONS
fi
if [ -n "$ORIGINAL_NPM_CONFIG_GLOBAL" ]; then
export npm_config_global="$ORIGINAL_NPM_CONFIG_GLOBAL"
else
unset npm_config_global
fi
if [ -n "$ORIGINAL_NPM_CONFIG_PREFIX" ]; then
export npm_config_prefix="$ORIGINAL_NPM_CONFIG_PREFIX"
else
unset npm_config_prefix
fi
export PATH="$ORIGINAL_PATH"
echo "======================================"
echo "All tests completed successfully!"
echo "======================================"
+13 -2
View File
@@ -31,6 +31,13 @@ import {
} from '../templates/skill-templates.js';
import type { CommandContent } from '../command-generation/index.js';
const DEFAULT_COMPATIBILITY = 'Requires openspec CLI.';
const OPENSPEC_CLI_VISIBILITY_GUIDANCE = `## OpenSpec CLI Visibility
The workflow below uses the \`openspec\` command. If this agent shell cannot find it, do not assume OpenSpec is absent; editor, agent, GUI, and automation shells may inherit a different \`PATH\` than the user's terminal.
Use \`OPENSPEC_BIN\` when it is set, otherwise start with \`openspec\`. If that command is not found, resolve the package-manager global bin directory or ask the user for the absolute executable path, then use that path for every \`openspec\` invocation in this workflow.`;
/**
* Skill template with directory name and workflow ID mapping.
*/
@@ -129,21 +136,25 @@ export function generateSkillContent(
generatedByVersion: string,
transformInstructions?: (instructions: string) => string
): string {
const compatibility = template.compatibility || DEFAULT_COMPATIBILITY;
const instructions = transformInstructions
? transformInstructions(template.instructions)
: template.instructions;
const body = compatibility.toLowerCase().includes('openspec cli')
? `${OPENSPEC_CLI_VISIBILITY_GUIDANCE}\n\n${instructions}`
: instructions;
return `---
name: ${template.name}
description: ${template.description}
license: ${template.license || 'MIT'}
compatibility: ${template.compatibility || 'Requires openspec CLI.'}
compatibility: ${compatibility}
metadata:
author: ${template.metadata?.author || 'openspec'}
version: "${template.metadata?.version || '1.0'}"
generatedBy: "${generatedByVersion}"
---
${instructions}
${body}
`;
}
+13 -6
View File
@@ -24,19 +24,26 @@ function getOnboardInstructions(): string {
## Preflight
Before starting, check if the OpenSpec CLI is installed:
Before starting, check whether the OpenSpec CLI is visible to this shell:
\`\`\`bash
# Unix/macOS
openspec --version 2>&1 || echo "CLI_NOT_INSTALLED"
OPENSPEC_CMD="\${OPENSPEC_BIN:-openspec}"
if command -v "$OPENSPEC_CMD" >/dev/null 2>&1 || [ -x "$OPENSPEC_CMD" ]; then
"$OPENSPEC_CMD" --version
else
echo "CLI_NOT_ON_PATH"
fi
# Windows (PowerShell)
# if (Get-Command openspec -ErrorAction SilentlyContinue) { openspec --version } else { echo "CLI_NOT_INSTALLED" }
# $OpenSpecCmd = if ($env:OPENSPEC_BIN) { $env:OPENSPEC_BIN } else { "openspec" }
# if (Get-Command $OpenSpecCmd -ErrorAction SilentlyContinue) { & $OpenSpecCmd --version } else { echo "CLI_NOT_ON_PATH" }
\`\`\`
**If CLI not installed:**
> OpenSpec CLI is not installed. Install it first, then come back to \`/opsx:onboard\`.
**If CLI is not visible:**
> OpenSpec CLI is not visible to this shell. If it is installed in another terminal, add its package-manager bin directory to the PATH used by this editor, agent, or automation, or set OPENSPEC_BIN to the absolute executable path.
Stop here if not installed.
Stop here if no usable CLI command can be found.
---
+18
View File
@@ -225,6 +225,23 @@ describe('skill-generation', () => {
expect(content).toContain('author: openspec');
expect(content).toContain('version: "1.0"');
expect(content).toContain('generatedBy: "0.24.0"');
expect(content).toContain('## OpenSpec CLI Visibility');
expect(content).toContain('OPENSPEC_BIN');
});
it('should prepend CLI visibility guidance for OpenSpec CLI skills', () => {
const template = {
name: 'cli-skill',
description: 'Uses the CLI',
instructions: 'Run openspec list.',
compatibility: 'Requires openspec CLI.',
};
const content = generateSkillContent(template, '0.24.0');
expect(content).toContain('## OpenSpec CLI Visibility');
expect(content).toContain('agent, GUI, and automation shells may inherit a different `PATH`');
expect(content).toContain('Run openspec list.');
});
it('should embed the provided version in generatedBy field', () => {
@@ -249,6 +266,7 @@ describe('skill-generation', () => {
name: 'test',
description: 'Test',
instructions: 'Body content',
compatibility: 'Standalone test fixture',
};
const content = generateSkillContent(template, '0.23.0');
@@ -36,7 +36,7 @@ const EXPECTED_FUNCTION_HASHES: Record<string, string> = {
getApplyChangeSkillTemplate: '6238712ba8cd2fd099c4f3bac13436f758fc6ac776fb8be19547f2b195240bfd',
getFfChangeSkillTemplate: 'a7332fb14c8dc3f9dec71f5d332790b4a8488191e7db4ab6132ccbefecf9ded9',
getSyncSpecsSkillTemplate: 'bded184e4c345619148de2c0ad80a5b527d4ffe45c87cc785889b9329e0f465b',
getOnboardSkillTemplate: 'c9e719a02d2ae7f74a0e978f9ad4e767c1921248a9e3724c3321c58a15c38ba9',
getOnboardSkillTemplate: 'b78d8479da5dc92432d81a6d29c56b6cbfed723a0750c7868a07966d58d47f76',
getOpsxExploreCommandTemplate: 'b421b88c7a532385f7b1404736d7893eb35a05573b4a04a96f72379ac1bbf148',
getOpsxNewCommandTemplate: '62eee32d6d81a376e7be845d0891e28e6262ad07482f9bfe6af12a9f0366c364',
getOpsxContinueCommandTemplate: '8bbaedcc95287f9e822572608137df4f49ad54cedfb08d3342d0d1c4e9716caa',
@@ -47,7 +47,7 @@ const EXPECTED_FUNCTION_HASHES: Record<string, string> = {
getOpsxSyncCommandTemplate: '378d035fe7cc30be3e027b66dcc4b8afc78ef1c8369c39479c9b05a582fb5ccf',
getVerifyChangeSkillTemplate: '40dde29051a0ba204295b74e49e87b6e9ff30c8b89ff0e791b4f955b4595de59',
getOpsxArchiveCommandTemplate: 'b44cc9748109f61687f9f596604b037bc3ea803abc143b22f09a76aebd98b493',
getOpsxOnboardCommandTemplate: 'fce531f952e939ee85a41848fc21e4cc720b0f3eb62737adc3a51ee6ad2dfc57',
getOpsxOnboardCommandTemplate: '34c3081988e4a890adad5efb7c6474a3ddca96d9ed2d28a9daac7b4f182a5735',
getOpsxBulkArchiveCommandTemplate: '0d77c82de43840a28c74f5181cb21e33b9a9d00454adf4bc92bdc9e69817d6f5',
getOpsxVerifyCommandTemplate: 'd7c0444863faabb16abb091bc40ee56d985ae4bfa9a4db1e622ca8ba03c32fed',
getOpsxProposeSkillTemplate: 'd67f937d44650e9c61d2158c865309fbab23cb3f50a3d4868a640a97776e3999',
@@ -56,17 +56,17 @@ const EXPECTED_FUNCTION_HASHES: Record<string, string> = {
};
const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record<string, string> = {
'openspec-explore': '08e1ec9958eb04653707dd3e198c3fd69cf1b3acd3cf95a1022693cca83c60fc',
'openspec-new-change': 'c324a7ace1f244aa3f534ac8e3370a2c11190d6d1b85a315f26a211398310f0f',
'openspec-continue-change': '463cf0b980ec9c3c24774414ef2a3e48e9faa8577bc8748990f45ab3d5efe960',
'openspec-apply-change': '38ad2cb645827eda555f20e1ac9d483e1d75bae4c817c0669474aaa8c12c0421',
'openspec-ff-change': '672c3a5b8df152d959b15bd7ae2be7a75ab7b8eaa2ec1e0daa15c02479b27937',
'openspec-sync-specs': 'b8859cf454379a19ca35dbf59eedca67306607f44a355327f9dc851114e50bde',
'openspec-archive-change': 'f83c85452bd47de0dee6b8efbcea6a62534f8a175480e9044f3043f887cebf0f',
'openspec-bulk-archive-change': '10477399bb07c7ba67f78e315bd68fb1901af8866720545baf4c62a6a679493b',
'openspec-verify-change': 'b6dc1b87940be9d6125b834831c8619019aec9a9748995f72bf981b6f08b67f8',
'openspec-onboard': 'c1444e026028210efd699110f7e9079bcb486d85ccf27f743213a81cb1084303',
'openspec-propose': '20e36dabefb90e232bad0667292bd5007ec280f8fc4fc995dbc4282bf45a22e7',
'openspec-explore': 'ad1178ca328ee6c4a44c42d5a39bb115fc0708778feded3339f35ed7bd82800e',
'openspec-new-change': 'f555ab9b1385e78e5d96e9e08bfe7c2035449c7349be21669e671aefbd0d7d4a',
'openspec-continue-change': 'a9e827be428a5f300aa3ff4a379403a7a3ac378ce0b790c99c0fbe2e14404625',
'openspec-apply-change': '8f8e6594ed8dffa854a2c3373b2674c814419063f0bb9ab79d98c5e18fd21aa6',
'openspec-ff-change': 'a61b501beb63dd625dd1b7c46243705c2746005a7673b2120a2cbca36d9eb831',
'openspec-sync-specs': '2075928ed531ab226bd3a05c67d386226eb0a3ec71e08f708e4837feeeb41a29',
'openspec-archive-change': '66fdcdd43c2a51d70823cf4d3d1eb174273b9350dd651b1113b5458b4ec81a99',
'openspec-bulk-archive-change': '7aa32f345f7b44d52619b4efa92589e0ab3cd671ae559bac1c156bb3251cda2b',
'openspec-verify-change': '5ea8266080e006319a604e5deac6f4ecb3a4d0e03c3e77b99897d3b62f70892e',
'openspec-onboard': '48511ff0e8aec12784dba58ec94f2ad61b1bbc8de4f73b47ca60ecea788a6db9',
'openspec-propose': '217b1ca83dc520207b4da584d3699791ece48a24eb164b524a1320189f61549b',
};
function stableStringify(value: unknown): string {