* 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:
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,
|
||||
|
||||
Reference in New Issue
Block a user