multilingual-absa / .opencode /gsd-core /bin /lib /install-profiles.cjs
Aryan Mishra
feat: initial commit - Multilingual ABSA project setup with 6 phases, 36 requirements
a6b96c2
Raw
History Blame Contribute Delete
33.7 kB
"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-<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,
};