"use strict"; /** * Skill Surface Budget Module — single source of truth for which skills/agents * are written to the runtime config dirs (ADR-0011). * * ADR-457 build-at-publish: the hand-written bin/lib/install-profiles.cjs collapsed * to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour * from the prior hand-written .cjs; only types are added. */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; const node_fs_1 = __importDefault(require("node:fs")); const node_path_1 = __importDefault(require("node:path")); const node_os_1 = __importDefault(require("node:os")); const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs"); // --------------------------------------------------------------------------- // Profile definitions // --------------------------------------------------------------------------- /** * PROFILES maps profile name → base skill set (array) or '*' sentinel (full). * * The effective set for any profile is CLOSURE(base, requires: manifest). * standard is a superset of core; full is the identity (all skills). * * Composition: --profile=core,audit resolves to union(closure(core), closure(audit)). */ const PROFILES = Object.freeze({ core: Object.freeze([ 'new-project', 'discuss-phase', 'plan-phase', 'execute-phase', 'phase', 'help', 'update', 'surface', ]), standard: Object.freeze([ // Core loop 'new-project', 'discuss-phase', 'plan-phase', 'execute-phase', 'help', 'update', 'surface', // Phase management (hot nodes from audit — required by 38+ skills) 'phase', 'review', 'config', 'progress', // Workspace / state 'resume-work', 'pause-work', 'workspace', ]), full: '*', }); // --------------------------------------------------------------------------- // Manifest parsing // --------------------------------------------------------------------------- /** * Parse the requires: field from YAML frontmatter. * Handles: "requires: [a, b, c]" (flow style) and absent field. * Returns string[] — empty array if no requires: field. * * No external YAML parser dependency — hand-parse the single line * since GSD enforces flow-style arrays for requires:. */ function parseRequires(content) { const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/m); if (!fmMatch) return []; const fm = fmMatch[1]; const line = fm.match(/^requires:\s*(.+)$/m); if (!line) return []; const val = line[1].trim(); // Flow-style: [a, b, c] if (val.startsWith('[') && val.endsWith(']')) { const inner = val.slice(1, -1).trim(); if (!inner) return []; return inner.split(',').map((s) => s.trim()).filter(Boolean); } // Single bare value (not currently used, but defensive) return val ? [val] : []; } /** * Parse agent references from a skill file's body text. * Scans the full content for `gsd-` patterns that correspond to * real agent files. Returns all unique `gsd-*` stems found in the body. * * The caller is responsible for filtering by which agents actually exist — * this function returns all syntactically valid `gsd-*` matches. */ function parseCallsAgents(content) { // Match word-boundary gsd- patterns; stems are lowercase letters and hyphens. // We use a regex that matches `gsd-` followed by one or more lowercase-alpha-or-hyphen chars. // This catches `gsd-planner`, `gsd-plan-checker`, etc. in prose and code. const matches = content.match(/\bgsd-[a-z][a-z-]*/g); if (!matches) return []; // Deduplicate return [...new Set(matches)]; } /** * Load the requires: dependency graph from a commands/gsd directory. * Also derives calls_agents for each skill by scanning the body text for * `gsd-*` agent name references. Agent stems are stored under the special * key `_calls_agents_` so they don't conflict with skill stems. */ const DEFAULT_COMMANDS_DIR = node_path_1.default.resolve(__dirname, '..', '..', '..', 'commands', 'gsd'); function loadSkillsManifest(commandsDir = DEFAULT_COMMANDS_DIR) { const manifest = new Map(); if (!node_fs_1.default.existsSync(commandsDir)) return manifest; const entries = node_fs_1.default.readdirSync(commandsDir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isFile()) continue; if (!entry.name.endsWith('.md')) continue; const stem = entry.name.slice(0, -3); try { const content = node_fs_1.default.readFileSync(node_path_1.default.join(commandsDir, entry.name), 'utf8'); manifest.set(stem, parseRequires(content)); // Derive agent references from body text const agentRefs = parseCallsAgents(content); manifest.set(`_calls_agents_${stem}`, agentRefs); } catch { manifest.set(stem, []); manifest.set(`_calls_agents_${stem}`, []); } } return manifest; } // --------------------------------------------------------------------------- // Profile resolution (transitive closure) // --------------------------------------------------------------------------- /** * Compute the transitive closure of a set of skill stems over the manifest. */ function computeClosure(base, manifest) { const closed = new Set(base); const queue = [...closed]; while (queue.length > 0) { const stem = queue.pop(); const deps = manifest.get(stem) || []; for (const dep of deps) { if (!closed.has(dep)) { closed.add(dep); queue.push(dep); } } } return closed; } /** * Compute the capability skills to add for a given profile mode from the registry. * Returns an array of skill stems contributed by capabilities whose profileMembership * includes the given mode. Guards against prototype pollution and malformed registry. */ function _capabilitySkillsForMode(mode, registry) { const BANNED = ['__proto__', 'constructor', 'prototype']; const clusters = registry.capabilityClusters; const membership = registry.profileMembership; if (!clusters || typeof clusters !== 'object' || !membership || typeof membership !== 'object') { return []; } const result = []; for (const capId of Object.keys(clusters)) { if (BANNED.includes(capId)) continue; const mem = membership[capId]; if (!mem || typeof mem !== 'object') continue; const profiles = mem.profiles; if (!Array.isArray(profiles)) continue; if (!profiles.includes(mode)) continue; const skills = clusters[capId]; if (!Array.isArray(skills)) continue; for (const s of skills) { if (typeof s === 'string' && s.length > 0) result.push(s); } } return result; } /** * Resolve a profile (or composed profiles) to a typed result object. */ function resolveProfile({ modes, manifest, _profilesOverride, registry } = {}) { const profiles = _profilesOverride || PROFILES; const activeModes = (modes && modes.length > 0) ? modes : ['full']; const normalizedModes = activeModes .flatMap((mode) => String(mode).split(',')) .map((mode) => mode.trim()) .filter(Boolean); const modesToResolve = normalizedModes.length > 0 ? normalizedModes : ['full']; // If any mode is 'full', the result is the full sentinel if (modesToResolve.includes('full')) { return { name: 'full', skills: '*', agents: new Set() }; } const validModes = modesToResolve.filter((mode) => Object.prototype.hasOwnProperty.call(profiles, mode)); if (validModes.length === 0) { // Invalid/corrupt marker fallback: avoid empty installs by defaulting to full. return { name: 'full', skills: '*', agents: new Set() }; } const man = manifest || new Map(); const unionSkills = new Set(); for (const mode of validModes) { const base = profiles[mode]; if (base === '*') { // This profile is full — sentinel short-circuit return { name: 'full', skills: '*', agents: new Set() }; } // ADR-857 phase 4c: union capability skills for this mode BEFORE closure so // their requires: chains expand too. const capSkills = registry ? _capabilitySkillsForMode(mode, registry) : []; const baseWithCap = [...base, ...capSkills]; const closure = computeClosure(baseWithCap, man); for (const s of closure) unionSkills.add(s); } // Derive agents: union of all agent names referenced in the body text of // every skill in unionSkills. Agent names are stored in the manifest under // _calls_agents_ keys (populated by loadSkillsManifest). const unionAgents = new Set(); for (const skillStem of unionSkills) { const agentRefs = man.get(`_calls_agents_${skillStem}`) || []; for (const agentStem of agentRefs) { unionAgents.add(agentStem); } } const name = validModes.length === 1 ? validModes[0] : validModes.join(','); return { name, skills: unionSkills, agents: unionAgents }; } // --------------------------------------------------------------------------- // Staging — skills // --------------------------------------------------------------------------- // Stage dirs created during this process — cleaned up on exit. // 13 runtime dispatch sites in install.js can each call stageSkillsForMode, // so accumulating them in a single set avoids leaks without forcing each // site to track its own cleanup handle. const STAGED_DIRS = new Set(); let exitHandlerRegistered = false; function cleanupStagedSkills() { for (const dir of STAGED_DIRS) { try { node_fs_1.default.rmSync(dir, { recursive: true, force: true }); } catch { // Best-effort: missing dir or permission error shouldn't crash a // successful install. The OS reaps tmpdir eventually. } } STAGED_DIRS.clear(); } // Signals we register a cleanup handler for in addition to the natural // 'exit' event. `process.on('exit')` does NOT fire on these — an installer // is exactly the kind of process users abort mid-run, so without explicit // signal handling Ctrl+C would leave staged tmp dirs behind. const CLEANUP_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP']; function ensureExitCleanup() { if (exitHandlerRegistered) return; exitHandlerRegistered = true; process.on('exit', cleanupStagedSkills); for (const sig of CLEANUP_SIGNALS) { // `once` so re-raising the signal below isn't intercepted by us a second // time — the OS-default handler should take over and exit with the right // status code (so CI sees the abort, scripts see 130 for SIGINT, etc.). process.once(sig, () => { cleanupStagedSkills(); process.kill(process.pid, sig); }); } } /** * Stage a filtered copy of commands/gsd for a resolved profile. * In full mode (skills === '*') returns srcDir unchanged (no-op). */ function stageSkillsForProfile(srcDir, resolvedProfile) { if (resolvedProfile.skills === '*') return srcDir; if (!node_fs_1.default.existsSync(srcDir)) return srcDir; const stageDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'gsd-profile-skills-')); try { const entries = node_fs_1.default.readdirSync(srcDir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isFile()) continue; if (!entry.name.endsWith('.md')) continue; const stem = entry.name.slice(0, -3); if (!(resolvedProfile.skills).has(stem)) continue; node_fs_1.default.copyFileSync(node_path_1.default.join(srcDir, entry.name), node_path_1.default.join(stageDir, entry.name)); } } catch (err) { try { node_fs_1.default.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ } throw err; } STAGED_DIRS.add(stageDir); ensureExitCleanup(); return stageDir; } /** * Stage a filtered copy of the agents directory for a resolved profile. * For 'full', returns srcAgentsDir unchanged. * For tiered profiles, copies only agents whose full stem (e.g. 'gsd-planner') * is in resolvedProfile.agents — which is populated by resolveProfile() from * the _calls_agents_* entries in the manifest. */ function stageAgentsForProfile(srcAgentsDir, resolvedProfile) { if (resolvedProfile.skills === '*') return srcAgentsDir; if (!node_fs_1.default.existsSync(srcAgentsDir)) return srcAgentsDir; const stageDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'gsd-profile-agents-')); try { if (resolvedProfile.agents instanceof Set && resolvedProfile.agents.size > 0) { const entries = node_fs_1.default.readdirSync(srcAgentsDir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isFile()) continue; if (!entry.name.endsWith('.md')) continue; // Agent stem is the full filename without extension, e.g. "gsd-planner" const stem = entry.name.slice(0, -3); if (!resolvedProfile.agents.has(stem)) continue; node_fs_1.default.copyFileSync(node_path_1.default.join(srcAgentsDir, entry.name), node_path_1.default.join(stageDir, entry.name)); } } // If agents is empty Set, we produce an empty stageDir (no agents for this profile) } catch (err) { try { node_fs_1.default.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ } throw err; } STAGED_DIRS.add(stageDir); ensureExitCleanup(); return stageDir; } /** * Build the namespace router → concrete sub-skill mapping (#69). The * authoritative source is each `ns-*.md` router file's `requires:` frontmatter * list. A concrete skill may be routed by more than one router (e.g. spec-phase * is shared by ns-workflow and ns-ideate); it is nested — and physically * duplicated — under every owning router. */ function buildNamespaceBundleMap(srcCommandsDir) { const routerStems = new Set(); const routerChildren = new Map(); const childToRouters = new Map(); if (!node_fs_1.default.existsSync(srcCommandsDir)) { return { routerStems, routerChildren, childToRouters }; } for (const entry of node_fs_1.default.readdirSync(srcCommandsDir, { withFileTypes: true })) { if (!entry.isFile() || !entry.name.endsWith('.md')) continue; if (!entry.name.startsWith('ns-')) continue; const stem = entry.name.slice(0, -3); let children = []; try { children = parseRequires(node_fs_1.default.readFileSync(node_path_1.default.join(srcCommandsDir, entry.name), 'utf8')); } catch { children = []; } routerStems.add(stem); routerChildren.set(stem, children); for (const child of children) { const owners = childToRouters.get(child) || []; owners.push(stem); childToRouters.set(child, owners); } } return { routerStems, routerChildren, childToRouters }; } /** * Rewrite a converted namespace-router SKILL.md so its routing table points at * nested sub-skill files instead of bare Skill-tool names (#69). Each table row * whose final cell carries a `gsd-` token (optionally with `--flag` * suffixes) is rewritten to `Read \`skills//SKILL.md\`` (flags preserved * as a note), the `Invoke` column header becomes `Read`, and the * "Invoke … using the Skill tool" trailer becomes a file-read instruction. * Only lines beginning with a table pipe are touched, so the `|` inside the * `description:` frontmatter field is never matched. */ function transformRouterBodyToNested(converted) { const lines = converted.split('\n'); const out = lines.map((line) => { if (/Invoke the matched skill directly using the Skill tool\./.test(line)) { return line.replace(/Invoke the matched skill directly using the Skill tool\./, "Read the matched sub-skill's SKILL.md and follow its instructions. The `skills//SKILL.md` paths in the right column are relative to this skill's own directory."); } if (!/^\s*\|/.test(line)) return line; if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) return line; if (/\|\s*Invoke\s*\|/.test(line)) { return line.replace(/\|\s*Invoke\s*\|/, '| Read |'); } const cells = line.split('|'); const lastIdx = cells.length - 2; if (lastIdx < 1) return line; const cell = cells[lastIdx]; const m = cell.match(/gsd-([a-z0-9-]+)((?:\s+--[a-z0-9-]+)*)/i); if (!m) return line; const stem = m[1]; const flags = m[2].trim(); cells[lastIdx] = flags ? ` Read \`skills/${stem}/SKILL.md\` (${flags}) ` : ` Read \`skills/${stem}/SKILL.md\` `; return cells.join('|'); }); return out.join('\n'); } function stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converter, prefix, nested = false) { if (!node_fs_1.default.existsSync(srcCommandsDir)) return srcCommandsDir; // Nesting applies to the `full` install AND to any surface whose skill set // still contains every namespace router (a full/reset surface). It must NOT // depend on the `'*'` sentinel alone: applySurface() materializes `full` into // a concrete Set, so a sentinel-only gate would re-flatten the layout on every // surface apply/reset (#69 adversarial-review finding). A partial surface that // drops a whole router cluster falls back to flat automatically. const bundles = nested ? buildNamespaceBundleMap(srcCommandsDir) : null; let doNest = false; if (nested && bundles && bundles.routerStems.size > 0) { if (resolvedProfile.skills === '*') { doNest = true; } else { const present = resolvedProfile.skills; doNest = [...bundles.routerStems].every((r) => present.has(r)); } } const stageDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'gsd-profile-runtime-skills-')); try { const entries = node_fs_1.default.readdirSync(srcCommandsDir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isFile()) continue; if (!entry.name.endsWith('.md')) continue; const stem = entry.name.slice(0, -3); if (resolvedProfile.skills !== '*' && !(resolvedProfile.skills).has(stem)) continue; const content = node_fs_1.default.readFileSync(node_path_1.default.join(srcCommandsDir, entry.name), 'utf8'); const skillName = `${prefix}${stem}`; const converted = converter(content, skillName); if (doNest && bundles.routerStems.has(stem)) { // Router skill: rewrite its routing table to the nested Read pattern and // emit it as the single top-level bundle entry. const destDir = node_path_1.default.join(stageDir, skillName); node_fs_1.default.mkdirSync(destDir, { recursive: true }); node_fs_1.default.writeFileSync(node_path_1.default.join(destDir, 'SKILL.md'), transformRouterBodyToNested(converted)); continue; } if (doNest && bundles.childToRouters.has(stem)) { // Concrete skill routed by one or more namespace routers: nest a copy // under each owning router's skills/ subdir so it drops out of the // top-level eager listing while staying readable by file path (#69). for (const routerStem of bundles.childToRouters.get(stem)) { const destDir = node_path_1.default.join(stageDir, `${prefix}${routerStem}`, 'skills', stem); node_fs_1.default.mkdirSync(destDir, { recursive: true }); node_fs_1.default.writeFileSync(node_path_1.default.join(destDir, 'SKILL.md'), converted); } continue; } // Flat top-level skill (default behaviour; also the unrouted fallback when // nesting is active). const destDir = node_path_1.default.join(stageDir, skillName); node_fs_1.default.mkdirSync(destDir, { recursive: true }); node_fs_1.default.writeFileSync(node_path_1.default.join(destDir, 'SKILL.md'), converted); } } catch (err) { try { node_fs_1.default.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ } throw err; } STAGED_DIRS.add(stageDir); ensureExitCleanup(); return stageDir; } /** * Stage a converted copy of the agents directory for a given runtime. * * Analogous to `stageCommandsForRuntimeFlat` but for agent `.md` files. Each * source `.md` is passed through `converter` and written as a flat `${name}.md` * file in the staging directory. Agent filenames are kept verbatim (no prefix * added here — the prefix is already embedded in agent stems, e.g. `gsd-planner.md`). * * This is used by the descriptor-driven `dispatchKindEntry` when an `agents` kind * entry carries a non-null converter (ADR-457 / #1173). When `converter` is null, * `agentsKind` falls back to the existing raw-copy path (`stageAgentsForProfile`). * * For the `full` profile (`skills === '*'`), all `.md` files are staged. * For tiered profiles, only agents whose full stem is in `resolvedProfile.agents` * are staged (mirrors `stageAgentsForProfile` behaviour). * * @param srcAgentsDir source agents directory (e.g. agents/) * @param resolvedProfile profile filter from resolveProfile() * @param converter (content: string) → string pure per-file converter */ function stageAgentsForRuntimeWithConverter(srcAgentsDir, resolvedProfile, converter) { if (!node_fs_1.default.existsSync(srcAgentsDir)) return srcAgentsDir; const stageDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'gsd-profile-runtime-agents-')); try { const entries = node_fs_1.default.readdirSync(srcAgentsDir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isFile()) continue; if (!entry.name.endsWith('.md')) continue; // For tiered profiles, gate by agent stem (full filename without extension). if (resolvedProfile.skills !== '*') { const stem = entry.name.slice(0, -3); if (!(resolvedProfile.agents instanceof Set && resolvedProfile.agents.has(stem))) { continue; } } const content = node_fs_1.default.readFileSync(node_path_1.default.join(srcAgentsDir, entry.name), 'utf8'); const converted = converter(content); node_fs_1.default.writeFileSync(node_path_1.default.join(stageDir, entry.name), converted, 'utf8'); } } catch (err) { try { node_fs_1.default.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ } throw err; } STAGED_DIRS.add(stageDir); ensureExitCleanup(); return stageDir; } /** * Stage converted command files as flat `.md` files. * * Analogous to `stageSkillsForRuntimeAsSkills` but for runtimes that use a * flat commands directory (e.g. Cursor's `.cursor/commands/.md`). * Each source `.md` is passed through `converter` and written as a single flat * `${stem}.md` file in the staging directory (no subdirectory, no prefix). * * The `_copyStaged` commands branch in install.js will add the prefix when * copying staged files to the destination directory, so staged files must be * named with just the stem (e.g. `help.md` not `gsd-help.md`). * * The `converter` receives `(content, ${prefix}${stem})` so it can embed the * full command name (e.g. 'gsd-help') into the document body if needed. * * Used by the `convertedCommandsKind` layout descriptor in * runtime-artifact-layout.cts (#785 — Cursor 1.6 slash commands). * * @param srcCommandsDir source commands directory (e.g. commands/gsd/) * @param resolvedProfile profile filter — '*' for all, Set for subset * @param converter (content, commandName) → string pure converter * @param prefix command name prefix (for converter arg), e.g. 'gsd-' */ function stageCommandsForRuntimeFlat(srcCommandsDir, resolvedProfile, converter, prefix) { if (!node_fs_1.default.existsSync(srcCommandsDir)) return srcCommandsDir; const stageDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'gsd-profile-runtime-commands-')); try { const entries = node_fs_1.default.readdirSync(srcCommandsDir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isFile()) continue; if (!entry.name.endsWith('.md')) continue; const stem = entry.name.slice(0, -3); if (resolvedProfile.skills !== '*' && !(resolvedProfile.skills).has(stem)) continue; const content = node_fs_1.default.readFileSync(node_path_1.default.join(srcCommandsDir, entry.name), 'utf8'); // Pass the full command name (with prefix) to the converter so it can // reference the installed command name in the body (e.g. for descriptions). // The staged file itself is named without the prefix; _copyStaged adds it. const commandName = `${prefix}${stem}`; const converted = converter(content, commandName); node_fs_1.default.writeFileSync(node_path_1.default.join(stageDir, `${stem}.md`), converted); } } catch (err) { try { node_fs_1.default.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ } throw err; } STAGED_DIRS.add(stageDir); ensureExitCleanup(); return stageDir; } // --------------------------------------------------------------------------- // Profile marker persistence // --------------------------------------------------------------------------- const PROFILE_MARKER_NAME = '.gsd-profile'; /** * Read the active profile from a runtime config directory. */ function readActiveProfile(runtimeConfigDir) { const markerPath = node_path_1.default.join(runtimeConfigDir, PROFILE_MARKER_NAME); try { const raw = node_fs_1.default.readFileSync(markerPath, 'utf8').trim(); if (!raw) return null; // Validate that it looks like a profile name (alphanumeric + hyphens + commas) if (!/^[a-z0-9,_-]+$/i.test(raw)) return null; return raw; } catch { return null; } } /** * Persist the active profile to a runtime config directory. */ function writeActiveProfile(runtimeConfigDir, profileName) { (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(runtimeConfigDir, PROFILE_MARKER_NAME), profileName + '\n'); } // --------------------------------------------------------------------------- // Profile resolution helpers for install / update flows // --------------------------------------------------------------------------- /** * Rank ordering for profiles (lower index = more restrictive / smaller skill set). * Unknown profiles default to the permissive end (treated as 'full'). */ const PROFILE_RANK = Object.freeze(['core', 'standard', 'full']); /** * Given an array of profile names (one per runtime), return the most-restrictive * profile — i.e. the one with the smallest effective skill set. * * Ordering (most to least restrictive): core < standard < full. * Composed profiles (e.g. 'core,audit') and unknown profiles are treated as * 'full' for this comparison. */ function mostRestrictiveProfile(profileNames) { if (!profileNames || profileNames.length === 0) return 'full'; // Initialize with the least-restrictive rank (one past the end of PROFILE_RANK) let bestRank = PROFILE_RANK.length; let bestName = 'full'; for (const name of profileNames) { const rank = PROFILE_RANK.indexOf(name); // Unknown/composed profiles are treated as the permissive 'full' rank. const effectiveRank = rank === -1 ? PROFILE_RANK.indexOf('full') : rank; if (effectiveRank < bestRank) { bestRank = effectiveRank; bestName = rank === -1 ? 'full' : name; } } return bestName; } /** * Resolve the effective profile name for an install() run. * * Priority: * 1. Explicit flag (requestedProfileName != null) → use it as-is. * 2. Marker exists in targetDir and is not 'full' → use marker. * 3. Else → 'full' (back-compat for fresh non-interactive installs). */ function resolveEffectiveProfile({ requestedProfileName, targetDir }) { // 1. Explicit flag overrides everything if (requestedProfileName != null) return requestedProfileName; // 2. Marker-driven (gsd update path) const marker = readActiveProfile(targetDir); if (marker && marker !== 'full') return marker; // 3. Default return 'full'; } // --------------------------------------------------------------------------- // Back-compat shims (deprecated — use profile-based API instead) // --------------------------------------------------------------------------- /** * @deprecated Use PROFILES.core instead. * Preserved for callers in install.js and existing tests. */ const MINIMAL_SKILL_ALLOWLIST = Object.freeze([...PROFILES.core]); const MINIMAL_ALLOWLIST_SET = new Set(MINIMAL_SKILL_ALLOWLIST); /** * @deprecated Use resolveProfile({ modes: ['core'] }) instead. */ function isMinimalMode(mode) { return mode === 'minimal' || mode === 'core-only'; } /** * Overloaded for back-compat. * - If resolvedProfileOrMode is a string: legacy mode check (full/minimal) * - If resolvedProfileOrMode is an object with .skills: new profile API * * @deprecated String-mode form; use resolvedProfile object form instead. */ function shouldInstallSkill(skillBaseName, resolvedProfileOrMode) { if (typeof resolvedProfileOrMode === 'object' && resolvedProfileOrMode !== null) { const { skills } = resolvedProfileOrMode; if (skills === '*') return true; return skills instanceof Set && skills.has(skillBaseName); } // Legacy string mode const mode = resolvedProfileOrMode; if (!isMinimalMode(mode)) return true; return MINIMAL_ALLOWLIST_SET.has(skillBaseName); } /** * Stage a filtered copy of the source commands/gsd directory. * Back-compat wrapper: maps 'minimal' → core profile, 'full' → full. * * @deprecated Use stageSkillsForProfile with a resolved profile instead. */ function stageSkillsForMode(srcDir, mode) { if (!isMinimalMode(mode)) return srcDir; if (!node_fs_1.default.existsSync(srcDir)) return srcDir; const stageDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'gsd-minimal-skills-')); try { const entries = node_fs_1.default.readdirSync(srcDir, { withFileTypes: true }); for (const entry of entries) { if (!entry.isFile()) continue; if (!entry.name.endsWith('.md')) continue; const baseName = entry.name.replace(/\.md$/, ''); if (!shouldInstallSkill(baseName, mode)) continue; node_fs_1.default.copyFileSync(node_path_1.default.join(srcDir, entry.name), node_path_1.default.join(stageDir, entry.name)); } } catch (err) { try { node_fs_1.default.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ } throw err; } STAGED_DIRS.add(stageDir); ensureExitCleanup(); return stageDir; } module.exports = { // New profile API (ADR-0011) PROFILES, PROFILE_RANK, loadSkillsManifest, resolveProfile, resolveEffectiveProfile, mostRestrictiveProfile, stageSkillsForProfile, stageAgentsForProfile, stageAgentsForRuntimeWithConverter, stageSkillsForRuntimeAsSkills, stageCommandsForRuntimeFlat, STAGED_DIRS, readActiveProfile, writeActiveProfile, // Shared internals parseRequires, cleanupStagedSkills, // Back-compat / deprecated MINIMAL_SKILL_ALLOWLIST, isMinimalMode, shouldInstallSkill, stageSkillsForMode, };