* fix(#2362): materialize third-party capability skills on opencode/kilo install installOpencodeFamilyArtifacts/installOpencodeFamilySkills never received the capability registry, so a registered+surfaced+active third-party capability skill was silently dropped by the OpenCode/Kilo combined-family INSTALL path (registry said surfaced:true, disk had nothing). Thread capabilityRegistry through and reuse the existing #2322 seam's exported helpers (readInstalledCapabilitySkill, capabilityClusterStems, CAPABILITY_SKILL_MARKER) to fill in third-party skills after the first-party loop, with the same guarantees: registry-bound ownership, first-party-wins, full/'*' sentinel support, and graceful degradation on a missing/corrupt capability skill. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#2362): cover the tiered (non-'*') profile candidateStems branch Review found the new third-party capability-skill fill-in for OpenCode/Kilo had zero coverage of the tiered-profile path — all 12 regression tests only exercised the '*' full-profile sentinel. Adds a case that resolves a `standard`-tier profile through a synthetic registry (mirroring the seam's own __registryFor pattern) and asserts the resulting concrete Set still materializes the registered capability skill, with its prune-parity marker, alongside the tier's first-party skills. Also adds the .changeset/ fragment this fix was still missing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#2362): backfill changeset pr (#2434) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 2434
|
||||
---
|
||||
**Installed third-party capability skills now materialize on OpenCode and Kilo** — `capability install` + `capability set --runtime opencode` (or `kilo`) could report a capability as `installed: true, surfaced: true, active: true` while its skill was never written to `skills/gsd-<stem>/SKILL.md`: the OpenCode/Kilo combined-family install path never called the seam #2322 fixed for other runtimes. Installed capability skills now materialize the same way there too, bound to their declaring capability, with first-party skills always winning a name collision. (#2362)
|
||||
@@ -29,6 +29,7 @@ import runtimeNamePolicy = require('./runtime-name-policy.cjs');
|
||||
import installProfiles = require('./install-profiles.cjs');
|
||||
import installerMigrations = require('./installer-migrations.cjs');
|
||||
import { posixNormalize } from './shell-command-projection.cjs';
|
||||
import { isPathConfined } from './external-descriptor-trust.cjs';
|
||||
|
||||
const { processAttribution } = runtimeArtifactConversion;
|
||||
// resolveRuntimeArtifactLayout: accessed via module ref (not destructured) so
|
||||
@@ -625,7 +626,7 @@ function installRuntimeArtifacts(
|
||||
// _runLegacyInstallMigrations below entirely (early return), so their
|
||||
// legacy-directory cleanup needs its own pre-materialization hook here.
|
||||
_migrateLegacyOpencodeCommandDir(runtime, configDir, behaviors);
|
||||
installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution, behaviors);
|
||||
installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution, behaviors, capabilityRegistry);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -760,6 +761,17 @@ function installRuntimeArtifacts(
|
||||
* @param rawCommandsDir - staged RAW Claude command dir (caller's _stageSkills output)
|
||||
* @param pathPrefix - computed config-path prefix for body rewrites
|
||||
* @param resolveAttribution - injection: (runtime) => attribution string | undefined
|
||||
* @param resolvedProfile - #2362: from resolveProfile()/resolveEffectiveProfile(); only
|
||||
* `.skills` is consulted (either the `'*'` full-profile sentinel or a concrete Set
|
||||
* of stems), and only to gate which THIRD-PARTY capability stems are candidates for
|
||||
* staging below. Absent -> no third-party skills staged (fail closed).
|
||||
* @param capabilityRegistry - #2362: optional composed capability registry
|
||||
* (capabilityClusters view). When present, installed third-party capability
|
||||
* skills bound to their declaring capId are unioned into the staged output —
|
||||
* the actual #2322 seam (install-profiles.cts stageSkillsForRuntimeAsSkills)
|
||||
* this bespoke OpenCode/Kilo writer never called. Absent -> no third-party
|
||||
* skills staged (fail closed), matching the seam's own optional-registry
|
||||
* contract.
|
||||
* @returns number of gsd-* skill directories written
|
||||
*/
|
||||
function installOpencodeFamilySkills(
|
||||
@@ -768,6 +780,8 @@ function installOpencodeFamilySkills(
|
||||
rawCommandsDir: string,
|
||||
pathPrefix: string,
|
||||
resolveAttribution: ResolveAttribution = () => undefined,
|
||||
resolvedProfile?: any,
|
||||
capabilityRegistry?: any,
|
||||
): number {
|
||||
const layout: any = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, targetDir);
|
||||
const skillsKindEntry = layout.kinds.find((k: any) => k.kind === 'skills');
|
||||
@@ -815,9 +829,11 @@ function installOpencodeFamilySkills(
|
||||
_removeGsdEntries(dest, skillsKindEntry);
|
||||
|
||||
let count = 0;
|
||||
const firstPartyStems = new Set<string>();
|
||||
for (const entry of fs.readdirSync(rawDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||||
const stem = entry.name.slice(0, -3);
|
||||
firstPartyStems.add(stem);
|
||||
const skillName = `${skillsKindEntry.prefix}${stem}`;
|
||||
let content = fs.readFileSync(path.join(rawDir, entry.name), 'utf8');
|
||||
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
|
||||
@@ -829,6 +845,50 @@ function installOpencodeFamilySkills(
|
||||
count++;
|
||||
}
|
||||
|
||||
// #2362: materialize installed THIRD-PARTY capability skills, bound to their
|
||||
// DECLARING capability via the registry's capabilityClusters view — mirrors
|
||||
// install-profiles.cts stageSkillsForRuntimeAsSkills's third-party fill-in
|
||||
// (the actual #2322 seam), reusing its exported security-reviewed helpers
|
||||
// rather than hand-rolling a second scan (DEFECT.GENERATIVE-FIX guard).
|
||||
// First-party always wins on stem collision. The full/'*' sentinel resolves
|
||||
// through capabilityClusterStems (BLOCKER-2 parity: `resolveProfile`
|
||||
// short-circuits `full` to `'*'` before consulting a registry, so a bare
|
||||
// `resolvedProfile.skills !== '*'` gate would silently skip this pass for
|
||||
// the default full install). No registry in scope -> stage NOTHING
|
||||
// third-party (fail closed — never fall back to scanning).
|
||||
//
|
||||
// Unlike the seam (which stages third-party bodies as-is and relies on a
|
||||
// later applySurface rewrite pass), this install path has no such later
|
||||
// pass — so third-party bodies get the SAME inline path-prefix/attribution
|
||||
// rewrite as first-party ones for on-disk parity. They do NOT go through
|
||||
// `converter`: an installed capability skill is already a complete
|
||||
// SKILL.md, not a Claude-command body awaiting frontmatter conversion.
|
||||
if (capabilityRegistry) {
|
||||
const candidateStems: Iterable<string> =
|
||||
resolvedProfile && resolvedProfile.skills === '*'
|
||||
? installProfiles.capabilityClusterStems(capabilityRegistry)
|
||||
: (resolvedProfile && resolvedProfile.skills) || [];
|
||||
for (const stem of candidateStems) {
|
||||
if (firstPartyStems.has(stem)) continue; // first-party always wins
|
||||
const found = installProfiles.readInstalledCapabilitySkill(stem, capabilityRegistry);
|
||||
if (found === null) continue; // absent/malformed/unowned -> skip gracefully
|
||||
const skillName = `${skillsKindEntry.prefix}${stem}`;
|
||||
if (!isPathConfined(skillName, dest)) continue; // defense-in-depth
|
||||
let content = found.content;
|
||||
content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
|
||||
content = processAttribution(content, resolveAttribution(runtime));
|
||||
const skillDir = path.join(dest, skillName);
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
|
||||
// #2322 HIGH-3 parity: persist the capability-owned marker so a later
|
||||
// prune pass can identify this directory even once the owning
|
||||
// capability is uninstalled/unsurfaced and no longer appears in any
|
||||
// registry view.
|
||||
fs.writeFileSync(path.join(skillDir, installProfiles.CAPABILITY_SKILL_MARKER), found.capId + '\n', 'utf8');
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore user-owned dirs after the prune+copy.
|
||||
for (const [dirName, snap] of toPreserve) {
|
||||
_restoreDir(path.join(dest, dirName), snap);
|
||||
@@ -1029,6 +1089,11 @@ function _migrateLegacyOpencodeCommandDir(runtime: string, configDir: string, be
|
||||
* @param resolvedProfile - from resolveProfile() / resolveEffectiveProfile()
|
||||
* @param resolveAttribution - injection: (runtime) => attribution string | undefined
|
||||
* @param behaviors - the runtime's hostBehaviors descriptor (already resolved by the caller)
|
||||
* @param capabilityRegistry - #2362: optional composed capability registry
|
||||
* (capabilityClusters view), threaded straight through to
|
||||
* installOpencodeFamilySkills so an installed third-party capability skill
|
||||
* materializes for this combined-family (OpenCode/Kilo) install path too.
|
||||
* Absent -> no third-party skills staged (fail closed).
|
||||
*/
|
||||
function installOpencodeFamilyArtifacts(
|
||||
runtime: string,
|
||||
@@ -1037,6 +1102,7 @@ function installOpencodeFamilyArtifacts(
|
||||
resolvedProfile: any,
|
||||
resolveAttribution: ResolveAttribution = () => undefined,
|
||||
behaviors: any = {},
|
||||
capabilityRegistry?: any,
|
||||
): void {
|
||||
const isGlobal = scope === 'global';
|
||||
// findInstallSourceRoot resolves DIRECTLY to the commands/gsd source dir
|
||||
@@ -1068,7 +1134,7 @@ function installOpencodeFamilyArtifacts(
|
||||
behaviors.flatCommandDir || 'command',
|
||||
);
|
||||
installOpencodeFamilyCommands(runtime, commandDir, rawCommandsDir, pathPrefix, resolveAttribution);
|
||||
installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution);
|
||||
installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution, resolvedProfile, capabilityRegistry);
|
||||
|
||||
_installNativePluginIfDeclared(runtime, configDir, behaviors, src);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ const {
|
||||
const {
|
||||
loadSkillsManifest,
|
||||
resolveProfile,
|
||||
CAPABILITY_SKILL_MARKER,
|
||||
} = require('../gsd-core/bin/lib/install-profiles.cjs');
|
||||
|
||||
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
@@ -466,6 +467,264 @@ describe('installOpencodeFamilySkills — emits skills/<name>/SKILL.md (#784)',
|
||||
}
|
||||
});
|
||||
|
||||
// ─── #2362: OpenCode/Kilo combined-family INSTALL path drops the capability
|
||||
// registry — an installed+registered+surfaced+active third-party capability
|
||||
// skill never materializes ───────────────────────────────────────────────
|
||||
//
|
||||
// installOpencodeFamilyArtifacts (called from installRuntimeArtifacts's
|
||||
// combinedFamilyInstall early return) never threaded capabilityRegistry into
|
||||
// installOpencodeFamilySkills, so the actual #2322 seam
|
||||
// (install-profiles.cjs stageSkillsForRuntimeAsSkills) — already reachable
|
||||
// and correct for the surface-apply path (`capability set --runtime opencode`)
|
||||
// — was never reached from a fresh/reapplied install. RED before the fix
|
||||
// (installOpencodeFamilyArtifacts/installOpencodeFamilySkills silently drop
|
||||
// the registry), GREEN after.
|
||||
|
||||
/** Install a fake already-installed third-party capability skill under a sandboxed GSD_HOME. */
|
||||
function install2362CapabilitySkill(gsdHome, capId, stem, content) {
|
||||
const skillDir = path.join(gsdHome, '.gsd', 'capabilities', capId, 'skills', stem);
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal capability-registry shape carrying one capId -> [stems] cluster.
|
||||
* `tier` (optional) additionally sets profileMembership so resolveProfile()
|
||||
* with a tiered (non-'*') mode unions this capability's stems into
|
||||
* resolvedProfile.skills — mirrors the seam's own __registryFor helper in
|
||||
* tests/runtime-artifact-layout-install-profiles.test.cjs.
|
||||
*/
|
||||
function registry2362For(capId, stems, tier) {
|
||||
const registry = { capabilityClusters: { [capId]: stems } };
|
||||
if (tier) {
|
||||
registry.profileMembership = {
|
||||
[capId]: { tier, profiles: tier === 'core' ? ['core', 'standard', 'full'] : ['standard', 'full'] },
|
||||
};
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
/** Run `fn` with GSD_HOME pointed at `gsdHome`, always restoring the prior value. */
|
||||
function with2362GsdHome(gsdHome, fn) {
|
||||
const saved = process.env.GSD_HOME;
|
||||
process.env.GSD_HOME = gsdHome;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.GSD_HOME;
|
||||
else process.env.GSD_HOME = saved;
|
||||
}
|
||||
}
|
||||
|
||||
/** Recursively snapshot every FILE under dir into a Map<relPath, content>. */
|
||||
function snapshot2362Dir(dir) {
|
||||
const snap = new Map();
|
||||
const walk = (rel) => {
|
||||
const abs = path.join(dir, rel);
|
||||
for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
|
||||
const relChild = path.join(rel, entry.name);
|
||||
if (entry.isDirectory()) walk(relChild);
|
||||
else if (entry.isFile()) snap.set(relChild, fs.readFileSync(path.join(dir, relChild), 'utf8'));
|
||||
}
|
||||
};
|
||||
walk('.');
|
||||
return snap;
|
||||
}
|
||||
|
||||
/** Assert every entry captured in `before` (a snapshot2362Dir Map) still exists, byte-identical, under dir. */
|
||||
function assert2362SnapshotSubsetPreserved(before, dir, label) {
|
||||
for (const [relChild, beforeContent] of before) {
|
||||
const candidatePath = path.join(dir, relChild);
|
||||
assert.ok(fs.existsSync(candidatePath), `${label}: ${relChild} must still exist`);
|
||||
assert.strictEqual(fs.readFileSync(candidatePath, 'utf8'), beforeContent, `${label}: ${relChild} must be byte-identical`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const __runtime2362 of ['opencode', 'kilo']) {
|
||||
describe(`installRuntimeArtifacts — #2362 ${__runtime2362} materializes an installed third-party capability skill`, () => {
|
||||
test(`${__runtime2362}: (1) registered capability skill materializes at skills/gsd-<stem>/SKILL.md`, (t) => {
|
||||
const gsdHome = createTempDir('gsd-2362-home-');
|
||||
const configDir = createTempDir(`gsd-2362-${__runtime2362}-cfg-`);
|
||||
t.after(() => { cleanup(gsdHome); cleanup(configDir); });
|
||||
|
||||
const AUTHORED = '---\nname: my-thing\ndescription: third-party test skill\n---\n\n# My Thing\nThird-party capability content.\n';
|
||||
install2362CapabilitySkill(gsdHome, 'my-thing', 'my-thing', AUTHORED);
|
||||
const registry = registry2362For('my-thing', ['my-thing']);
|
||||
const resolvedFull = resolveProfile({ modes: ['full'] });
|
||||
|
||||
with2362GsdHome(gsdHome, () => {
|
||||
installRuntimeArtifacts(__runtime2362, configDir, 'global', resolvedFull, () => undefined, registry);
|
||||
});
|
||||
|
||||
const stagedPath = path.join(configDir, 'skills', 'gsd-my-thing', 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(stagedPath),
|
||||
`#2362: gsd-my-thing/SKILL.md must exist after installRuntimeArtifacts('${__runtime2362}', ...) — registry says surfaced:true but the combined-family install path never materialized it`,
|
||||
);
|
||||
|
||||
// (2) prune parity: the staged directory carries the #2322 HIGH-3 marker
|
||||
// naming its declaring capId, matching the seam's guarantee.
|
||||
const markerPath = path.join(configDir, 'skills', 'gsd-my-thing', CAPABILITY_SKILL_MARKER);
|
||||
assert.ok(fs.existsSync(markerPath), 'staged third-party skill must carry the CAPABILITY_SKILL_MARKER for prune parity');
|
||||
assert.strictEqual(fs.readFileSync(markerPath, 'utf8').trim(), 'my-thing', 'marker must name the declaring capId');
|
||||
});
|
||||
|
||||
test(`${__runtime2362}: (2) tiered (non-'*') profile — resolvedProfile.skills is a concrete Set and still materializes the third-party skill`, (t) => {
|
||||
const gsdHome = createTempDir('gsd-2362-home-');
|
||||
const configDir = createTempDir(`gsd-2362-${__runtime2362}-cfg-tiered-`);
|
||||
t.after(() => { cleanup(gsdHome); cleanup(configDir); });
|
||||
|
||||
install2362CapabilitySkill(gsdHome, 'my-thing', 'my-thing', '# my-thing (tiered)\n');
|
||||
// tier='standard' -> profileMembership includes 'standard', so
|
||||
// resolveProfile({modes:['standard'], registry}) unions 'my-thing' into
|
||||
// the resolved Set BEFORE it ever reaches installOpencodeFamilySkills —
|
||||
// exercising the `(resolvedProfile && resolvedProfile.skills) || []`
|
||||
// (non-'*') branch of the new candidateStems ternary, never covered by
|
||||
// the mode=['full'] cases above.
|
||||
const registry = registry2362For('my-thing', ['my-thing'], 'standard');
|
||||
const resolvedTiered = resolveProfile({ modes: ['standard'], registry });
|
||||
assert.ok(
|
||||
resolvedTiered.skills instanceof Set,
|
||||
"sanity: a tiered (non-'*') profile mode must resolve to a concrete Set, not the full sentinel",
|
||||
);
|
||||
assert.ok(
|
||||
resolvedTiered.skills.has('my-thing'),
|
||||
'sanity: resolveProfile must union the registered capability skill into the tiered profile\'s Set (mirrors production callers)',
|
||||
);
|
||||
|
||||
with2362GsdHome(gsdHome, () => {
|
||||
installRuntimeArtifacts(__runtime2362, configDir, 'global', resolvedTiered, () => undefined, registry);
|
||||
});
|
||||
|
||||
const stagedPath = path.join(configDir, 'skills', 'gsd-my-thing', 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(stagedPath),
|
||||
`#2362: a tiered profile (mode=standard) must also materialize a registered third-party capability skill via the non-'*' candidateStems branch`,
|
||||
);
|
||||
const markerPath = path.join(configDir, 'skills', 'gsd-my-thing', CAPABILITY_SKILL_MARKER);
|
||||
assert.ok(fs.existsSync(markerPath), 'staged third-party skill must carry the CAPABILITY_SKILL_MARKER for prune parity even on a tiered profile');
|
||||
assert.strictEqual(fs.readFileSync(markerPath, 'utf8').trim(), 'my-thing', 'marker must name the declaring capId');
|
||||
|
||||
// Sanity: first-party skills that ARE part of the 'standard' base list
|
||||
// (e.g. 'help') must still stage too — the third-party fill-in must not
|
||||
// replace or crowd out the tiered profile's own first-party selection.
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md')),
|
||||
"sanity: first-party 'standard' base skills must still stage alongside the third-party fill-in",
|
||||
);
|
||||
});
|
||||
|
||||
test(`${__runtime2362}: (3) default full profile — resolvedProfile.skills === '*' still materializes the third-party skill (BLOCKER-2 parity)`, (t) => {
|
||||
const gsdHome = createTempDir('gsd-2362-home-');
|
||||
const configDir = createTempDir(`gsd-2362-${__runtime2362}-cfg-full-`);
|
||||
t.after(() => { cleanup(gsdHome); cleanup(configDir); });
|
||||
|
||||
install2362CapabilitySkill(gsdHome, 'my-thing', 'my-thing', '# my-thing (full)\n');
|
||||
const registry = registry2362For('my-thing', ['my-thing']);
|
||||
const resolvedFull = resolveProfile({ modes: ['full'] });
|
||||
assert.strictEqual(resolvedFull.skills, '*', "sanity: full mode resolves to the '*' sentinel");
|
||||
|
||||
with2362GsdHome(gsdHome, () => {
|
||||
installRuntimeArtifacts(__runtime2362, configDir, 'global', resolvedFull, () => undefined, registry);
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(configDir, 'skills', 'gsd-my-thing', 'SKILL.md')),
|
||||
"BLOCKER-2 parity: the '*' full-profile sentinel must still stage a registered capability skill",
|
||||
);
|
||||
});
|
||||
|
||||
test(`${__runtime2362}: (4) first-party wins on a stem collision`, (t) => {
|
||||
const gsdHome = createTempDir('gsd-2362-home-');
|
||||
const configDir = createTempDir(`gsd-2362-${__runtime2362}-cfg-collide-`);
|
||||
t.after(() => { cleanup(gsdHome); cleanup(configDir); });
|
||||
|
||||
const EVIL = '# EVIL HELP — must never win over the first-party gsd-help skill\n';
|
||||
install2362CapabilitySkill(gsdHome, 'evil-cap', 'help', EVIL);
|
||||
const registry = registry2362For('evil-cap', ['help']);
|
||||
const resolvedFull = resolveProfile({ modes: ['full'] });
|
||||
|
||||
with2362GsdHome(gsdHome, () => {
|
||||
installRuntimeArtifacts(__runtime2362, configDir, 'global', resolvedFull, () => undefined, registry);
|
||||
});
|
||||
|
||||
const helpPath = path.join(configDir, 'skills', 'gsd-help', 'SKILL.md');
|
||||
assert.ok(fs.existsSync(helpPath), 'sanity: first-party gsd-help must stage');
|
||||
const helpContent = fs.readFileSync(helpPath, 'utf8');
|
||||
assert.notStrictEqual(helpContent, EVIL, 'the third-party EVIL content must never win the collision');
|
||||
assert.ok(!helpContent.includes('EVIL HELP'), 'first-party content must be staged on a stem collision, not the third-party body');
|
||||
});
|
||||
|
||||
test(`${__runtime2362}: (5) graceful degradation — an unreadable capability SKILL.md does not throw and first-party staging is unaffected`, (t) => {
|
||||
const gsdHome = createTempDir('gsd-2362-home-');
|
||||
const configDir = createTempDir(`gsd-2362-${__runtime2362}-cfg-degrade-`);
|
||||
t.after(() => { cleanup(gsdHome); cleanup(configDir); });
|
||||
|
||||
install2362CapabilitySkill(gsdHome, 'my-thing', 'my-thing', '# my-thing\n');
|
||||
const registry = registry2362For('my-thing', ['my-thing']);
|
||||
const resolvedFull = resolveProfile({ modes: ['full'] });
|
||||
|
||||
// Inject the IO failure deterministically by monkeypatching fs, scoped to
|
||||
// this one capability's SKILL.md path — never chmod 0o000 (root bypasses
|
||||
// mode bits; silently zero-coverage under root Docker/CI).
|
||||
const skillPath = path.join(gsdHome, '.gsd', 'capabilities', 'my-thing', 'skills', 'my-thing', 'SKILL.md');
|
||||
const origReadFileSync = fs.readFileSync;
|
||||
fs.readFileSync = function injectedReadFileSync(p, ...rest) {
|
||||
if (p === skillPath) throw new Error('injected read failure (#2362 regression test)');
|
||||
return origReadFileSync.call(fs, p, ...rest);
|
||||
};
|
||||
try {
|
||||
with2362GsdHome(gsdHome, () => {
|
||||
assert.doesNotThrow(() => {
|
||||
installRuntimeArtifacts(__runtime2362, configDir, 'global', resolvedFull, () => undefined, registry);
|
||||
}, `installRuntimeArtifacts('${__runtime2362}', ...) must not throw when a registry-referenced capability skill is unreadable`);
|
||||
});
|
||||
} finally {
|
||||
fs.readFileSync = origReadFileSync;
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(configDir, 'skills', 'gsd-my-thing', 'SKILL.md')),
|
||||
'an unreadable capability skill must be skipped, not partially staged',
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md')),
|
||||
'first-party skills must still materialize when a referenced capability skill is unreadable',
|
||||
);
|
||||
});
|
||||
|
||||
test(`${__runtime2362}: (6) control — first-party skills are unperturbed (byte-identical) by an installed third-party capability skill`, (t) => {
|
||||
const gsdHome = createTempDir('gsd-2362-home-');
|
||||
const configDir = createTempDir(`gsd-2362-${__runtime2362}-cfg-control-`);
|
||||
t.after(() => { cleanup(gsdHome); cleanup(configDir); });
|
||||
|
||||
const resolvedFull = resolveProfile({ modes: ['full'] });
|
||||
|
||||
// Baseline: no registry threaded through at all.
|
||||
installRuntimeArtifacts(__runtime2362, configDir, 'global', resolvedFull, () => undefined, undefined);
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
const baselineSnapshot = snapshot2362Dir(skillsDir);
|
||||
assert.ok(baselineSnapshot.size > 0, 'sanity: baseline install staged at least one first-party skill');
|
||||
assert.ok(!baselineSnapshot.has(path.join('gsd-my-thing', 'SKILL.md')), 'sanity: baseline has no gsd-my-thing');
|
||||
|
||||
// Re-apply to the SAME configDir once a third-party capability is also
|
||||
// registered+installed — rebuilds nothing else, so any first-party byte
|
||||
// drift here is attributable only to the third-party fill-in pass.
|
||||
install2362CapabilitySkill(gsdHome, 'my-thing', 'my-thing', '# my-thing\n');
|
||||
const registry = registry2362For('my-thing', ['my-thing']);
|
||||
with2362GsdHome(gsdHome, () => {
|
||||
installRuntimeArtifacts(__runtime2362, configDir, 'global', resolvedFull, () => undefined, registry);
|
||||
});
|
||||
|
||||
assert2362SnapshotSubsetPreserved(baselineSnapshot, skillsDir, `${__runtime2362} first-party skills`);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(skillsDir, 'gsd-my-thing', 'SKILL.md')),
|
||||
'the third-party capability skill must ALSO be present after the re-apply',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Section 7: uninstallRuntimeArtifacts — all runtimes ─────────────────────
|
||||
|
||||
describe('uninstallRuntimeArtifacts — consumes Runtime Artifact Uninstall Plan Module', () => {
|
||||
|
||||
Reference in New Issue
Block a user