* 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:
5
.changeset/784-opencode-kilo-skills.md
Normal file
5
.changeset/784-opencode-kilo-skills.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 810
|
||||
---
|
||||
Emit native on-demand skills (`skills/<name>/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/<name>/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)
|
||||
201
bin/install.js
201
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/<name>/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 <configDir>/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<relPath, Buffer>
|
||||
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/<name>/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,
|
||||
|
||||
@@ -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/<name>/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/<name>/SKILL.md` — since Kilo derives from OpenCode and shares its config schema and skill layout.
|
||||
|
||||
**Override the install directory:**
|
||||
|
||||
|
||||
@@ -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/<name>/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/<name>/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:
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user