* 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:
@@ -236,28 +236,44 @@ describe('resolveRuntimeArtifactLayout — cline', () => {
|
||||
});
|
||||
|
||||
describe('resolveRuntimeArtifactLayout — opencode', () => {
|
||||
test('returns correct layout for opencode', () => {
|
||||
test('returns commands + skills layout for opencode (#784)', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('opencode', FAKE_DIR);
|
||||
assert.strictEqual(layout.runtime, 'opencode');
|
||||
assert.strictEqual(layout.configDir, FAKE_DIR);
|
||||
assert.strictEqual(layout.kinds.length, 1);
|
||||
assert.strictEqual(layout.kinds[0].kind, 'commands');
|
||||
assert.strictEqual(layout.kinds[0].destSubpath, 'command');
|
||||
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
|
||||
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
||||
assert.strictEqual(layout.kinds.length, 2);
|
||||
|
||||
const commands = layout.kinds.find((k) => k.kind === 'commands');
|
||||
assert.ok(commands, 'should have a commands kind');
|
||||
assert.strictEqual(commands.destSubpath, 'command');
|
||||
assert.strictEqual(commands.prefix, 'gsd-');
|
||||
assert.strictEqual(typeof commands.stage, 'function');
|
||||
|
||||
const skills = layout.kinds.find((k) => k.kind === 'skills');
|
||||
assert.ok(skills, 'should have a skills kind');
|
||||
assert.strictEqual(skills.destSubpath, 'skills');
|
||||
assert.strictEqual(skills.prefix, 'gsd-');
|
||||
assert.strictEqual(typeof skills.stage, 'function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRuntimeArtifactLayout — kilo', () => {
|
||||
test('returns correct layout for kilo', () => {
|
||||
test('returns commands + skills layout for kilo (#784)', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('kilo', FAKE_DIR);
|
||||
assert.strictEqual(layout.runtime, 'kilo');
|
||||
assert.strictEqual(layout.configDir, FAKE_DIR);
|
||||
assert.strictEqual(layout.kinds.length, 1);
|
||||
assert.strictEqual(layout.kinds[0].kind, 'commands');
|
||||
assert.strictEqual(layout.kinds[0].destSubpath, 'command');
|
||||
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
|
||||
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
||||
assert.strictEqual(layout.kinds.length, 2);
|
||||
|
||||
const commands = layout.kinds.find((k) => k.kind === 'commands');
|
||||
assert.ok(commands, 'should have a commands kind');
|
||||
assert.strictEqual(commands.destSubpath, 'command');
|
||||
assert.strictEqual(commands.prefix, 'gsd-');
|
||||
assert.strictEqual(typeof commands.stage, 'function');
|
||||
|
||||
const skills = layout.kinds.find((k) => k.kind === 'skills');
|
||||
assert.ok(skills, 'should have a skills kind');
|
||||
assert.strictEqual(skills.destSubpath, 'skills');
|
||||
assert.strictEqual(skills.prefix, 'gsd-');
|
||||
assert.strictEqual(typeof skills.stage, 'function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -428,6 +444,33 @@ describe('stage — opencode commands kind', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('stage — opencode/kilo skills kind (#784)', () => {
|
||||
for (const runtime of ['opencode', 'kilo']) {
|
||||
test(`${runtime} skills stage writes gsd-<stem>/SKILL.md with name + description`, () => {
|
||||
const layout = resolveRuntimeArtifactLayout(runtime, FAKE_STAGE_DIR);
|
||||
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
|
||||
assert.ok(skillsKind, 'should have a skills kind');
|
||||
|
||||
const stagedDir = skillsKind.stage(PROFILE_CORE);
|
||||
assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist');
|
||||
const entries = fs.readdirSync(stagedDir);
|
||||
assert.ok(entries.length >= 1, 'at least one skill dir should be staged');
|
||||
for (const entry of entries) {
|
||||
assert.ok(entry.startsWith('gsd-'), `entry should start with gsd-: ${entry}`);
|
||||
const skillMd = path.join(stagedDir, entry, 'SKILL.md');
|
||||
assert.ok(fs.existsSync(skillMd), `SKILL.md must exist in ${entry}`);
|
||||
const content = fs.readFileSync(skillMd, 'utf8');
|
||||
// OpenCode skill spec: name must match the dir, description required.
|
||||
assert.ok(content.startsWith('---\n'), 'SKILL.md must open with frontmatter');
|
||||
assert.match(content, new RegExp(`^name: ${entry}$`, 'm'), `name must equal dir ${entry}`);
|
||||
assert.match(content, /^description: /m, 'description frontmatter required');
|
||||
// No colon-namespace command leaks in the converted body.
|
||||
assert.ok(!/\/gsd:/.test(content), 'body must not contain /gsd: colon refs');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('stage — cursor commands kind (#785)', () => {
|
||||
test('cursor commands kind stage returns directory with converted .md files', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('cursor', FAKE_STAGE_DIR);
|
||||
|
||||
Reference in New Issue
Block a user