Merge remote-tracking branch 'origin/next' into fix/1477-surface-source-marker

# Conflicts:
#	bin/install.js
This commit is contained in:
Joe Slitzker
2026-06-25 09:48:15 -05:00
284 changed files with 13704 additions and 1352 deletions

View File

@@ -630,6 +630,15 @@ const processAttribution = runtimeArtifactConversion.processAttribution;
const computePathPrefix = runtimeArtifactConversion._computePathPrefix;
const applyRuntimeContentRewritesInPlace = runtimeArtifactConversion.applyRuntimeContentRewritesInPlace;
const applyRuntimeContentRewritesForCommandsInPlace = runtimeArtifactConversion.applyRuntimeContentRewritesForCommandsInPlace;
// #1675 (ADR-1508): the augment converter family is single-sourced in the
// conversion module. install.js re-binds (does not re-define) these so there
// is exactly one body — the generative-drift hazard the dedup removes. The two
// private helpers (getAugmentSkillAdapterHeader, convertSlashCommandsToAugmentSkillMentions)
// live only in the conversion module now; they are no longer duplicated here.
// (All call sites are below this line → no TDZ hazard.)
const convertClaudeToAugmentMarkdown = runtimeArtifactConversion.convertClaudeToAugmentMarkdown;
const convertClaudeCommandToAugmentSkill = runtimeArtifactConversion.convertClaudeCommandToAugmentSkill;
const convertClaudeAgentToAugmentAgent = runtimeArtifactConversion.convertClaudeAgentToAugmentAgent;
function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
return hooksSurface.rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts);
@@ -2457,20 +2466,18 @@ function convertClaudeToWindsurfMarkdown(content) {
// Replace subagent_type from Claude to Windsurf format
converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="generalPurpose"');
converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}');
// Replace project-level Claude conventions with Windsurf/Devin equivalents
// Workspace skills install to .devin/ (Devin Desktop preferred dir, #1085).
// Legacy .windsurf/ is still recognized on read but new installs use .devin/.
converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.devin/rules`');
converted = converted.replace(/\.\/CLAUDE\.md/g, '.devin/rules');
converted = converted.replace(/`CLAUDE\.md`/g, '`.devin/rules`');
converted = converted.replace(/\bCLAUDE\.md\b/g, '.devin/rules');
converted = converted.replace(/\.claude\/skills\//g, '.devin/skills/');
converted = converted.replace(/\.\/\.claude\//g, './.devin/');
converted = converted.replace(/\.claude\//g, '.devin/');
// Replace project-level Claude conventions with Windsurf equivalents.
converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.windsurf/rules`');
converted = converted.replace(/\.\/CLAUDE\.md/g, '.windsurf/rules');
converted = converted.replace(/`CLAUDE\.md`/g, '`.windsurf/rules`');
converted = converted.replace(/\bCLAUDE\.md\b/g, '.windsurf/rules');
converted = converted.replace(/\.claude\/skills\//g, '.windsurf/skills/');
converted = converted.replace(/\.\/\.claude\//g, './.windsurf/');
converted = converted.replace(/\.claude\//g, '.windsurf/');
// Bare forms (no trailing slash) — after slash forms to avoid double-rewrite.
// Use negative lookahead (?![\w-]) to preserve .claude-plugin and .claudeignore.
converted = converted.replace(/~\/\.claude(?![\w-])/g, '~/.devin');
converted = converted.replace(/\$HOME\/\.claude(?![\w-])/g, '$HOME/.devin');
converted = converted.replace(/~\/\.claude(?![\w-])/g, '~/.windsurf');
converted = converted.replace(/\$HOME\/\.claude(?![\w-])/g, '$HOME/.windsurf');
// Environment variable name rewrite
converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'WINDSURF_CONFIG_DIR');
// Remove Claude Code-specific bug workarounds before brand replacement
@@ -2524,6 +2531,33 @@ function convertClaudeCommandToWindsurfSkill(content, skillName) {
return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
}
function convertClaudeCommandToWindsurfWorkflow(content, commandName) {
// #1615 security: commandName flows unsanitized into a markdown body that
// Windsurf loads as an LLM-readable workflow. Validate at entry to prevent
// (a) prompt injection via newlines / markdown structure in the filename,
// (b) path-component injection via .., /, \ in stem → @-reference target.
// Pattern: optional gsd- prefix + lowercase alphanumeric + dashes; rejects
// everything else. See DEFECT.PROMPT-INJECTION-SCAN-COLLISION and the
// PR #1622 security review.
if (typeof commandName !== 'string' || !/^(?:gsd-)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(commandName)) {
const preview = typeof commandName === 'string' ? JSON.stringify(commandName.slice(0, 60)) : String(commandName);
throw new Error(
`convertClaudeCommandToWindsurfWorkflow: rejected commandName ${preview}; ` +
'must match /^(?:gsd-)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/ (no slashes, backslashes, spaces, dots, trailing dash, or control chars — prevents prompt injection and path-component injection into the workflow body)'
);
}
const converted = convertClaudeToWindsurfMarkdown(content);
const { frontmatter } = extractFrontmatterAndBody(converted);
const description = frontmatter ? extractFrontmatterField(frontmatter, 'description') : '';
const stem = commandName.startsWith('gsd-') ? commandName.slice(4) : commandName;
const workflow = `# ${commandName}\n\n${toSingleLine(description || `Run ${commandName}.`)}\n\nRead and execute the GSD command at @~/.claude/gsd-core/commands/gsd/${stem}.md end-to-end. Treat the user's message after /${commandName} as the command arguments.`;
const byteLength = Buffer.byteLength(workflow, 'utf8');
if (byteLength > 12000) {
throw new Error(`Windsurf workflow ${commandName} exceeds 12000 bytes (${byteLength}); extract references before installing`);
}
return workflow;
}
/**
* Convert Claude Code agent markdown to Windsurf agent format.
* Strips frontmatter fields Windsurf doesn't support (color, skills),
@@ -2555,105 +2589,14 @@ const claudeToAugmentTools = {
TodoWrite: 'add_tasks',
};
function convertSlashCommandsToAugmentSkillMentions(content) {
return content.replace(/gsd:/gi, 'gsd-');
}
function convertClaudeToAugmentMarkdown(content) {
let converted = convertSlashCommandsToAugmentSkillMentions(content);
converted = converted.replace(/\bBash\(/g, 'launch-process(');
converted = converted.replace(/\bEdit\(/g, 'str-replace-editor(');
converted = converted.replace(/\bRead\(/g, 'view(');
converted = converted.replace(/\bWrite\(/g, 'save-file(');
converted = converted.replace(/\bTodoWrite\(/g, 'add_tasks(');
converted = converted.replace(/\bAskUserQuestion\b/g, 'conversational prompting');
// Replace subagent_type from Claude to Augment format
converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="generalPurpose"');
converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}');
// Replace project-level Claude conventions with Augment equivalents
converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.augment/rules/`');
converted = converted.replace(/\.\/CLAUDE\.md/g, '.augment/rules/');
converted = converted.replace(/`CLAUDE\.md`/g, '`.augment/rules/`');
converted = converted.replace(/\bCLAUDE\.md\b/g, '.augment/rules/');
converted = converted.replace(/\.claude\/skills\//g, '.augment/skills/');
// Remove Claude Code-specific bug workarounds before brand replacement
converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, '');
converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, '');
// Replace "Claude Code" brand references with "Augment"
converted = converted.replace(/\bClaude Code\b/g, 'Augment');
return converted;
}
function getAugmentSkillAdapterHeader(skillName) {
return `<augment_skill_adapter>
## A. Skill Invocation
- This skill is invoked when the user mentions \`${skillName}\` or describes a task matching this skill.
- Treat all user text after the skill mention as \`{{GSD_ARGS}}\`.
- If no arguments are present, treat \`{{GSD_ARGS}}\` as empty.
## B. User Prompting
When the workflow needs user input, prompt the user conversationally:
- Present options as a numbered list in your response text
- Ask the user to reply with their choice
- For multi-select, ask for comma-separated numbers
## C. Tool Usage
Use these Augment tools when executing GSD workflows:
- \`launch-process\` for running commands (terminal operations)
- \`str-replace-editor\` for editing existing files
- \`view\` for reading files and listing directories
- \`save-file\` for creating new files
- \`grep\` for searching code (or use MCP servers for advanced search)
- \`web-search\`, \`web-fetch\` for web queries
- \`add_tasks\`, \`view_tasklist\`, \`update_tasks\` for task management
## D. Subagent Spawning
When the workflow needs to spawn a subagent:
- Use the built-in subagent spawning capability
- Define agent prompts in \`.augment/agents/\` directory
</augment_skill_adapter>`;
}
function convertClaudeCommandToAugmentSkill(content, skillName) {
const converted = convertClaudeToAugmentMarkdown(content);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
let description = `Run GSD workflow ${skillName}.`;
if (frontmatter) {
const maybeDescription = extractFrontmatterField(frontmatter, 'description');
if (maybeDescription) {
description = maybeDescription;
}
}
description = toSingleLine(description);
const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description;
const adapter = getAugmentSkillAdapterHeader(skillName);
return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
}
/**
* Convert Claude Code agent markdown to Augment agent format.
* Strips frontmatter fields Augment doesn't support (color, skills),
* converts tool references, and cleans up for Augment agents.
*/
function convertClaudeAgentToAugmentAgent(content) {
let converted = convertClaudeToAugmentMarkdown(content);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
if (!frontmatter) return converted;
const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
const description = extractFrontmatterField(frontmatter, 'description') || '';
const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`;
return `${cleanFrontmatter}\n${body}`;
}
/**
* Copy Claude commands as Augment skills — one folder per skill with SKILL.md.
* Mirrors copyCommandsAsCursorSkills but uses Augment converters.
*/
// #1675 (ADR-1508): the augment converter family below was a byte-identical
// duplicate of runtime-artifact-conversion.cjs:
// convertSlashCommandsToAugmentSkillMentions, convertClaudeToAugmentMarkdown,
// getAugmentSkillAdapterHeader, convertClaudeCommandToAugmentSkill,
// convertClaudeAgentToAugmentAgent
// Deleted here and bound from runtimeArtifactConversion above (single source).
// The DEFECT.GENERATIVE-FIX parity guard in
// tests/enh-1511-rewrite-engine-relocation.test.cjs asserts reference identity.
function convertSlashCommandsToTraeSkillMentions(content) {
return content.replace(/\/gsd:([a-z0-9-]+)/g, (_, commandName) => {
@@ -3229,6 +3172,66 @@ function cleanupCodexSkillMetadataSidecars(skillsDir) {
}
}
/**
* Remove legacy Windsurf skill artifacts from .devin/skills/gsd- directories.
*
* Pre-#1615 Windsurf installs wrote skills under .devin/ (Devin Desktop
* preferred dir, #1085). #1615 moved Windsurf to .windsurf/workflows/.
* Old .devin/skills/gsd- dirs linger on disk indefinitely and confuse
* users who see two GSD trees.
*
* Preserves user-owned content:
* - non-gsd-* dirs under .devin/skills/ (user-authored skills)
* - gsd-dev-preferences/ (user-owned per #2973)
* - any files (not dirs) under .devin/skills/
*
* @param {string} workspaceDir - workspace root (process.cwd() for local installs)
* @returns {number} count of removed legacy gsd-* skill directories
*/
function cleanupWindsurfLegacyDevinSkills(workspaceDir) {
const legacySkillsDir = path.join(workspaceDir, '.devin', 'skills');
if (!fs.existsSync(legacySkillsDir)) return 0;
// Mirror the user-owned list from cleanupCodexSkillMetadataSidecars (#2973).
const _userOwnedSkillDirs = new Set(['gsd-dev-preferences']);
let removed = 0;
for (const entry of fs.readdirSync(legacySkillsDir, { withFileTypes: true })) {
if (!entry.isDirectory() || !entry.name.startsWith('gsd-')) continue;
if (_userOwnedSkillDirs.has(entry.name)) continue;
const dirToRemove = path.join(legacySkillsDir, entry.name);
try {
// Symlink guard: if the gsd-* dir is itself a symlink pointing outside
// the .devin tree, deleting through it could escape the tree. Skip.
const stat = fs.lstatSync(dirToRemove);
if (stat.isSymbolicLink()) continue;
fs.rmSync(dirToRemove, { recursive: true, force: true });
removed++;
} catch (_err) {
// Fail open — a single bad dir must not block the install.
}
}
// If .devin/skills/ is now empty, prune it. If .devin/ itself is then empty,
// prune that too — leaves the workspace clean for the new .windsurf/ layout.
// Never remove non-empty containers (user may have other Devin content).
try {
if (fs.existsSync(legacySkillsDir) && fs.readdirSync(legacySkillsDir).length === 0) {
fs.rmdirSync(legacySkillsDir);
const devinDir = path.join(workspaceDir, '.devin');
if (fs.existsSync(devinDir) && fs.readdirSync(devinDir).length === 0) {
fs.rmdirSync(devinDir);
}
}
} catch (_err) {
// best-effort container cleanup
}
return removed;
}
/**
* Generate the GSD config block for Codex config.toml.
* @param {Array<{name: string, description: string}>} agents
@@ -9598,6 +9601,17 @@ function install(isGlobal, runtime = 'claude', options = {}) {
cleanupCodexSkillMetadataSidecars(path.join(targetDir, 'skills'));
}
// #1629 Finding B: Windsurf local only — remove legacy .devin/skills/gsd-*
// dirs from pre-#1615 installs. #1615 moved Windsurf to .windsurf/workflows/
// but never cleaned up the old .devin/skills/ layout (#1085). User-owned
// content is preserved (non-gsd- dirs, gsd-dev-preferences, symlinks).
if (isWindsurf && !isGlobal) {
const removedCount = cleanupWindsurfLegacyDevinSkills(process.cwd());
if (removedCount > 0) {
console.log(` ${green}✓${reset} Removed ${removedCount} legacy .devin/skills/gsd-* dir(s) (pre-#1615 Windsurf layout)`);
}
}
// Hermes only: write DESCRIPTION.md for the gsd/ category after layout install
if (isHermes) {
writeHermesCategoryDescription(path.join(targetDir, 'skills', 'gsd'));
@@ -9638,6 +9652,23 @@ function install(isGlobal, runtime = 'claude', options = {}) {
} else {
failures.push('agents/gsd.yaml');
}
} else if (isWindsurf) {
if (isGlobal) {
console.log(` ${green}✓${reset} Windsurf global install skipped workflow artifacts (workspace-only)`);
} else {
const workflowsDir = path.join(targetDir, 'workflows');
if (fs.existsSync(workflowsDir)) {
const workflowCount = fs.readdirSync(workflowsDir)
.filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length;
if (workflowCount > 0) {
console.log(` ${green}✓${reset} Installed ${workflowCount} workflows to workflows/`);
} else {
failures.push('workflows/gsd-*');
}
} else {
failures.push('workflows/gsd-*');
}
}
} else {
const skillsDir = path.join(targetDir, 'skills');
if (fs.existsSync(skillsDir)) {
@@ -9868,16 +9899,14 @@ function install(isGlobal, runtime = 'claude', options = {}) {
// contexts,references,templates,workflows} but NOT the commands/gsd source
// tree, and _runLegacyUninstallCleanup actively removes any commands/gsd/
// for that scope — so findInstallSourceRoot's walk-up has nothing to find
// and /gsd-surface (list/status and the write subcommands) throws. This is
// the writer half of the marker that runtime-artifact-layout.cjs's finders
// already read (the reader landed in #1476). It points at the package's own
// commands/gsd source, whose parent also holds bin/install.js — the path
// loadInstallExports derives the installer exports from. Scoped to the
// Claude-global layout (issue #1477) — the only install path that ships the
// skills layout without a commands/gsd source tree; every other runtime/scope
// deploys commands/gsd, so its walk-up already resolves and needs no marker.
// Guarded on source presence so a half-published package never writes a
// dangling marker.
// and /gsd-surface (list/status) throws. This is the writer half of the
// marker that runtime-artifact-layout.cjs's finders already read (the reader
// landed in #1476). It points at the package's own commands/gsd source.
// Scoped to the Claude-global layout (issue #1477) — the only install path
// that ships the skills layout without a commands/gsd source tree; every
// other runtime/scope deploys commands/gsd, so its walk-up already resolves
// and needs no marker. Guarded on source presence so a half-published
// package never writes a dangling marker.
if (runtime === 'claude' && isGlobal) {
const gsdSourceCommands = path.join(src, 'commands', 'gsd');
if (fs.existsSync(gsdSourceCommands)) {
@@ -9887,6 +9916,23 @@ function install(isGlobal, runtime = 'claude', options = {}) {
}
}
// #1629 critical fix: Windsurf workflow wrappers (convertClaudeCommandToWindsurfWorkflow)
// delegate to command bodies at <targetDir>/gsd-core/commands/gsd/${stem}.md via a
// hardcoded @~/.claude/gsd-core/commands/gsd/ path that _applyRuntimeRewrites rewrites
// to the install target. The source gsd-core/ dir does NOT ship with commands/ —
// the canonical command source lives at the package root (commands/gsd/). Without
// this copy, every /gsd-* workflow in Cascade references a missing file and the LLM
// cannot execute the command body. Surfaced by the #1629 regression test after the
// original adversarial review of #1622 missed it.
if (isWindsurf && !isGlobal) {
const commandsSrc = path.join(src, 'commands', 'gsd');
const commandsDest = path.join(skillDest, 'commands', 'gsd');
if (fs.existsSync(commandsSrc)) {
copyWithPathReplacement(commandsSrc, commandsDest, pathPrefix, runtime, true, isGlobal);
console.log(` ${green}✓${reset} Installed command bodies to gsd-core/commands/gsd/ (workflow delegation targets)`);
}
}
// Copy shared manifests into the gsd-core payload
// at the co-located path that CJS modules resolve first:
// gsd-core/bin/shared/*.json
@@ -11201,10 +11247,14 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
configureKiloPermissions(isGlobal, configDir);
}
// For non-Claude runtimes, set resolve_model_ids: "omit" in ~/.gsd/defaults.json
// so resolveModelInternal() returns '' instead of Claude aliases (opus/sonnet/haiku)
// that the runtime can't resolve. Users can still use model_overrides for explicit IDs.
// See #1156. Guard matches the #130-class pattern on configureOpencodePermissions above.
// For non-Claude runtimes, DEFAULT resolve_model_ids to "omit" in ~/.gsd/defaults.json
// when it is absent or falsy, so resolveModelInternal() returns '' instead of Claude
// aliases (opus/sonnet/haiku) the runtime can't resolve. An explicit `true` opt-in
// (resolveModelInternal returns full materialized model IDs) MUST be preserved —
// rewriting it to "omit" would make generated agent manifests inherit the active
// chat model instead of pinning the resolved model. See #1156 (default-to-omit
// intent) and #1569 (preserve explicit true). Guard matches the #130-class pattern
// on configureOpencodePermissions above.
if (runtime !== 'claude' && !process.env.GSD_TEST_MODE) {
const gsdDir = path.join(os.homedir(), '.gsd');
const defaultsPath = path.join(gsdDir, 'defaults.json');
@@ -11212,7 +11262,22 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
fs.mkdirSync(gsdDir, { recursive: true });
let defaults = {};
try { defaults = JSON.parse(fs.readFileSync(defaultsPath, 'utf8')); } catch { /* new file */ }
if (defaults.resolve_model_ids !== 'omit') {
// Recover a malformed (valid-JSON-but-non-object) defaults.json to a fresh object so
// the write below succeeds and the file is no longer broken. Without this, `null` /
// `[]` / a number / a string bypass the parse catch and either throw a TypeError on
// property access (swallowed by the outer try/catch, leaving the file broken) or get
// a property set that won't round-trip through JSON.stringify. (#1657)
if (defaults === null || typeof defaults !== 'object' || Array.isArray(defaults)) {
defaults = {};
}
// Three-valued domain: false/absent → aliases; true → full IDs; "omit" → ''.
// Honor ONLY an explicit canonical `true` opt-in (full model IDs) and an existing
// "omit"; default everything else — absent, falsy, OR any non-canonical value — to
// "omit", the safe non-Claude default. Allowlist-based so malformed values
// (0, "", "yes", {}, …) don't leak Claude aliases the runtime can't resolve (#1569).
const existing = defaults.resolve_model_ids;
const shouldDefaultToOmit = existing !== true && existing !== 'omit';
if (shouldDefaultToOmit) {
defaults.resolve_model_ids = 'omit';
fs.writeFileSync(defaultsPath, JSON.stringify(defaults, null, 2) + '\n');
console.log(` ${green}✓${reset} Set resolve_model_ids: "omit" in ~/.gsd/defaults.json`);
@@ -11668,6 +11733,189 @@ function homePathCoveredByRc(globalBin, homeDir, rcFileNames) {
return false;
}
/**
* Decode fish's universal-variable value escaping (the inverse of fish's
* `full_escape`). fish serializes every non-`[A-Za-z0-9/_]` byte in
* `fish_variables` — e.g. space -> `\x20`, hyphen -> `\x2d`, dot -> `\x2e` —
* and joins list elements with the literal 4-char token `\x1e` (NOT a raw
* 0x1e byte). Callers split on `\x1e` first, then decode each element here.
*
* Pure and total: any unrecognised `\`-sequence is passed through verbatim,
* so `decode(fishEscape(p)) === p` holds for every path string. Exported for
* a fast-check round-trip property test (#323).
*
* @param {string} s A single (already `\x1e`-split) escaped value.
* @returns {string} The decoded literal.
*/
function decodeFishUniversalValue(s) {
let out = '';
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (c !== '\\') { out += c; continue; }
const n = s[i + 1];
if (n === 'n') { out += '\n'; i += 1; }
else if (n === 'r') { out += '\r'; i += 1; }
else if (n === 't') { out += '\t'; i += 1; }
else if (n === '\\') { out += '\\'; i += 1; }
else if (n === 'x' || n === 'X') {
const hex = s.slice(i + 2, i + 4);
if (/^[0-9a-fA-F]{2}$/.test(hex)) { out += String.fromCharCode(parseInt(hex, 16)); i += 3; }
else { out += c; }
} else if (n === 'u') {
const hex = s.slice(i + 2, i + 6);
if (/^[0-9a-fA-F]{4}$/.test(hex)) { out += String.fromCharCode(parseInt(hex, 16)); i += 5; }
else { out += c; }
} else if (n === 'U') {
const hex = s.slice(i + 2, i + 10);
if (/^[0-9a-fA-F]{8}$/.test(hex)) { out += String.fromCodePoint(parseInt(hex, 16)); i += 9; }
else { out += c; }
} else { out += c; }
}
return out;
}
/**
* Check whether fish's configuration already places `globalBin` on PATH (#323).
*
* fish does not use the sh-style `export PATH=` rc files that
* `homePathCoveredByRc()` parses, so a fish user whose `fish_user_paths`
* already covers the global bin would otherwise see a false-positive
* "not on your PATH" warning on every install. Two detection routes,
* mirroring how `fish_add_path` actually persists:
*
* 1. The universal-variable store `fish_variables` — a
* `SETUVAR fish_user_paths:<a>\x1e<b>…` line whose `\x1e`-separated
* entries are absolute paths (fish does not HOME-expand them here).
* 2. `config.fish` — explicit `fish_add_path …`, `set -gx PATH …`, or
* `set -Ux fish_user_paths …` lines that name the directory after
* HOME expansion.
*
* Best-effort and side-effect-free: any unreadable / missing file is ignored
* (no fish subprocess is spawned). Honours `$XDG_CONFIG_HOME` and always also
* checks `~/.config/fish`. Pass `fishConfigDir` to override the lookup
* directory (tests).
*
* @param {string} globalBin Absolute path to npm's global bin directory.
* @param {string} homeDir Absolute path used to substitute HOME / ~.
* @param {string} [fishConfigDir] Override the fish config directory.
* @returns {boolean} true iff fish config adds globalBin to PATH.
*/
function homePathCoveredByFishConfig(globalBin, homeDir, fishConfigDir) {
if (!globalBin || !homeDir) return false;
const path = require('path');
const fs = require('fs');
const normalise = (p) => {
if (!p) return '';
let n = p.replace(/[\\/]+$/g, '');
if (n === '') n = p.startsWith('/') ? '/' : p;
return n;
};
const targetAbs = normalise(path.resolve(globalBin));
const homeAbs = path.resolve(homeDir);
const baseDirs = [];
if (fishConfigDir) {
baseDirs.push(fishConfigDir);
} else {
if (process.env.XDG_CONFIG_HOME) {
baseDirs.push(path.join(process.env.XDG_CONFIG_HOME, 'fish'));
}
baseDirs.push(path.join(homeAbs, '.config', 'fish'));
}
const expandHome = (segment) => {
let s = segment;
s = s.replace(/\$\{HOME\}/g, homeAbs).replace(/\$HOME/g, homeAbs);
if (s.startsWith('~/') || s === '~') {
s = s === '~' ? homeAbs : path.join(homeAbs, s.slice(2));
}
return s;
};
// Compare an already-resolved absolute literal (a decoded fish_user_paths
// entry — fish stores these resolved, never as `$VAR`/`~`). A literal `$`
// here is part of the directory name, so it must NOT be treated as an
// unexpanded variable.
const matchesLiteral = (segment) => {
if (!segment || !path.isAbsolute(segment)) return false;
try {
return normalise(path.resolve(segment)) === targetAbs;
} catch {
return false;
}
};
// Compare a config.fish shell token: strip surrounding quotes, expand the
// common HOME forms, and skip anything still holding a `$` (an unexpanded
// variable such as `$PATH` / `$fish_user_paths`) or still relative.
const matchesTarget = (rawSegment) => {
if (!rawSegment) return false;
let seg = rawSegment.trim();
if ((seg.startsWith('"') && seg.endsWith('"')) ||
(seg.startsWith("'") && seg.endsWith("'"))) {
seg = seg.slice(1, -1);
}
const expanded = expandHome(seg);
if (expanded.includes('$')) return false;
return matchesLiteral(expanded);
};
const readLines = (filePath) => {
try {
return fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
} catch {
return null;
}
};
for (const baseDir of baseDirs) {
// Route 1: universal variable store.
const uvarLines = readLines(path.join(baseDir, 'fish_variables'));
if (uvarLines) {
for (const rawLine of uvarLines) {
const m = /^SETUVAR(?:\s+--\S+)*\s+fish_user_paths:(.*)$/.exec(rawLine);
if (!m) continue;
// Elements are joined by the literal `\x1e` token; decode each. The
// decoded entry is an absolute literal — compare it directly.
for (const entry of m[1].split('\\x1e')) {
if (matchesLiteral(decodeFishUniversalValue(entry))) return true;
}
}
}
// Route 2: config.fish explicit PATH mutations.
const configLines = readLines(path.join(baseDir, 'config.fish'));
if (configLines) {
for (const rawLine of configLines) {
const line = rawLine.replace(/^\s+/, '');
if (line.startsWith('#')) continue;
let rest = null;
let m;
if ((m = /^fish_add_path\s+(.+)$/.exec(line))) {
rest = m[1];
} else if ((m = /^set\s+(?:-\S+\s+)*PATH\s+(.+)$/.exec(line))) {
rest = m[1];
} else if ((m = /^set\s+(?:-\S+\s+)*fish_user_paths\s+(.+)$/.exec(line))) {
rest = m[1];
}
if (rest === null) continue;
// Tokens are whitespace-separated; flag tokens (`-g`, `--path`) and
// variable references are skipped by matchesTarget / the `-` guard.
for (const tok of rest.split(/\s+/)) {
if (!tok || tok.startsWith('-')) continue;
if (matchesTarget(tok)) return true;
}
}
}
}
return false;
}
/**
* Emit a PATH-export suggestion if globalBin is not already on PATH AND
* the user's shell rc files do not already cover it via a HOME-relative
@@ -11710,6 +11958,16 @@ function maybeSuggestPathExport(globalBin, homeDir) {
return;
}
// Same idea for fish users: fish_user_paths / config.fish already covers the
// dir, the current session just predates it. fish has no sh-style rc file so
// homePathCoveredByRc never sees it — check the fish config explicitly (#323).
if (homePathCoveredByFishConfig(globalBin, homeDir)) {
console.log('');
console.log(` ${yellow}⚠${reset} ${bold}${globalBin}${reset}'s directory is already on your PATH via fish's universal variables — open a new fish session (or run ${cyan}exec fish${reset}).`);
console.log('');
return;
}
console.log('');
console.log(` ${yellow}⚠${reset} ${bold}${globalBin}${reset} is not on your PATH.`);
console.log(` Add it with one of:`);
@@ -11943,6 +12201,7 @@ module.exports = {
convertClaudeAgentToCodexAgent,
generateCodexAgentToml,
cleanupCodexSkillMetadataSidecars,
cleanupWindsurfLegacyDevinSkills,
generateCodexConfigBlock,
stripGsdFromCodexConfig,
migrateCodexHooksMapFormat,
@@ -12004,6 +12263,7 @@ module.exports = {
skillFrontmatterName,
convertClaudeToWindsurfMarkdown,
convertClaudeCommandToWindsurfSkill,
convertClaudeCommandToWindsurfWorkflow,
convertClaudeAgentToWindsurfAgent,
convertClaudeToAugmentMarkdown,
convertClaudeCommandToAugmentSkill,
@@ -12045,6 +12305,8 @@ module.exports = {
USER_OWNED_ARTIFACTS,
finishInstall,
homePathCoveredByRc,
homePathCoveredByFishConfig,
decodeFishUniversalValue,
maybeSuggestPathExport,
runtimeMap,
allRuntimes,