fix(#1091): thread install scope into skill converters so local Antigravity/Copilot installs use workspace paths (#1092)
The skills layout wrapper (skillsKind) invoked every per-runtime skill converter as realConverter(content, skillName, runtime, cmdNames). The 3rd positional arg is overloaded: claude/kimi/cline converters read `runtime` there, but the copilot/antigravity converters read `isGlobal` there — so they received the truthy runtime string and always took the global path branch, leaking ~/.gemini/antigravity/ and ~/.copilot/ into local/workspace installs instead of .agent/ and .github/. Thread `scope` from resolveRuntimeArtifactLayout -> dispatchKindEntry -> skillsKind, derive isGlobal = scope === 'global', and pass it as a non-colliding 5th positional arg. Move isGlobal out of the colliding 3rd slot in the two converter signatures (3rd/4th become ignored _runtime/_cmdNames, matching the kimi convention). The fix flows through the shared ArtifactKind.stage closure, so applySurface re-apply inherits it via the same seam. Regression test exercises the wrapper seam (installRuntimeArtifacts at local scope) for both runtimes and asserts workspace paths, not global. Closes #1091 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/steady-otters-leap.md
Normal file
5
.changeset/steady-otters-leap.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
type: Fixed
|
||||||
|
pr: 1092
|
||||||
|
---
|
||||||
|
**Workspace (local) Antigravity and Copilot skill installs no longer point at the global config home** — a local install rewrote `~/.claude/` references in `SKILL.md` bodies to the global `~/.gemini/antigravity/` / `~/.copilot/` paths instead of the workspace-relative `.agent/` / `.github/`, because the skills layout wrapper passed the runtime name into the converter's `isGlobal` parameter slot. (#1092)
|
||||||
@@ -1737,12 +1737,13 @@ function convertClaudeToCopilotContent(content, isGlobal = false) {
|
|||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isGlobal is the 5th positional arg (3rd/4th are runtime/cmdNames passed by the skills wrapper). See runtime-artifact-layout skillsKind.
|
||||||
/**
|
/**
|
||||||
* Convert a Claude command (.md) to a Copilot skill (SKILL.md).
|
* Convert a Claude command (.md) to a Copilot skill (SKILL.md).
|
||||||
* Transforms frontmatter only — body passes through with CONV-06/07 applied.
|
* Transforms frontmatter only — body passes through with CONV-06/07 applied.
|
||||||
* Skills keep original tool names (no mapping) per CONTEXT.md decision.
|
* Skills keep original tool names (no mapping) per CONTEXT.md decision.
|
||||||
*/
|
*/
|
||||||
function convertClaudeCommandToCopilotSkill(content, skillName, isGlobal = false) {
|
function convertClaudeCommandToCopilotSkill(content, skillName, _runtime = null, _cmdNames = null, isGlobal = false) {
|
||||||
const converted = convertClaudeToCopilotContent(content, isGlobal);
|
const converted = convertClaudeToCopilotContent(content, isGlobal);
|
||||||
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
||||||
if (!frontmatter) return converted;
|
if (!frontmatter) return converted;
|
||||||
@@ -2240,12 +2241,13 @@ function convertClaudeToAntigravityContent(content, isGlobal = false) {
|
|||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isGlobal is the 5th positional arg (3rd/4th are runtime/cmdNames passed by the skills wrapper). See runtime-artifact-layout skillsKind.
|
||||||
/**
|
/**
|
||||||
* Convert a Claude command (.md) to an Antigravity skill (SKILL.md).
|
* Convert a Claude command (.md) to an Antigravity skill (SKILL.md).
|
||||||
* Transforms frontmatter to minimal name + description only.
|
* Transforms frontmatter to minimal name + description only.
|
||||||
* Body passes through with path/command conversions applied.
|
* Body passes through with path/command conversions applied.
|
||||||
*/
|
*/
|
||||||
function convertClaudeCommandToAntigravitySkill(content, skillName, isGlobal = false) {
|
function convertClaudeCommandToAntigravitySkill(content, skillName, _runtime = null, _cmdNames = null, isGlobal = false) {
|
||||||
const converted = convertClaudeToAntigravityContent(content, isGlobal);
|
const converted = convertClaudeToAntigravityContent(content, isGlobal);
|
||||||
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
||||||
if (!frontmatter) return converted;
|
if (!frontmatter) return converted;
|
||||||
|
|||||||
@@ -251,6 +251,10 @@ function kimiAgentsKind(destSubpath: string, prefix: string, configDir: string):
|
|||||||
* @param runtime canonical runtime ID (gates Hermes/Qwen branding in converter)
|
* @param runtime canonical runtime ID (gates Hermes/Qwen branding in converter)
|
||||||
* @param configDir runtime config dir (for .gsd-source marker resolution)
|
* @param configDir runtime config dir (for .gsd-source marker resolution)
|
||||||
* @param nested if true, nest concrete skills under their ns-* routers (#69)
|
* @param nested if true, nest concrete skills under their ns-* routers (#69)
|
||||||
|
* @param scope install scope; converted to isGlobal and passed as 5th positional
|
||||||
|
* arg so scope-aware converters (antigravity, copilot) can choose
|
||||||
|
* between global home paths and workspace-relative paths without
|
||||||
|
* colliding with the `runtime` string at position 3.
|
||||||
*/
|
*/
|
||||||
function skillsKind(
|
function skillsKind(
|
||||||
destSubpath: string,
|
destSubpath: string,
|
||||||
@@ -259,6 +263,7 @@ function skillsKind(
|
|||||||
runtime: string,
|
runtime: string,
|
||||||
configDir: string,
|
configDir: string,
|
||||||
nested = false,
|
nested = false,
|
||||||
|
scope: 'local' | 'global' = 'global',
|
||||||
): ArtifactKind {
|
): ArtifactKind {
|
||||||
return {
|
return {
|
||||||
kind: 'skills',
|
kind: 'skills',
|
||||||
@@ -266,12 +271,17 @@ function skillsKind(
|
|||||||
prefix,
|
prefix,
|
||||||
stage: (resolved) => {
|
stage: (resolved) => {
|
||||||
const installExports = getInstallExports();
|
const installExports = getInstallExports();
|
||||||
const realConverter = installExports[converterName] as (content: string, skillName: string, runtime: string, cmdNames: string[]) => string;
|
const realConverter = installExports[converterName] as (content: string, skillName: string, runtime: string, cmdNames: string[], isGlobal: boolean) => string;
|
||||||
// Compute cmdNames once per stage call for performance (#3583).
|
// Compute cmdNames once per stage call for performance (#3583).
|
||||||
// Extra args are ignored by converters that don't need runtime/cmdNames.
|
// Extra trailing args are ignored by converters that don't need them. The
|
||||||
|
// isGlobal flag is the 5th positional (NOT the 3rd): the 3rd positional is
|
||||||
|
// `runtime` for the claude/kimi/cline converters, so the scope-aware
|
||||||
|
// converters (antigravity, copilot) read isGlobal from position 5 to avoid
|
||||||
|
// colliding with `runtime` and always taking the global branch.
|
||||||
const cmdNames = installExports.readGsdCommandNames();
|
const cmdNames = installExports.readGsdCommandNames();
|
||||||
|
const isGlobal = scope === 'global';
|
||||||
const wrappedConverter = (content: string, skillName: string): string =>
|
const wrappedConverter = (content: string, skillName: string): string =>
|
||||||
realConverter(content, skillName, runtime, cmdNames);
|
realConverter(content, skillName, runtime, cmdNames, isGlobal);
|
||||||
return stageSkillsForRuntimeAsSkills(findInstallSourceRoot(configDir), resolved, wrappedConverter, prefix, nested);
|
return stageSkillsForRuntimeAsSkills(findInstallSourceRoot(configDir), resolved, wrappedConverter, prefix, nested);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -382,7 +392,7 @@ function getRegistry(): { runtimes: Record<string, { runtime?: { artifactLayout?
|
|||||||
* Map a single ArtifactKindDescriptor entry to an ArtifactKind using the
|
* Map a single ArtifactKindDescriptor entry to an ArtifactKind using the
|
||||||
* matching builder function. Mirrors the hand-built calls in the old switch.
|
* matching builder function. Mirrors the hand-built calls in the old switch.
|
||||||
*/
|
*/
|
||||||
function dispatchKindEntry(entry: ArtifactKindDescriptor, runtime: string, configDir: string): ArtifactKind {
|
function dispatchKindEntry(entry: ArtifactKindDescriptor, runtime: string, configDir: string, scope: 'local' | 'global'): ArtifactKind {
|
||||||
const { kind, destSubpath, prefix, nesting, converter } = entry;
|
const { kind, destSubpath, prefix, nesting, converter } = entry;
|
||||||
const nested = nesting === 'nested';
|
const nested = nesting === 'nested';
|
||||||
|
|
||||||
@@ -402,7 +412,7 @@ function dispatchKindEntry(entry: ArtifactKindDescriptor, runtime: string, confi
|
|||||||
`resolveRuntimeArtifactLayout: skills entry for '${runtime}' has converter=null (converter is required for skills)`,
|
`resolveRuntimeArtifactLayout: skills entry for '${runtime}' has converter=null (converter is required for skills)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return skillsKind(destSubpath, prefix, converter, runtime, configDir, nested);
|
return skillsKind(destSubpath, prefix, converter, runtime, configDir, nested, scope);
|
||||||
|
|
||||||
case 'kimi-agents':
|
case 'kimi-agents':
|
||||||
return kimiAgentsKind(destSubpath, prefix, configDir);
|
return kimiAgentsKind(destSubpath, prefix, configDir);
|
||||||
@@ -438,7 +448,7 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const entries: ArtifactKindDescriptor[] = desc[scope] ?? [];
|
const entries: ArtifactKindDescriptor[] = desc[scope] ?? [];
|
||||||
const kinds: ArtifactKind[] = entries.map((entry) => dispatchKindEntry(entry, runtime, configDir));
|
const kinds: ArtifactKind[] = entries.map((entry) => dispatchKindEntry(entry, runtime, configDir, scope));
|
||||||
|
|
||||||
return { runtime, configDir, scope, kinds };
|
return { runtime, configDir, scope, kinds };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -538,7 +538,7 @@ description: Test skill
|
|||||||
|
|
||||||
Check ~/.claude/settings and ./.claude/local and $HOME/.claude/global.`;
|
Check ~/.claude/settings and ./.claude/local and $HOME/.claude/global.`;
|
||||||
|
|
||||||
const result = convertClaudeCommandToCopilotSkill(input, 'gsd-test', true);
|
const result = convertClaudeCommandToCopilotSkill(input, 'gsd-test', null, null, true);
|
||||||
assert.ok(result.includes('~/.copilot/settings'), 'tilde path converted to global');
|
assert.ok(result.includes('~/.copilot/settings'), 'tilde path converted to global');
|
||||||
assert.ok(result.includes('./.github/local'), 'dot-slash path converted');
|
assert.ok(result.includes('./.github/local'), 'dot-slash path converted');
|
||||||
assert.ok(result.includes('$HOME/.copilot/global'), '$HOME path converted to global');
|
assert.ok(result.includes('$HOME/.copilot/global'), '$HOME path converted to global');
|
||||||
|
|||||||
@@ -75,6 +75,21 @@ function writeCommandEntry(destDir, prefix, stem) {
|
|||||||
fs.writeFileSync(path.join(destDir, `${prefix}${stem}.md`), `# ${stem}\n`);
|
fs.writeFileSync(path.join(destDir, `${prefix}${stem}.md`), `# ${stem}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readAllSkillMd(dir) {
|
||||||
|
const out = [];
|
||||||
|
if (!fs.existsSync(dir)) return '';
|
||||||
|
const stack = [dir];
|
||||||
|
while (stack.length) {
|
||||||
|
const cur = stack.pop();
|
||||||
|
for (const ent of fs.readdirSync(cur, { withFileTypes: true })) {
|
||||||
|
const p = path.join(cur, ent.name);
|
||||||
|
if (ent.isDirectory()) stack.push(p);
|
||||||
|
else if (ent.name === 'SKILL.md') out.push(fs.readFileSync(p, 'utf8'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
describe('installRuntimeArtifacts — skills runtimes write gsd-prefixed skill dirs', () => {
|
describe('installRuntimeArtifacts — skills runtimes write gsd-prefixed skill dirs', () => {
|
||||||
for (const runtime of SKILLS_RUNTIMES_LAYOUT) {
|
for (const runtime of SKILLS_RUNTIMES_LAYOUT) {
|
||||||
test(`${runtime}: gsd-prefixed skill dirs in skills/`, (t) => {
|
test(`${runtime}: gsd-prefixed skill dirs in skills/`, (t) => {
|
||||||
@@ -516,3 +531,44 @@ describe('uninstallRuntimeArtifacts — legacy cleanup runs before layout remova
|
|||||||
assert.ok(fs.existsSync(path.join(userSkill, 'SKILL.md')));
|
assert.ok(fs.existsSync(path.join(userSkill, 'SKILL.md')));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('skills wrapper threads install scope into converter isGlobal (regression: local installs must not leak global home paths)', () => {
|
||||||
|
// Bug: the skills wrapper in runtime-artifact-layout passed `runtime` (a truthy
|
||||||
|
// string) as the converter's 3rd positional arg. For antigravity/copilot that
|
||||||
|
// param was `isGlobal`, so LOCAL installs always took the GLOBAL path branch and
|
||||||
|
// leaked ~/.gemini/antigravity or ~/.copilot instead of the workspace path.
|
||||||
|
for (const { runtime, globalMarker, localMarker } of [
|
||||||
|
{ runtime: 'antigravity', globalMarker: '~/.gemini/antigravity', localMarker: '.agents' },
|
||||||
|
{ runtime: 'copilot', globalMarker: '~/.copilot', localMarker: '.github' },
|
||||||
|
]) {
|
||||||
|
test(`${runtime}: local skill content uses workspace path, not global home`, (t) => {
|
||||||
|
const globalDir = createTempDir(`gsd-ial-g-${runtime}-`);
|
||||||
|
const localDir = createTempDir(`gsd-ial-l-${runtime}-`);
|
||||||
|
t.after(() => { cleanup(globalDir); cleanup(localDir); });
|
||||||
|
|
||||||
|
installRuntimeArtifacts(runtime, globalDir, 'global', RESOLVED_CORE);
|
||||||
|
installRuntimeArtifacts(runtime, localDir, 'local', RESOLVED_CORE);
|
||||||
|
|
||||||
|
const gSkills = resolveRuntimeArtifactLayout(runtime, globalDir, 'global').kinds.find(k => k.kind === 'skills');
|
||||||
|
const lSkills = resolveRuntimeArtifactLayout(runtime, localDir, 'local').kinds.find(k => k.kind === 'skills');
|
||||||
|
assert.ok(gSkills && lSkills, `${runtime}: must resolve a skills kind for both scopes`);
|
||||||
|
|
||||||
|
const gCombined = readAllSkillMd(path.join(globalDir, gSkills.destSubpath));
|
||||||
|
const lCombined = readAllSkillMd(path.join(localDir, lSkills.destSubpath));
|
||||||
|
|
||||||
|
// Precondition (non-vacuity guard): some core skill carries a ~/.claude
|
||||||
|
// reference, so the GLOBAL install surfaces the global home marker. If this
|
||||||
|
// assertion ever fails, the source skills lost their path references — fix
|
||||||
|
// the fixture/source, do not delete this test.
|
||||||
|
assert.ok(gCombined.includes(globalMarker),
|
||||||
|
`${runtime}: precondition — global install should contain '${globalMarker}'`);
|
||||||
|
|
||||||
|
// The actual regression: a LOCAL install must NOT leak the global home path…
|
||||||
|
assert.ok(!lCombined.includes(globalMarker),
|
||||||
|
`${runtime}: local install must NOT leak global home path '${globalMarker}'`);
|
||||||
|
// …and SHOULD reference the workspace-relative path.
|
||||||
|
assert.ok(lCombined.includes(localMarker),
|
||||||
|
`${runtime}: local install must reference workspace path '${localMarker}'`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user