enh(#784): emit native skills for OpenCode + Kilo runtimes (#810)

* 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:
Tom Boucher
2026-06-07 16:13:06 -04:00
committed by GitHub
parent 10087a48f5
commit ea0d8f09b1
7 changed files with 411 additions and 27 deletions

View File

@@ -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', () => {

View File

@@ -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);

View File

@@ -18,6 +18,8 @@ const {
convertClaudeToOpencodeFrontmatter,
convertClaudeToKiloFrontmatter,
convertClaudeToGeminiAgent,
convertClaudeCommandToOpencodeSkill,
convertClaudeCommandToKiloSkill,
neutralizeAgentReferences,
} = require('../bin/install.js');
@@ -344,3 +346,70 @@ describe('neutralizeAgentReferences', () => {
assert.ok(result.includes('claude-code'), 'claude-code preserved');
});
});
// ─── OpenCode-family skill converters (SKILL.md) — #784 ──────────────────────
const SKILL_SAMPLE_COMMAND = `---
description: Show available GSD commands and usage guide
argument-hint: "[topic]"
allowed-tools:
- Read
- Bash
---
Run \`/gsd:help\` to see the guide. AskUserQuestion when unsure.
`;
const SKILL_BETA_COMMAND = `---
description: "[BETA] Offload plan phase to the cloud and import back."
---
Body for /gsd:ultraplan-phase.
`;
describe('convertClaudeCommandToOpencodeSkill / convertClaudeCommandToKiloSkill (#784)', () => {
const cases = [
{ label: 'opencode', convert: convertClaudeCommandToOpencodeSkill },
{ label: 'kilo', convert: convertClaudeCommandToKiloSkill },
];
for (const { label, convert } of cases) {
describe(`${label} skill conversion`, () => {
test('emits SKILL.md frontmatter with name matching the skill dir', () => {
const out = convert(SKILL_SAMPLE_COMMAND, 'gsd-help');
assert.ok(out.startsWith('---\n'), 'opens with frontmatter');
assert.match(out, /^name: gsd-help$/m, 'name equals the skill name');
});
test('preserves the description from the source command', () => {
const out = convert(SKILL_SAMPLE_COMMAND, 'gsd-help');
assert.match(out, /^description: "Show available GSD commands and usage guide"$/m);
});
test('drops the command tools/permission block (skills inherit perms)', () => {
const out = convert(SKILL_SAMPLE_COMMAND, 'gsd-help');
const fmEnd = out.indexOf('\n---', 4);
const fm = out.slice(0, fmEnd);
assert.ok(!/tools:/.test(fm), 'no tools block in skill frontmatter');
assert.ok(!/permission:/.test(fm), 'no permission block in skill frontmatter');
});
test('rewrites /gsd: colon refs to hyphen form in the body', () => {
const out = convert(SKILL_SAMPLE_COMMAND, 'gsd-help');
assert.ok(!/\/gsd:/.test(out), 'no /gsd: colon refs remain');
assert.match(out, /\/gsd-help/, 'colon ref rewritten to hyphen form');
});
test('quotes descriptions with leading YAML flow indicators ([BETA])', () => {
const out = convert(SKILL_BETA_COMMAND, 'gsd-ultraplan-phase');
assert.match(out, /^description: "\[BETA\] /m, 'leading [BETA] safely quoted');
});
test('falls back to a synthetic description when none present', () => {
const out = convert('Body only, no frontmatter.', 'gsd-mystery');
assert.match(out, /^name: gsd-mystery$/m);
assert.match(out, /^description: "Run GSD workflow gsd-mystery\."$/m);
});
});
}
});