Merge remote-tracking branch 'origin/next' into fix/1477-surface-source-marker
# Conflicts: # bin/install.js
This commit is contained in:
514
bin/install.js
514
bin/install.js
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user