* feat(#784): emit native skills for OpenCode + Kilo runtimes OpenCode and Kilo share a config schema and both discover on-demand skills from skills/<name>/SKILL.md. The installer previously emitted only flat commands (command/) and file-based agents (agents/) for these runtimes. Add a shared OpenCode-family skill writer that stages each GSD command as a spec-compliant SKILL.md (name matching the directory, description 1-1024 chars), wired through the runtime artifact layout so uninstall cleans skills/ automatically. Skills respect the active install profile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#784): correct skill body paths + preserve user dev-preferences Address adversarial-review findings: - Add opencode/kilo cases to _applyRuntimeRewrites so staged SKILL.md bodies are re-pointed from the converter's hardcoded default config dir to the actual install target (fixes --local / --config-dir installs; commands/agents already did this by applying pathPrefix pre-conversion). - Preserve user-owned skills/gsd-dev-preferences across reinstall in installOpencodeFamilySkills (snapshot+restore around the gsd-* prune), matching installRuntimeArtifacts. - Export installOpencodeFamilySkills and add regression tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#784): guarantee command/skill body parity, fix kilo-alt double-rewrite Follow-up adversarial-review found the post-conversion path rewrite could double-rewrite custom Kilo dirs (kilo -> kilo-alt -> kilo-alt-alt) because the kilo pathPrefix is a $HOME (non-absolute) superset of the hardcoded default base. Restructure so OpenCode/Kilo skills mirror copyFlattenedCommands exactly: stage raw commands, apply pathPrefix BEFORE conversion via a new shared applyOpencodeFamilyPathPrefix() helper (now used by both the command and skill writers), then convert. This guarantees byte-for-byte command/ skill body parity for global, --local, and --config-dir installs and removes the prefix-overlap hazard. Drop the fragile _applyRuntimeRewrites opencode/ kilo case. Strengthen the path regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(#784): derive opencode/kilo skills from the same staged command set Pass the installer's _stageSkills() output directly to installOpencodeFamilySkills instead of re-staging via the layout, so the command/ and skills/ surfaces always cover the identical profile-resolved set — including the --minimal/--core-only alias path, which stages differently from a plain --profile=core. Verified: minimal install now emits 8 commands and 8 skills. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#784): set changeset PR number to 810 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#784): fully escape backslashes in test helper (CodeQL js/incomplete-string-escaping) Replace the dot-only escape `replace(/[.]/g, '\\.')` with a complete regex-escape pattern `replace(/[\\.*+?^${}()|[\]]/g, '\\$&')` so all regex metacharacters (including backslash itself) in `defaultBase` are safely escaped before interpolation into `new RegExp(...)`. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
installRuntimeArtifacts,
|
||||
installOpencodeFamilySkills,
|
||||
parseRuntimeInput,
|
||||
allRuntimes,
|
||||
} = require('../bin/install.js');
|
||||
@@ -187,6 +188,83 @@ describe('installRuntimeArtifacts — opencode / kilo flat commands', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─── #784: installOpencodeFamilySkills — skills + path rewrite + preservation ─
|
||||
|
||||
// Stage the raw command set the way the installer's _stageSkills() does, so the
|
||||
// skills writer receives the same input as the flattened-command writer.
|
||||
function stageRawCommands(runtime, configDir) {
|
||||
const layout = resolveRuntimeArtifactLayout(runtime, configDir, 'global');
|
||||
const commandsKind = layout.kinds.find((k) => k.kind === 'commands');
|
||||
return commandsKind.stage(RESOLVED_CORE);
|
||||
}
|
||||
|
||||
describe('installOpencodeFamilySkills — emits skills/<name>/SKILL.md (#784)', () => {
|
||||
for (const runtime of ['opencode', 'kilo']) {
|
||||
test(`${runtime}: writes gsd-help/SKILL.md with name + description`, (t) => {
|
||||
const configDir = createTempDir(`gsd-ocs-${runtime}-`);
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const raw = stageRawCommands(runtime, configDir);
|
||||
const count = installOpencodeFamilySkills(runtime, configDir, raw, `${configDir}/`);
|
||||
assert.ok(count >= 1, 'should report installed skills');
|
||||
|
||||
const skillMd = path.join(configDir, 'skills', 'gsd-help', 'SKILL.md');
|
||||
assert.ok(fs.existsSync(skillMd), 'gsd-help/SKILL.md must exist');
|
||||
const content = fs.readFileSync(skillMd, 'utf8');
|
||||
assert.match(content, /^name: gsd-help$/m, 'name matches dir');
|
||||
assert.match(content, /^description: /m, 'description present');
|
||||
assert.ok(!/\/gsd:/.test(content), 'no /gsd: colon refs in body');
|
||||
});
|
||||
|
||||
test(`${runtime}: rewrites body paths to the actual install target (#784 path fix)`, (t) => {
|
||||
const configDir = createTempDir(`gsd-ocp-${runtime}-`);
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
// Simulate a custom/local install: pathPrefix points at configDir, NOT the
|
||||
// runtime's default global config dir. Body refs must use pathPrefix.
|
||||
const pathPrefix = `${configDir}/`;
|
||||
installOpencodeFamilySkills(runtime, configDir, stageRawCommands(runtime, configDir), pathPrefix);
|
||||
|
||||
const defaultBase = runtime === 'kilo' ? '.config/kilo' : '.config/opencode';
|
||||
const help = fs.readFileSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md'), 'utf8');
|
||||
// gsd-help references gsd-core workflow files via @<configDir>/gsd-core/...
|
||||
assert.ok(
|
||||
help.includes(`${configDir}/gsd-core/`),
|
||||
'gsd-help body must reference the actual install target via pathPrefix',
|
||||
);
|
||||
for (const skillName of fs.readdirSync(path.join(configDir, 'skills'))) {
|
||||
const body = fs.readFileSync(path.join(configDir, 'skills', skillName, 'SKILL.md'), 'utf8');
|
||||
assert.ok(
|
||||
!body.includes(`~/${defaultBase}/`),
|
||||
`${skillName}: must not leak hardcoded ~/${defaultBase}/ — should use install target`,
|
||||
);
|
||||
// Regression guard for the prefix-overlap double-rewrite (e.g. kilo-alt-alt).
|
||||
assert.ok(
|
||||
!new RegExp(`${defaultBase.replace(/[\\.*+?^${}()|[\]]/g, '\\$&')}-[^/\\s]*-`).test(body),
|
||||
`${skillName}: must not contain a doubled config-dir suffix`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${runtime}: preserves user-owned gsd-dev-preferences across reinstall (#784)`, (t) => {
|
||||
const configDir = createTempDir(`gsd-ocd-${runtime}-`);
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const userSkill = path.join(configDir, 'skills', 'gsd-dev-preferences');
|
||||
fs.mkdirSync(userSkill, { recursive: true });
|
||||
const marker = '---\nname: gsd-dev-preferences\ndescription: mine\n---\nKEEP ME\n';
|
||||
fs.writeFileSync(path.join(userSkill, 'SKILL.md'), marker);
|
||||
|
||||
installOpencodeFamilySkills(runtime, configDir, stageRawCommands(runtime, configDir), `${configDir}/`);
|
||||
|
||||
const after = fs.readFileSync(path.join(userSkill, 'SKILL.md'), 'utf8');
|
||||
assert.ok(after.includes('KEEP ME'), 'user-owned dev-preferences must survive reinstall');
|
||||
// GSD-managed skills should also be present.
|
||||
assert.ok(fs.existsSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md')));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Section 7: uninstallRuntimeArtifacts — all runtimes ─────────────────────
|
||||
|
||||
describe('uninstallRuntimeArtifacts — removes gsd-owned entries, preserves foreign', () => {
|
||||
|
||||
Reference in New Issue
Block a user