diff --git a/.changeset/784-opencode-kilo-skills.md b/.changeset/784-opencode-kilo-skills.md new file mode 100644 index 000000000..0d75bdfad --- /dev/null +++ b/.changeset/784-opencode-kilo-skills.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 810 +--- +Emit native on-demand skills (`skills//SKILL.md`) for the OpenCode-family runtimes (OpenCode and Kilo) at install time, in addition to the existing flat `command/` and file-based `agents/` surfaces. OpenCode and Kilo share a config schema and both discover skills from `skills//SKILL.md`; the installer now stages each GSD command as a skill with minimal, spec-compliant frontmatter (`name` matching the directory, `description` 1–1024 chars) via a shared OpenCode-family skill writer. Skills respect the active install profile (core/minimal stage only their subset) and are removed on uninstall. (#784) diff --git a/bin/install.js b/bin/install.js index 04257db81..169b8c700 100755 --- a/bin/install.js +++ b/bin/install.js @@ -5985,6 +5985,70 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) { return `---\n${newFrontmatter}\n---${body}`; } +/** + * Shared SKILL.md writer for the OpenCode-family runtimes (OpenCode + Kilo), + * which share a config schema (Kilo derives from OpenCode). OpenCode discovers + * skills as `skills//SKILL.md` and Kilo follows the same layout + * (https://opencode.ai/docs/skills, https://kilo.ai/docs/customize/skills). + * + * The skill body reuses the runtime's command-frontmatter converter for tool, + * path, and `/gsd:`→`/gsd-` body rewrites, then rebuilds a minimal skill + * frontmatter: only `name` (lowercase-hyphen, must match the containing + * directory) and `description` (1–1024 chars) are emitted, per the OpenCode + * skill spec. The command's `tools:`/`permission:` block is intentionally + * dropped — OpenCode skills are loaded on-demand via the native skill tool and + * inherit the calling agent's permissions. + * + * @param {string} content - Claude command markdown (with YAML frontmatter) + * @param {string} skillName - Skill directory name (e.g. gsd-help) + * @param {(content: string) => string} frontmatterConverter - runtime command converter + * @returns {string} SKILL.md content + */ +function convertClaudeCommandToOpencodeFamilySkill(content, skillName, frontmatterConverter) { + const converted = frontmatterConverter(content); + const { frontmatter, body } = extractFrontmatterAndBody(converted); + let description = `Run GSD workflow ${skillName}.`; + if (frontmatter) { + const maybeDescription = extractFrontmatterField(frontmatter, 'description'); + if (maybeDescription) { + description = maybeDescription; + } + } + description = toSingleLine(description); + // OpenCode skill descriptions must be 1–1024 characters. + if (description.length > 1024) { + description = `${description.slice(0, 1021)}...`; + } + // `name` must be lowercase alphanumeric with single-hyphen separators and + // match the containing directory name (the staged dir is `${skillName}/`). + const name = yamlIdentifier(skillName); + return `---\nname: ${name}\ndescription: ${yamlQuote(description)}\n---\n\n${body.trimStart()}`; +} + +/** + * Convert a Claude command (.md) to an OpenCode skill (SKILL.md). + * Thin wrapper over the shared OpenCode-family writer. + */ +function convertClaudeCommandToOpencodeSkill(content, skillName) { + return convertClaudeCommandToOpencodeFamilySkill( + content, + skillName, + (c) => convertClaudeToOpencodeFrontmatter(c), + ); +} + +/** + * Convert a Claude command (.md) to a Kilo skill (SKILL.md). + * Thin wrapper over the shared OpenCode-family writer (Kilo shares the schema). + */ +function convertClaudeCommandToKiloSkill(content, skillName) { + return convertClaudeCommandToOpencodeFamilySkill( + content, + skillName, + (c) => convertClaudeToKiloFrontmatter(c), + ); +} + /** * Convert Claude Code markdown command to Gemini TOML format * @param {string} content - Markdown file content with YAML frontmatter @@ -6037,6 +6101,31 @@ function convertClaudeToGeminiToml(content) { * @param {string} pathPrefix - Path prefix for file references * @param {string} runtime - Target runtime ('claude', 'opencode', or 'kilo') */ +/** + * Apply OpenCode-family (`opencode`/`kilo`) `@file` path-prefix rewrites to a + * RAW Claude command/skill body, BEFORE the frontmatter converter runs. + * + * This is the single source of truth shared by copyFlattenedCommands (commands) + * and installOpencodeFamilySkills (skills) so the two surfaces produce identical + * path references. Applying pathPrefix pre-conversion (rather than rewriting an + * already-converted body) is what avoids the converter's hardcoded default + * config dir leaking into --local / --config-dir installs, and the + * prefix-overlap double-rewrite hazard for custom dirs like `kilo-alt`. (#784) + * + * @param {string} content - raw Claude command markdown + * @param {string} runtime - 'opencode' or 'kilo' + * @param {string} pathPrefix - trailing-slash install-target prefix + * @returns {string} + */ +function applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix) { + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/\.\/\.claude\//g, `./${getDirName(runtime)}/`); + content = content.replace(/~\/\.opencode\//g, pathPrefix); + content = content.replace(/~\/\.kilo\//g, pathPrefix); + return content; +} + function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) { if (!fs.existsSync(srcDir)) { return; @@ -6069,16 +6158,7 @@ function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) { const destPath = path.join(destDir, destName); let content = fs.readFileSync(srcPath, 'utf8'); - const globalClaudeRegex = /~\/\.claude\//g; - const globalClaudeHomeRegex = /\$HOME\/\.claude\//g; - const localClaudeRegex = /\.\/\.claude\//g; - const opencodeDirRegex = /~\/\.opencode\//g; - const kiloDirRegex = /~\/\.kilo\//g; - content = content.replace(globalClaudeRegex, pathPrefix); - content = content.replace(globalClaudeHomeRegex, pathPrefix); - content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`); - content = content.replace(opencodeDirRegex, pathPrefix); - content = content.replace(kiloDirRegex, pathPrefix); + content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix); content = processAttribution(content, getCommitAttribution(runtime)); content = runtime === 'kilo' ? convertClaudeToKiloFrontmatter(content) @@ -6495,7 +6575,11 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) { break; default: - // Unknown runtime — no rewrites + // Unknown runtime — no rewrites. + // OpenCode/Kilo are intentionally absent: their skills are written by + // installOpencodeFamilySkills, which applies pathPrefix BEFORE the + // command→skill conversion (mirroring copyFlattenedCommands) rather than + // rewriting already-converted SKILL.md bodies. See #784. break; } @@ -6824,6 +6908,87 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) { } } +/** + * Install the skills layout kind for an OpenCode-family runtime (OpenCode/Kilo). + * + * These runtimes do NOT go through installRuntimeArtifacts (their commands use a + * bespoke flattened-command writer), so this writes ONLY the skills kind + * alongside their existing command/ + agents/ surfaces. Uninstall is already + * layout-driven (uninstallRuntimeArtifacts iterates layout.kinds), so the + * skills/ dir is cleaned up automatically once the layout declares it. + * + * `rawCommandsDir` MUST be the SAME staged command directory the flattened + * command writer consumes (the caller passes its `_stageSkills()` output) 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`. + * + * Mirrors copyFlattenedCommands exactly per file — pathPrefix rewrite → + * attribution → command→skill conversion — guaranteeing command/ and skills/ + * bodies match byte-for-byte for global, --local, and --config-dir installs. + * We deliberately do NOT use skillsKindEntry.stage(): that converts before any + * pathPrefix is known, so its bodies would carry the converter's hardcoded + * default config dir. (#784) + * + * @param {string} runtime - 'opencode' or 'kilo' + * @param {string} targetDir - resolved runtime config directory + * @param {string} rawCommandsDir - staged RAW Claude command dir (caller's _stageSkills output) + * @param {string} pathPrefix - computed config-path prefix for body rewrites + * @returns {number} number of gsd-* skill directories written + */ +function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPrefix) { + const layout = resolveRuntimeArtifactLayout(runtime, targetDir); + const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills'); + if (!skillsKindEntry) return 0; + const rawDir = rawCommandsDir; + if (!rawDir || !fs.existsSync(rawDir)) return 0; + + const converter = runtime === 'kilo' + ? convertClaudeCommandToKiloSkill + : convertClaudeCommandToOpencodeSkill; + + const dest = path.join(targetDir, skillsKindEntry.destSubpath); + fs.mkdirSync(dest, { recursive: true }); + + // Preserve user-owned GSD-prefixed skill dirs across the gsd-* prune. + // gsd-dev-preferences is generated by the user (via generate-dev-preferences) + // and lives at /skills/gsd-dev-preferences — _removeGsdEntries + // would otherwise wipe it. Mirrors the preservation in installRuntimeArtifacts + // (#2973). + const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences']; + const toPreserve = new Map(); // dirName -> Map + for (const dirName of USER_OWNED_SKILL_DIRS) { + const skillDir = path.join(dest, dirName); + if (!fs.existsSync(skillDir)) continue; + const snap = _snapshotDir(skillDir); + if (snap.size > 0) toPreserve.set(dirName, snap); + } + + _removeGsdEntries(dest, skillsKindEntry); + + let count = 0; + for (const entry of fs.readdirSync(rawDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.md')) continue; + const stem = entry.name.slice(0, -3); + const skillName = `${skillsKindEntry.prefix}${stem}`; + let content = fs.readFileSync(path.join(rawDir, entry.name), 'utf8'); + content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix); + content = processAttribution(content, getCommitAttribution(runtime)); + content = converter(content, skillName); + const skillDir = path.join(dest, skillName); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); + count++; + } + + // Restore user-owned dirs after the prune+copy. + for (const [dirName, snap] of toPreserve) { + _restoreDir(path.join(dest, dirName), snap); + } + + return count; +} + /** * Layout-driven uninstall orchestrator. * Runs legacy cleanup first, then uses resolveRuntimeArtifactLayout to @@ -9047,6 +9212,17 @@ function install(isGlobal, runtime = 'claude', options = {}) { } else { failures.push('command/gsd-*'); } + + // Also emit OpenCode-family skills (skills//SKILL.md). OpenCode and + // Kilo support native, on-demand skills in addition to flat commands — see + // resolveRuntimeArtifactLayout's opencode/kilo entries. Derive skills from + // the SAME staged command set (gsdSrc) so both surfaces match exactly. (#784) + const _skillCount = installOpencodeFamilySkills(runtime, targetDir, gsdSrc, pathPrefix); + if (_skillCount > 0) { + console.log(` ${green}✓${reset} Installed ${_skillCount} skills to skills/`); + } else { + failures.push('skills/gsd-*'); + } } else if (isCline) { // Cline local install: rules-based only — commands are embedded in .clinerules (generated below). // No skills/commands directory needed for local installs. @@ -11393,6 +11569,8 @@ module.exports = { convertClaudeCommandToCodexSkill, convertClaudeToOpencodeFrontmatter, convertClaudeToKiloFrontmatter, + convertClaudeCommandToOpencodeSkill, + convertClaudeCommandToKiloSkill, configureOpencodePermissions, neutralizeAgentReferences, GSD_CODEX_MARKER, @@ -11471,6 +11649,7 @@ module.exports = { ensureCodexHooksJsonSessionStart, readGsdCommandNames, installRuntimeArtifacts, + installOpencodeFamilySkills, uninstallRuntimeArtifacts, parseConfigDirFromArgs, cleanupLegacyGsdCc, diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index 85ec443b4..1231e0336 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -110,7 +110,7 @@ GEMINI_CONFIG_DIR=~/.gemini-alt npx @opengsd/gsd-core@latest --gemini --global npx @opengsd/gsd-core@latest --opencode --global ``` -Skills land in `~/.config/opencode/` (XDG) or `~/.opencode/`. The installer converts agent frontmatter to OpenCode's schema — removing the `tools:` field and converting colour values to hex. See [Installing without Node.js — OpenCode transformations](#opencode--required-transformations) if you need to understand what changes. +The installer writes three surfaces under `~/.config/opencode/` (XDG) or `~/.opencode/`: flat slash commands in `command/`, file-based subagents in `agents/`, and on-demand skills in `skills//SKILL.md`. It converts agent frontmatter to OpenCode's schema — removing the `tools:` field and converting colour values to hex — and emits each skill with spec-compliant frontmatter (`name` matching the skill directory plus a `description`). Skills are loaded on demand via OpenCode's native skill tool; commands remain invokable as `/gsd-*`. See [Installing without Node.js — OpenCode transformations](#opencode--required-transformations) if you need to understand what changes. **Override the install directory:** @@ -126,7 +126,7 @@ OPENCODE_CONFIG_DIR=~/.config/opencode-alt npx @opengsd/gsd-core@latest --openco npx @opengsd/gsd-core@latest --kilo --global ``` -Skills land in `~/.config/kilo/` (XDG) or `~/.kilo/`. Uses the same OpenCode-style flat markdown command format. +The installer writes the same three surfaces under `~/.config/kilo/` (XDG) or `~/.kilo/` as for OpenCode — flat commands in `command/`, subagents in `agents/`, and skills in `skills//SKILL.md` — since Kilo derives from OpenCode and shares its config schema and skill layout. **Override the install directory:** diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index 24a504bf6..1b2a495fe 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -350,11 +350,21 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope: break; case 'opencode': - kinds = [commandsKind('command', 'gsd-', configDir)]; + // OpenCode reads flat slash commands from command/ and on-demand skills + // from skills//SKILL.md (https://opencode.ai/docs/skills). Emit both. + kinds = [ + commandsKind('command', 'gsd-', configDir), + skillsKind('skills', 'gsd-', 'convertClaudeCommandToOpencodeSkill', 'opencode', configDir), + ]; break; case 'kilo': - kinds = [commandsKind('command', 'gsd-', configDir)]; + // Kilo derives from OpenCode and shares the skills//SKILL.md layout + // (https://kilo.ai/docs/customize/skills). Emit flat commands + skills. + kinds = [ + commandsKind('command', 'gsd-', configDir), + skillsKind('skills', 'gsd-', 'convertClaudeCommandToKiloSkill', 'kilo', configDir), + ]; break; default: diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index b6e6ca68b..d3bee37bb 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -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//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 @/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', () => { diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index c2be44d97..fedf9ed20 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -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-/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); diff --git a/tests/runtime-converters.test.cjs b/tests/runtime-converters.test.cjs index a799a62b7..3edccd6e6 100644 --- a/tests/runtime-converters.test.cjs +++ b/tests/runtime-converters.test.cjs @@ -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); + }); + }); + } +});