Spaces:
Runtime error
Runtime error
Aryan Mishra
feat: initial commit - Multilingual ABSA project setup with 6 phases, 36 requirements
a6b96c2 | ; | |
| /** | |
| * 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-<stem>` 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-<stem> 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_<stem>` 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_<stem> 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-<stem>` token (optionally with `--flag` | |
| * suffixes) is rewritten to `Read \`skills/<stem>/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/<name>/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/<name>.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, | |
| }; | |