diff --git a/.gitignore b/.gitignore index 169c2f39a..ef059bc36 100644 --- a/.gitignore +++ b/.gitignore @@ -130,6 +130,8 @@ build/ /gsd-core/bin/lib/worktree-base-ref.cjs /gsd-core/bin/lib/worktree-safety.cjs /gsd-core/bin/lib/planning-workspace.cjs +/gsd-core/bin/lib/command-roster.cjs +/gsd-core/bin/lib/runtime-artifact-conversion.cjs /gsd-core/bin/lib/runtime-artifact-layout.cjs /gsd-core/bin/lib/runtime-config-adapter-registry.cjs /gsd-core/bin/lib/runtime-hooks-surface.cjs diff --git a/CONTEXT.md b/CONTEXT.md index ac09572b5..43bd00659 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -139,6 +139,12 @@ Module owning which skills and agents are written to runtime config directories ### Runtime Artifact Layout Module Module owning the per-runtime mapping from artifact kind to filesystem placement. ADR-3660 defines the typed `kinds` per runtime (`commands`, `agents`, `skills`) with destination subpath, prefix, and stage adapter (with per-runtime converters in `bin/install.js`: `convertClaudeCommandToClaudeSkill`, `…CodexSkill`, `…CopilotSkill`, `…AntigravitySkill`). Owns the per-runtime `nested` skill-bundle decision (#69): a `skillsKind` flag in `src/runtime-artifact-layout.cts` drives whether a runtime receives the nested router layout (6 `gsd-ns-*` routers + concrete skills under `/skills//`) or the flat `skills/gsd-/` layout; the evidence/doc-link matrix is recorded in a comment above `resolveRuntimeArtifactLayout`. Phase 1 applies this seam to the Runtime Surface Module (`surface.cjs:applySurface`); as of #813, `applySurface` applies the same per-runtime skill-body path rewrites as `installRuntimeArtifacts` for `skills` kinds — re-surfacing no longer overwrites installed SKILL.md bodies with converter-default `~/.claude` paths. The shared accessor `getInstallExports` (exported from `runtime-artifact-layout.cjs`) is the single-source seam through which `surface.cjs` reaches `computePathPrefix` and `applyRuntimeContentRewritesInPlace`; the resolved `scope` (`'local'`|`'global'`) is now carried on the `Layout` object returned by `resolveRuntimeArtifactLayout` so `applySurface` derives the same `pathPrefix` (global `$HOME` form vs. absolute) as a fresh install. Phase 2 is planned to migrate install/uninstall in `bin/install.js` so all lifecycle sites iterate one shared layout table instead of re-encoding runtime layout logic. This design is intended to remove the #3659 class of omissions. Migrations remain under the Installer Migration Module (ADR-0008). See ADR-3660. +### Runtime Artifact Conversion Module +Sibling Module to Runtime Artifact Layout Module. Owns projection from canonical Claude-authored command/agent/skill markdown into runtime-specific artifact bodies, including converter selection, frontmatter/body normalization, runtime path rewrites, and staged artifact generation. Runtime Artifact Layout remains responsible for filesystem placement (`kind`, destination subpath, prefix, nesting); Runtime Artifact Conversion owns the content Implementation behind that placement seam so install, uninstall/surface parity, and future plugin/package projections stop reaching back through `bin/install.js` for converter functions or `GSD_TEST_MODE`-guarded installer exports. Chosen direction: sibling Module, not an expanded Layout Module, to preserve ADR-3660's narrow placement responsibility while deepening artifact content locality. First slice: relocate only the layout-reached conversion family (`convertClaudeCommandTo*Skill`, converted command-file emitters, `buildKimiAgentArtifacts`) plus the minimal helper closure they need; do not leave helper dependencies in `bin/install.js` because that would preserve the same shallow seam under a new filename. Installer integration decision: `bin/install.js` imports the conversion Module at top level and re-exports the moved names for compatibility; the conversion Module must not import `bin/install.js` or Runtime Artifact Layout, so the dependency direction becomes installer/layout Adapters -> conversion Module, never conversion -> installer. First-slice Interface decision: export the existing compatibility names only; do not introduce a grouped `convertRuntimeArtifact` Interface until after relocation proves byte-for-byte behavior. + +### Command Roster Module +Tiny read-only helper Module owning discovery of canonical `commands/gsd/*.md` command stems for artifact conversion and runtime projection. It is a sibling dependency of Runtime Artifact Conversion Module, not part of conversion itself: conversion consumes a roster to safely rewrite `gsd:` / `/gsd-` references, while roster discovery owns filesystem/catalog knowledge. First slice: extract existing `readGsdCommandNames` behavior behind this Module instead of moving it into Runtime Artifact Conversion Module or keeping it as installer-owned state. + ### Runtime Install Policy Module Projects a pure, typed install plan for a given runtime by composing artifact placements (Runtime Artifact Layout Module), command text (Shell Command Projection Module), and per-runtime config intentions — with no filesystem IO or format-specific serialization. Runtime-specific adapters consume the plan and execute concrete file mutations and config rendering. See ADR-58. diff --git a/bin/install.js b/bin/install.js index 5e14d7634..1c8f93a8e 100755 --- a/bin/install.js +++ b/bin/install.js @@ -26,8 +26,8 @@ const { // fs.readdirSync + RegExp work for every skill. const { transformContentToHyphen, - readCmdNames: readGsdCommandNames, -} = require(path.join(__dirname, '..', 'scripts', 'fix-slash-commands.cjs')); + readGsdCommandNames, +} = require('../gsd-core/bin/lib/command-roster.cjs'); const { resolveAntigravityGlobalDir, getGlobalConfigDir, @@ -38,6 +38,7 @@ const { readBaseRefFromSettings, } = require('../gsd-core/bin/lib/worktree-base-ref.cjs'); const { resolveInstallPlan } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs'); +const runtimeArtifactConversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs'); // Canonical set of hook files shipped to users. Imported here so writeManifest() // records exactly the same set that build-hooks.js copies to hooks/dist/, making // the manifest and the installed hooks/ dir structurally identical. Avoids the @@ -12281,6 +12282,7 @@ module.exports = { parseConfigDirFromArgs, cleanupLegacyGsdCc, _applyRuntimeRewrites, + ...runtimeArtifactConversion, }; // Main logic — only run when not loaded as a module for testing diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index fba13bb14..4cc287403 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -284,6 +284,7 @@ "code-review-flags.cjs", "command-aliases.cjs", "command-arg-projection.cjs", + "command-roster.cjs", "command-routing-hub.cjs", "commands.cjs", "config-loader.cjs", @@ -344,6 +345,7 @@ "roadmap-parser.cjs", "roadmap-upgrade.cjs", "roadmap.cjs", + "runtime-artifact-conversion.cjs", "runtime-artifact-layout.cjs", "runtime-config-adapter-registry.cjs", "runtime-homes.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 4ba444ca7..67dda5330 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -372,7 +372,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (110 shipped) +## CLI Modules (112 shipped) Full listing: `gsd-core/bin/lib/*.cjs`. @@ -395,6 +395,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `code-review-flags.cjs` | Typed flag parser for `/gsd:code-review`; exports `parseCodeReviewFlags(argv)` (→ `{ fix, all, auto, depth, files }`) and `resolveCodeReviewWorkflow(flags)` (→ `'code-review.md' \| 'code-review-fix.md'`); canonical dispatch seam for `--fix`/`--all`/`--auto` routing | | `command-aliases.cjs` | Alias/subcommand metadata for manifest-backed family routers | | `command-arg-projection.cjs` | Typed flag and positional argument projection helpers shared across command-family routers | +| `command-roster.cjs` | Read-only discovery of canonical `commands/gsd/*.md` command stems for runtime artifact conversion and namespace rewrites | | `command-routing-hub.cjs` | Pure-result dispatch hub that centralizes mode decision (SDK vs CJS), error taxonomy, and no-throw contract for all command-family routers (#3788) | | `commands.cjs` | Misc CLI commands (slug, timestamp, todos, scaffolding, stats) | | `config-loader.cjs` | Project config loading — defaults merge, legacy-key migration, workstream overlay, unknown-key/profile-override validation (extracted from `core.cjs`, ADR-857) | @@ -455,6 +456,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `roadmap-parser.cjs` | ROADMAP.md parsing — milestone slicing, current-milestone extraction, phase/milestone lookups, milestone-phase filter (extracted from `core.cjs`, ADR-857) | | `roadmap-upgrade.cjs` | Migration tool for converting legacy `Phase N` entries to milestone-prefixed `Phase M-NN` convention; `computeMigrationPlan` + `applyMigration` with dry-run default and atomic rollback | | `roadmap.cjs` | ROADMAP.md parsing, phase extraction, plan progress | +| `runtime-artifact-conversion.cjs` | Runtime artifact conversion module — projects Claude-authored commands, agents, and skills into runtime-specific artifact bodies while preserving installer compatibility exports | | `runtime-artifact-layout.cjs` | Runtime artifact layout module — resolves the artifact directory shapes (commands, agents, skills) for each supported runtime; single source of truth for per-runtime artifact placement (#3663) | | `runtime-config-adapter-registry.cjs` | Explicit runtime config adapter registry — resolves per-runtime config-mutation install intent (install surface, shared-settings gate, finish-phase permission writer); see ADR-58. | | `runtime-hooks-surface.cjs` | Runtime hooks surface module — standalone hook-surface writer functions extracted from bin/install.js (ADR-857 phase 5f-1); owns Cline/Cursor/Copilot/Codex hook artifact generation and reconciliation. | diff --git a/eslint.config.mjs b/eslint.config.mjs index caba11fa5..8082ecebe 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -96,6 +96,8 @@ export default tseslint.config( 'gsd-core/bin/lib/worktree-safety.cjs', 'gsd-core/bin/lib/worktree-base-ref.cjs', 'gsd-core/bin/lib/planning-workspace.cjs', + 'gsd-core/bin/lib/command-roster.cjs', + 'gsd-core/bin/lib/runtime-artifact-conversion.cjs', 'gsd-core/bin/lib/runtime-artifact-layout.cjs', 'gsd-core/bin/lib/runtime-config-adapter-registry.cjs', 'gsd-core/bin/lib/runtime-hooks-surface.cjs', diff --git a/src/command-roster.cts b/src/command-roster.cts new file mode 100644 index 000000000..2cf0b3afa --- /dev/null +++ b/src/command-roster.cts @@ -0,0 +1,29 @@ +'use strict'; + +/** + * Command Roster Module + * + * Read-only helper for discovering canonical commands/gsd command stems and + * applying the shared GSD slash-command namespace transform. + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const slashCommandTransformer = require('../../../scripts/fix-slash-commands.cjs') as { + readCmdNames: () => string[]; + transformContentToHyphen: (src: string, cmdNames: string[]) => string; + transformContent: (src: string, cmdNames: string[]) => string; + buildPattern: (cmdNames: string[]) => RegExp | null; + buildColonPattern: (cmdNames: string[]) => RegExp | null; +}; + +function readGsdCommandNames(): string[] { + return slashCommandTransformer.readCmdNames(); +} + +export = { + readGsdCommandNames, + transformContentToHyphen: slashCommandTransformer.transformContentToHyphen, + transformContent: slashCommandTransformer.transformContent, + buildPattern: slashCommandTransformer.buildPattern, + buildColonPattern: slashCommandTransformer.buildColonPattern, +}; diff --git a/src/runtime-artifact-conversion.cts b/src/runtime-artifact-conversion.cts new file mode 100644 index 000000000..a4490a34c --- /dev/null +++ b/src/runtime-artifact-conversion.cts @@ -0,0 +1,2160 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment, + @typescript-eslint/no-require-imports, + @typescript-eslint/no-unsafe-assignment, + @typescript-eslint/no-unsafe-member-access, + @typescript-eslint/no-unsafe-return, + @typescript-eslint/no-unsafe-call, + @typescript-eslint/no-unsafe-argument */ +// Mechanical extraction from bin/install.js; keep behavior parity before typing. +// @ts-nocheck +'use strict'; +/** + * Runtime Artifact Conversion Module. + * + * First slice: layout-reached command/skill artifact converters moved out of + * bin/install.js so Runtime Artifact Layout no longer reaches through the + * Installer Module for conversion behavior. + */ + +import path from 'node:path'; +import commandRoster = require('./command-roster.cjs'); +const { readGsdCommandNames, transformContentToHyphen } = commandRoster; +const pkg = require('../../../package.json'); + + +const colorNameToHex = { + cyan: '#00FFFF', + red: '#FF0000', + green: '#00FF00', + blue: '#0000FF', + yellow: '#FFFF00', + magenta: '#FF00FF', + orange: '#FFA500', + purple: '#800080', + pink: '#FFC0CB', + white: '#FFFFFF', + black: '#000000', + gray: '#808080', + grey: '#808080', +}; + +// Tool name mapping from Claude Code to OpenCode +// OpenCode uses lowercase tool names; special mappings for renamed tools +const claudeToOpencodeTools = { + AskUserQuestion: 'question', + SlashCommand: 'skill', + TodoWrite: 'todowrite', + WebFetch: 'webfetch', + WebSearch: 'websearch', // Plugin/MCP - keep for compatibility +}; + +// Tool name mapping from Claude Code to Gemini CLI +// Gemini CLI uses snake_case built-in tool names +const claudeToGeminiTools = { + Read: 'read_file', + Write: 'write_file', + Edit: 'replace', + Bash: 'run_shell_command', + Glob: 'glob', + Grep: 'search_file_content', + WebSearch: 'google_web_search', + WebFetch: 'web_fetch', + TodoWrite: 'write_todos', +}; + +// Tool name mapping from Claude/GSD agents to Kimi CLI module paths. +// Kimi custom agent YAML requires fully-qualified module paths. +const claudeToKimiTools = { + Read: 'kimi_cli.tools.file:ReadFile', + ReadFile: 'kimi_cli.tools.file:ReadFile', + Write: 'kimi_cli.tools.file:WriteFile', + WriteFile: 'kimi_cli.tools.file:WriteFile', + Edit: 'kimi_cli.tools.file:StrReplaceFile', + MultiEdit: 'kimi_cli.tools.file:StrReplaceFile', + StrReplaceFile: 'kimi_cli.tools.file:StrReplaceFile', + Bash: 'kimi_cli.tools.shell:Shell', + Shell: 'kimi_cli.tools.shell:Shell', + Grep: 'kimi_cli.tools.file:Grep', + Glob: 'kimi_cli.tools.file:Glob', + Agent: 'kimi_cli.tools.agent:Agent', + Task: 'kimi_cli.tools.agent:Agent', + AskUserQuestion: 'kimi_cli.tools.ask_user:AskUserQuestion', + TodoWrite: 'kimi_cli.tools.todo:SetTodoList', + SetTodoList: 'kimi_cli.tools.todo:SetTodoList', + WebSearch: 'kimi_cli.tools.web:SearchWeb', + SearchWeb: 'kimi_cli.tools.web:SearchWeb', + WebFetch: 'kimi_cli.tools.web:FetchURL', + FetchURL: 'kimi_cli.tools.web:FetchURL', + ReadMediaFile: 'kimi_cli.tools.file:ReadMediaFile', + TaskList: 'kimi_cli.tools.background:TaskList', + TaskOutput: 'kimi_cli.tools.background:TaskOutput', + TaskStop: 'kimi_cli.tools.background:TaskStop', +}; + +/** + * Convert a Claude Code tool name to OpenCode format + * - Applies special mappings (AskUserQuestion -> question, etc.) + * - Converts to lowercase (except MCP tools which keep their format) + */ +function convertToolName(claudeTool) { + // Check for special mapping first + if (claudeToOpencodeTools[claudeTool]) { + return claudeToOpencodeTools[claudeTool]; + } + // MCP tools (mcp__*) keep their format + if (claudeTool.startsWith('mcp__')) { + return claudeTool; + } + // Default: convert to lowercase + return claudeTool.toLowerCase(); +} + +/** + * Convert a Claude Code tool name to Gemini CLI format + * - Applies Claude→Gemini mapping (Read→read_file, Bash→run_shell_command, etc.) + * - Filters out MCP tools (mcp__*) — they are auto-discovered at runtime in Gemini + * - Filters out Task/Agent — agents are auto-registered as tools in Gemini + * @returns {string|null} Gemini tool name, or null if tool should be excluded + */ +function convertGeminiToolName(claudeTool) { + // MCP tools: exclude — auto-discovered from mcpServers config at runtime + if (claudeTool.startsWith('mcp__')) { + return null; + } + // Task/Agent: exclude — agents are auto-registered as callable tools. + // AskUserQuestion: exclude — Gemini CLI does not expose an ask_user tool; + // emitting it causes frontmatter validation errors (#3362). + if ( + claudeTool === 'Task' || + claudeTool === 'Agent' || + claudeTool === 'AskUserQuestion' || + claudeTool === 'ask_user' + ) { + return null; + } + // Check for explicit mapping + if (claudeToGeminiTools[claudeTool]) { + return claudeToGeminiTools[claudeTool]; + } + // Default: lowercase + return claudeTool.toLowerCase(); +} + +function createKimiToolDiagnostic(reason, tool, source = null) { + const isMcp = reason === 'mcp_managed'; + return { + level: 'warning', + code: isMcp ? 'kimi_mcp_tool_excluded' : 'kimi_unsupported_tool', + reason, + message: isMcp + ? `MCP-managed tool '${tool}' is configured outside Kimi agent YAML.` + : `Tool '${tool}' is not supported by the Kimi tool mapper.`, + value: tool, + source, + }; +} + +/** + * Convert a Claude/GSD tool name to a Kimi CLI module path. + * @returns {string|null} Kimi module path, or null when excluded/unsupported. + */ +function convertKimiToolName(claudeTool) { + const tool = String(claudeTool || '').trim(); + if (!tool) return null; + if (tool.startsWith('mcp__')) return null; + return claudeToKimiTools[tool] || null; +} + +function mapClaudeToolsToKimiTools(claudeTools, options = {}) { + const diagnostics = []; + const tools = []; + const seen = new Set(); + const source = options && Object.prototype.hasOwnProperty.call(options, 'source') + ? options.source + : null; + + for (const rawTool of Array.isArray(claudeTools) ? claudeTools : []) { + const tool = String(rawTool || '').trim(); + if (!tool) continue; + + if (tool.startsWith('mcp__')) { + diagnostics.push(createKimiToolDiagnostic('mcp_managed', tool, source)); + continue; + } + + const kimiTool = convertKimiToolName(tool); + if (!kimiTool) { + diagnostics.push(createKimiToolDiagnostic('unsupported_tool', tool, source)); + continue; + } + + if (!seen.has(kimiTool)) { + seen.add(kimiTool); + tools.push(kimiTool); + } + } + + return { tools, diagnostics }; +} + +const claudeToKiloAgentPermissions = { + Read: 'read', + Write: 'edit', + Edit: 'edit', + Bash: 'bash', + Grep: 'grep', + Glob: 'glob', + Task: 'task', + WebFetch: 'webfetch', + WebSearch: 'websearch', + TodoWrite: 'todowrite', + AskUserQuestion: 'question', + SlashCommand: 'skill', +}; + +const kiloAgentPermissionOrder = [ + 'read', + 'edit', + 'bash', + 'grep', + 'glob', + 'task', + 'webfetch', + 'websearch', + 'skill', + 'question', + 'todowrite', + 'list', + 'codesearch', + 'lsp', +]; + +function convertClaudeToKiloPermissionTool(claudeTool) { + return claudeToKiloAgentPermissions[claudeTool] || null; +} + +function buildKiloAgentPermissionBlock(claudeTools) { + const allowedPermissions = new Set(); + + for (const tool of claudeTools) { + const mapped = convertClaudeToKiloPermissionTool(tool); + if (mapped) { + allowedPermissions.add(mapped); + } + } + + const lines = ['permission:']; + for (const permission of kiloAgentPermissionOrder) { + lines.push(` ${permission}: ${allowedPermissions.has(permission) ? 'allow' : 'deny'}`); + } + + return lines; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function replaceRelativePathReference(content, fromPath, toPath) { + const escapedPath = escapeRegExp(fromPath); + return content.replace( + new RegExp(`(^|[^A-Za-z0-9_./-])${escapedPath}`, 'g'), + (_, prefix) => `${prefix}${toPath}`, + ); +} + +/** + * Convert a Claude Code tool name to GitHub Copilot format. + * - Applies explicit mapping from claudeToCopilotTools + * - Handles mcp__context7__* prefix → io.github.upstash/context7/* + * - Falls back to lowercase for unknown tools + */ +function convertCopilotToolName(claudeTool) { + // mcp__context7__* wildcard → io.github.upstash/context7/* + if (claudeTool.startsWith('mcp__context7__')) { + return 'io.github.upstash/context7/' + claudeTool.slice('mcp__context7__'.length); + } + // Check explicit mapping + if (claudeToCopilotTools[claudeTool]) { + return claudeToCopilotTools[claudeTool]; + } + // mcp__{tavily,ref,jina,exa,firecrawl}__* use the generic MCP passthrough like exa/firecrawl; + // add explicit Copilot registry mappings when the io.github ids are confirmed (#657 follow-up) + // Default: lowercase + return claudeTool.toLowerCase(); +} + +/** + * Apply Copilot-specific content conversion — CONV-06 (paths) + CONV-07 (command names). + * Path mappings depend on install mode: + * Global: ~/.claude/ → ~/.copilot/, ./.claude/ → ./.github/ + * Local: ~/.claude/ → ./.github/, ./.claude/ → ./.github/ + * Applied to ALL Copilot content (skills, agents, engine files). + * @param {string} content - Source content to convert + * @param {boolean} [isGlobal=false] - Whether this is a global install + */ +function convertClaudeToCopilotContent(content, isGlobal = false) { + let c = content; + // CONV-06: Path replacement — most specific first to avoid substring matches. + // Handle both `~/.claude/foo` (trailing slash) and bare `~/.claude` forms in + // one pass via a capture group, matching the approach used by Antigravity, + // OpenCode, Kilo, and Codex converters (issue #2545). + if (isGlobal) { + c = c.replace(/\$HOME\/\.claude(\/|\b)/g, '$HOME/.copilot$1'); + c = c.replace(/~\/\.claude(\/|\b)/g, '~/.copilot$1'); + } else { + c = c.replace(/\$HOME\/\.claude\//g, '.github/'); + c = c.replace(/~\/\.claude\//g, '.github/'); + c = c.replace(/\$HOME\/\.claude\b/g, '.github'); + c = c.replace(/~\/\.claude\b/g, '.github'); + } + c = c.replace(/\.\/\.claude\//g, './.github/'); + c = c.replace(/\.claude\//g, '.github/'); + // CONV-07: Command name conversion (all gsd: references → gsd-) + c = c.replace(/gsd:/g, 'gsd-'); + // Runtime-neutral agent name replacement (#766) + c = neutralizeAgentReferences(c, 'copilot-instructions.md'); + return c; +} + +/** + * Convert a Claude command (.md) to a Copilot skill (SKILL.md). + * Transforms frontmatter only — body passes through with CONV-06/07 applied. + * Skills keep original tool names (no mapping) per CONTEXT.md decision. + */ +// isGlobal is the 5th positional arg (3rd/4th are runtime/cmdNames passed by the skills wrapper). See runtime-artifact-layout skillsKind. +function convertClaudeCommandToCopilotSkill(content, skillName, _runtime = null, _cmdNames = null, isGlobal = false) { + const converted = convertClaudeToCopilotContent(content, isGlobal); + const { frontmatter, body } = extractFrontmatterAndBody(converted); + if (!frontmatter) return converted; + + const description = extractFrontmatterField(frontmatter, 'description') || ''; + const argumentHint = extractFrontmatterField(frontmatter, 'argument-hint'); + const agent = extractFrontmatterField(frontmatter, 'agent'); + + // CONV-02: Extract allowed-tools YAML multiline list → comma-separated string + const toolsMatch = frontmatter.match(/^allowed-tools:\s*\n((?:\s+-\s+.+\n?)*)/m); + let toolsLine = ''; + if (toolsMatch) { + const tools = toolsMatch[1].match(/^\s+-\s+(.+)/gm); + if (tools) { + toolsLine = tools.map(t => t.replace(/^\s+-\s+/, '').trim()).join(', '); + } + } + + // Reconstruct frontmatter in Copilot format + // #2876: descriptions starting with a YAML flow indicator (`[BETA] …`, + // `{ … }`, `*ref`, `&anchor`, etc.) parse as flow sequences/mappings and + // crash gh-copilot's frontmatter loader. Always quote so any leading + // character is parser-safe. + let fm = `---\nname: ${skillName}\ndescription: ${yamlQuote(description)}\n`; + if (argumentHint) fm += `argument-hint: ${yamlQuote(argumentHint)}\n`; + if (agent) fm += `agent: ${agent}\n`; + if (toolsLine) fm += `allowed-tools: ${toolsLine}\n`; + fm += '---'; + + return `${fm}\n${body}`; +} + +/** + * Map a skill directory name (gsd-) to the frontmatter `name:` used + * by Claude Code as the skill identity. Emits the hyphen form (gsd-) + * so Claude Code autocomplete shows the canonical invocation form, not the + * deprecated colon form. See #2808. + * + * Historical note: this previously returned `gsd:` (colon) because + * workflows called Skill(skill="gsd:"). Those calls have been updated + * to use hyphen form (#2808) so the colon rewrite is no longer needed. + * + * Codex must NOT use this helper: its adapter invokes skills as `$gsd-` + * (shell-var syntax) — hyphen form is already correct there. + */ +function skillFrontmatterName(skillDirName) { + if (typeof skillDirName !== 'string') return skillDirName; + // Return the hyphen form as-is (gsd-) — canonical since #2808. + return skillDirName; +} + +/** + * Qwen Code skills accept an optional numeric `priority` frontmatter field. + * Per the Qwen skills spec (qwen-code/docs/users/features/skills.md, verified + * #778): HIGHER values sort EARLIER in the `/skills` TUI listing (omitted ≈ 0; + * negatives sort below unset). It affects ONLY the `/skills` list order — + * slash-command completion and the `/help` view stay alphabetical. + * + * We assign descending priorities to GSD's main-loop commands so the most-used + * workflow skills surface first; utility skills are deliberately left unset + * (default 0) and sort below. + * + * NOTE: the #778 issue body proposed the INVERSE numbering (plan-phase: 10, + * utilities: 90+). The verified spec shows that would BURY the core loop below + * utilities, so we implement the spec-correct direction (core = high) instead. + * Keyed by command stem (skill dir is `gsd-`). + */ +const QWEN_SKILL_PRIORITY = Object.freeze({ + 'new-project': 100, + 'discuss-phase': 95, + 'plan-phase': 90, + 'execute-phase': 85, + progress: 80, + 'verify-work': 75, + phase: 70, + review: 65, + ship: 60, + config: 55, + surface: 50, + 'resume-work': 45, + 'pause-work': 40, + help: 35, + update: 30, +}); + +/** + * Convert a Claude command (.md) to a Claude skill (SKILL.md). + * Claude Code is the native format, so minimal conversion needed — + * preserve allowed-tools as YAML multiline list, preserve argument-hint. + * Emits `name: gsd-` (hyphen) so Skill(skill="gsd-") calls and + * tab autocomplete use the canonical command namespace. + */ +function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, cmdNames = null) { + const { frontmatter, body } = extractFrontmatterAndBody(content); + if (!frontmatter) return content; + + // #3583: rewrite any /gsd: or gsd: in the body to the canonical + // hyphen form (gsd-) so installed SKILL.md bodies match the hyphen + // `name:` Claude Code (and Qwen/Hermes) register under (#2808). `cmdNames` + // is optional and pre-computed by the caller for performance; direct test + // calls fall back to reading the list. + const names = cmdNames || readGsdCommandNames(); + const normalizedBody = transformContentToHyphen(body, names); + + const description = extractFrontmatterField(frontmatter, 'description') || ''; + const argumentHint = extractFrontmatterField(frontmatter, 'argument-hint'); + const agent = extractFrontmatterField(frontmatter, 'agent'); + // #769: preserve context: and effort: from source command files so they + // are emitted into the installed SKILL.md frontmatter unchanged. + const context = extractFrontmatterField(frontmatter, 'context'); + const effort = extractFrontmatterField(frontmatter, 'effort'); + + // Preserve allowed-tools as YAML multiline list (Claude native format) + const toolsMatch = frontmatter.match(/^allowed-tools:\s*\n((?:\s+-\s+.+\n?)*)/m); + let toolsBlock = ''; + if (toolsMatch) { + toolsBlock = 'allowed-tools:\n' + toolsMatch[1]; + // Ensure trailing newline + if (!toolsBlock.endsWith('\n')) toolsBlock += '\n'; + } + + // Reconstruct frontmatter in Claude skill format + const frontmatterName = skillFrontmatterName(skillName); + let fm = `---\nname: ${frontmatterName}\ndescription: ${yamlQuote(description)}\n`; + // Hermes' SKILL.md spec lists `version` as a required frontmatter field. + // Track GSD's package version so Hermes' skill_view() reports a stable + // identifier per install. + if (runtime === 'hermes') fm += `version: ${yamlQuote(pkg.version)}\n`; + // #778 (b) — Qwen-only numeric priority for /skills ordering. Scoped to qwen + // so Claude/Hermes skill frontmatter is unchanged (they ignore the field, but + // we keep their output byte-stable). skillName is the `gsd-` dir name. + if (runtime === 'qwen') { + const stem = typeof skillName === 'string' && skillName.startsWith('gsd-') + ? skillName.slice(4) + : skillName; + const priority = Object.prototype.hasOwnProperty.call(QWEN_SKILL_PRIORITY, stem) + ? QWEN_SKILL_PRIORITY[stem] + : undefined; + if (typeof priority === 'number') fm += `priority: ${priority}\n`; + } + if (argumentHint) fm += `argument-hint: ${yamlQuote(argumentHint)}\n`; + if (agent) fm += `agent: ${agent}\n`; + // #769: emit context: and effort: when present so the runtime can honour + // them natively (context: fork = isolated subagent window; effort: = + // token-budget tier). Fields are Claude-specific; unknown frontmatter + // fields are silently ignored by other runtimes (backward-compatible). + if (context) fm += `context: ${context}\n`; + if (effort) fm += `effort: ${effort}\n`; + if (toolsBlock) fm += toolsBlock; + fm += '---'; + + return `${fm}\n${normalizedBody}`; +} + +function normalizeKimiSkillName(skillName) { + let text = String(skillName || '').trim().toLowerCase(); + if (text.startsWith('/')) text = text.slice(1); + if (text.startsWith('$')) text = text.slice(1); + text = text.replace(/^gsd:/, 'gsd-'); + if (!text.startsWith('gsd-')) text = `gsd-${text}`; + text = text.replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); + return text || 'gsd-command'; +} + +function convertGsdCommandReferencesToKimiSkillInvocations(content, cmdNames) { + if (!Array.isArray(cmdNames) || cmdNames.length === 0) return content; + const commands = [...cmdNames].sort((a, b) => b.length - a.length).map(escapeRegExp); + const commandGroup = commands.join('|'); + const colonPattern = new RegExp(`(? `/skill:gsd-${cmd}`) + .replace(hyphenPattern, (_, cmd) => `/skill:gsd-${cmd}`); +} + +function convertClaudeCommandToKimiSkill(content, skillName, _runtime = null, cmdNames = null) { + const { frontmatter, body } = extractFrontmatterAndBody(content); + const kimiSkillName = normalizeKimiSkillName(skillName); + const names = cmdNames || readGsdCommandNames(); + const description = frontmatter + ? extractFrontmatterField(frontmatter, 'description') || `Run GSD workflow ${kimiSkillName}.` + : `Run GSD workflow ${kimiSkillName}.`; + const normalizedBody = convertGsdCommandReferencesToKimiSkillInvocations( + frontmatter ? body : content, + names + ); + + return `---\nname: ${kimiSkillName}\ndescription: ${yamlQuote(toSingleLine(description))}\n---\nInvoke this Kimi skill with \`/skill:${kimiSkillName}\`.\n\n${normalizedBody}`; +} + +const KIMI_CANONICAL_GSD_AGENT_RE = /^gsd-[a-z0-9-]+$/; + +function parseKimiAgentSource(source) { + if (typeof source === 'string') { + return { + path: null, + content: source, + }; + } + if (!source || typeof source !== 'object' || typeof source.content !== 'string') { + return null; + } + return { + path: typeof source.path === 'string' ? source.path : null, + content: source.content, + }; +} + +function parseFrontmatterTools(frontmatter) { + if (!frontmatter) return []; + const lines = frontmatter.split(/\r?\n/); + const tools = []; + let collecting = false; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + if (collecting) { + if (trimmed.startsWith('- ')) { + tools.push(trimmed.slice(2).trim()); + continue; + } + collecting = false; + } + + if (trimmed === 'tools:' || trimmed === 'allowed-tools:') { + collecting = true; + continue; + } + + if (trimmed.startsWith('tools:') || trimmed.startsWith('allowed-tools:')) { + const value = trimmed.slice(trimmed.indexOf(':') + 1).trim(); + if (value) { + for (const tool of value.split(',')) { + const name = tool.trim(); + if (name) tools.push(name); + } + } else { + collecting = true; + } + } + } + + return tools; +} + +function addKimiAgentDiagnostic(diagnostics, code, message, value, source = null) { + diagnostics.push({ + level: 'warning', + code, + message, + value, + source, + }); +} + +function mapKimiAgentContractTools(toolNames, diagnostics, sourceName) { + const result = mapClaudeToolsToKimiTools(toolNames, { source: sourceName }); + diagnostics.push(...result.diagnostics); + return result.tools; +} + +function neutralizeKimiAgentPrompt(content) { + const { frontmatter, body } = extractFrontmatterAndBody(content); + let prompt = frontmatter ? body : content; + prompt = neutralizeAgentReferences(prompt, 'AGENTS.md'); + prompt = prompt.replace(/~\/\.claude\/gsd-core\b/g, 'GSD core'); + prompt = prompt.replace(/\$HOME\/\.claude\/gsd-core\b/g, 'GSD core'); + return prompt.replace(/^\s*\r?\n/, ''); +} + +function pushKimiToolsYaml(lines, indent, tools) { + const prefix = ' '.repeat(indent); + if (!Array.isArray(tools) || tools.length === 0) { + lines.push(`${prefix}tools: []`); + return; + } + lines.push(`${prefix}tools:`); + for (const tool of tools) { + lines.push(`${prefix} - ${yamlQuote(tool)}`); + } +} + +function buildKimiRootAgentYaml({ description, tools, subagents }) { + const lines = [ + 'version: 1', + 'agent:', + ' name: gsd', + ` description: ${yamlQuote(toSingleLine(description || 'Run GSD workflows in Kimi CLI.'))}`, + ' extend: default', + ' system_prompt_path: ./gsd.md', + ]; + pushKimiToolsYaml(lines, 2, tools); + + if (subagents.length > 0) { + lines.push(' subagents:'); + for (const subagent of subagents) { + lines.push(` ${subagent.name}:`); + lines.push(` path: ./subagents/${subagent.name}.yaml`); + lines.push(` description: ${yamlQuote(toSingleLine(subagent.description))}`); + } + } + + return `${lines.join('\n')}\n`; +} + +function buildKimiSubagentYaml({ name, description, tools }) { + const lines = [ + 'version: 1', + 'agent:', + ` name: ${name}`, + ` description: ${yamlQuote(toSingleLine(description || `Run ${name}.`))}`, + ` system_prompt_path: ./${name}.md`, + ]; + pushKimiToolsYaml(lines, 2, tools); + return `${lines.join('\n')}\n`; +} + +function buildKimiAgentArtifacts({ + rootAgent = '', + subagents = [], + requestedSubagents = null, +} = {}) { + const diagnostics = []; + const rootSource = parseKimiAgentSource(rootAgent) || { path: null, content: '' }; + const { frontmatter: rootFrontmatter } = extractFrontmatterAndBody(rootSource.content); + const rootDescription = rootFrontmatter + ? extractFrontmatterField(rootFrontmatter, 'description') || 'Run GSD workflows in Kimi CLI.' + : 'Run GSD workflows in Kimi CLI.'; + + const subagentSources = Array.isArray(subagents) ? subagents : []; + if (!Array.isArray(subagents)) { + addKimiAgentDiagnostic( + diagnostics, + 'kimi_unsupported_subagents_input', + 'Subagents input must be an array of Markdown strings or source objects.', + typeof subagents, + null + ); + } + + const subagentMap = new Map(); + for (const source of subagentSources) { + const parsed = parseKimiAgentSource(source); + if (!parsed) { + addKimiAgentDiagnostic( + diagnostics, + 'kimi_unsupported_subagent_input', + 'Subagent source must be a Markdown string or an object with content.', + typeof source, + null + ); + continue; + } + + const { frontmatter } = extractFrontmatterAndBody(parsed.content); + const fallbackName = parsed.path ? path.basename(parsed.path, path.extname(parsed.path)) : null; + const name = frontmatter + ? extractFrontmatterField(frontmatter, 'name') || fallbackName + : fallbackName; + if (!name || !KIMI_CANONICAL_GSD_AGENT_RE.test(name)) { + addKimiAgentDiagnostic( + diagnostics, + 'kimi_invalid_subagent_name', + 'Subagent source does not use a canonical gsd-* Kimi agent name.', + name || '(missing)', + parsed.path + ); + continue; + } + + const description = frontmatter + ? extractFrontmatterField(frontmatter, 'description') || `Run ${name}.` + : `Run ${name}.`; + const tools = mapKimiAgentContractTools(parseFrontmatterTools(frontmatter), diagnostics, name); + subagentMap.set(name, { + name, + description, + tools, + prompt: neutralizeKimiAgentPrompt(parsed.content), + }); + } + + const requested = Array.isArray(requestedSubagents) && requestedSubagents.length > 0 + ? requestedSubagents + : [...subagentMap.keys()]; + const selectedSubagents = []; + for (const requestedName of requested) { + if (subagentMap.has(requestedName)) { + selectedSubagents.push(subagentMap.get(requestedName)); + continue; + } + addKimiAgentDiagnostic( + diagnostics, + 'kimi_unknown_subagent', + 'Requested subagent was not generated and will not be emitted in Kimi YAML.', + requestedName, + null + ); + } + + const rootTools = mapKimiAgentContractTools(parseFrontmatterTools(rootFrontmatter), diagnostics, 'gsd'); + if (selectedSubagents.length > 0 && !rootTools.includes('kimi_cli.tools.agent:Agent')) { + rootTools.push('kimi_cli.tools.agent:Agent'); + } + + return { + root: { + name: 'gsd', + yamlPath: 'agents/gsd.yaml', + promptPath: 'agents/gsd.md', + yaml: buildKimiRootAgentYaml({ + description: rootDescription, + tools: rootTools, + subagents: selectedSubagents, + }), + prompt: neutralizeKimiAgentPrompt(rootSource.content), + }, + subagents: selectedSubagents.map((subagent) => ({ + name: subagent.name, + yamlPath: `agents/subagents/${subagent.name}.yaml`, + promptPath: `agents/subagents/${subagent.name}.md`, + yaml: buildKimiSubagentYaml(subagent), + prompt: subagent.prompt, + })), + diagnostics, + }; +} + +/** + * Convert a Claude agent (.md) to a Copilot agent (.agent.md). + * Applies tool mapping + deduplication, formats tools as JSON array. + * CONV-04: JSON array format. CONV-05: Tool name mapping. + */ +function convertClaudeAgentToCopilotAgent(content, isGlobal = false) { + const converted = convertClaudeToCopilotContent(content, isGlobal); + const { frontmatter, body } = extractFrontmatterAndBody(converted); + if (!frontmatter) return converted; + + const name = extractFrontmatterField(frontmatter, 'name') || 'unknown'; + const description = extractFrontmatterField(frontmatter, 'description') || ''; + const color = extractFrontmatterField(frontmatter, 'color'); + const toolsRaw = extractFrontmatterField(frontmatter, 'tools') || ''; + + // CONV-04 + CONV-05: Map tools, deduplicate, format as JSON array + const claudeTools = toolsRaw.split(',').map(t => t.trim()).filter(Boolean); + const mappedTools = claudeTools.map(t => convertCopilotToolName(t)); + const uniqueTools = [...new Set(mappedTools)]; + const toolsArray = uniqueTools.length > 0 + ? "['" + uniqueTools.join("', '") + "']" + : '[]'; + + // Reconstruct frontmatter in Copilot format. Quote description (#2876) + // so a leading YAML flow indicator (`[BETA] …`, `{ … }`, etc.) doesn't + // crash the Copilot frontmatter loader. + let fm = `---\nname: ${name}\ndescription: ${yamlQuote(description)}\ntools: ${toolsArray}\n`; + if (color) fm += `color: ${color}\n`; + fm += '---'; + + return `${fm}\n${body}`; +} + +/** + * Apply Antigravity-specific content conversion — path replacement + command name conversion. + * Path mappings depend on install mode: + * Global: ~/.claude/ → ~/.gemini/antigravity/, ./.claude/ → ./.agents/ + * Local: ~/.claude/ → .agents/, ./.claude/ → ./.agents/ + * Applied to ALL Antigravity content (skills, agents, engine files). + * @param {string} content - Source content to convert + * @param {boolean} [isGlobal=false] - Whether this is a global install + */ +function convertClaudeToAntigravityContent(content, isGlobal = false) { + let c = content; + if (isGlobal) { + c = c.replace(/\$HOME\/\.claude\//g, '$HOME/.gemini/antigravity/'); + c = c.replace(/~\/\.claude\//g, '~/.gemini/antigravity/'); + // Bare form (no trailing slash) — must come after slash form to avoid double-replace + c = c.replace(/\$HOME\/\.claude\b/g, '$HOME/.gemini/antigravity'); + c = c.replace(/~\/\.claude\b/g, '~/.gemini/antigravity'); + } else { + c = c.replace(/\$HOME\/\.claude\//g, '.agents/'); + c = c.replace(/~\/\.claude\//g, '.agents/'); + // Bare form (no trailing slash) — must come after slash form to avoid double-replace + c = c.replace(/\$HOME\/\.claude\b/g, '.agents'); + c = c.replace(/~\/\.claude\b/g, '.agents'); + } + c = c.replace(/\.\/\.claude\//g, './.agents/'); + c = c.replace(/\.claude\//g, '.agents/'); + // Command name conversion (all gsd: references → gsd-) + c = c.replace(/gsd:/g, 'gsd-'); + // Runtime-neutral agent name replacement (#766) + c = neutralizeAgentReferences(c, 'GEMINI.md'); + return c; +} + +/** + * Convert a Claude command (.md) to an Antigravity skill (SKILL.md). + * Transforms frontmatter to minimal name + description only. + * Body passes through with path/command conversions applied. + */ +// isGlobal is the 5th positional arg (3rd/4th are runtime/cmdNames passed by the skills wrapper). See runtime-artifact-layout skillsKind. +function convertClaudeCommandToAntigravitySkill(content, skillName, _runtime = null, _cmdNames = null, isGlobal = false) { + const converted = convertClaudeToAntigravityContent(content, isGlobal); + const { frontmatter, body } = extractFrontmatterAndBody(converted); + if (!frontmatter) return converted; + + const name = skillName || extractFrontmatterField(frontmatter, 'name') || 'unknown'; + const description = extractFrontmatterField(frontmatter, 'description') || ''; + + // #2876: quote description so YAML flow indicators in the source + // (e.g. `[BETA] …`) don't break downstream frontmatter parsers. + const fm = `---\nname: ${name}\ndescription: ${yamlQuote(description)}\n---`; + return `${fm}\n${body}`; +} + +/** + * Convert a Claude agent (.md) to an Antigravity agent. + * Uses Gemini tool names since Antigravity runs on Gemini 3 backend. + */ +function convertClaudeAgentToAntigravityAgent(content, isGlobal = false) { + const converted = convertClaudeToAntigravityContent(content, isGlobal); + const { frontmatter, body } = extractFrontmatterAndBody(converted); + if (!frontmatter) return converted; + + const name = extractFrontmatterField(frontmatter, 'name') || 'unknown'; + const description = extractFrontmatterField(frontmatter, 'description') || ''; + const color = extractFrontmatterField(frontmatter, 'color'); + const toolsRaw = extractFrontmatterField(frontmatter, 'tools') || ''; + + // Map tools to Gemini equivalents (reuse existing convertGeminiToolName) + const claudeTools = toolsRaw.split(',').map(t => t.trim()).filter(Boolean); + const mappedTools = claudeTools.map(t => convertGeminiToolName(t)).filter(Boolean); + + // #2876: quote description for the same reason as the skill variant. + let fm = `---\nname: ${name}\ndescription: ${yamlQuote(description)}\ntools: ${mappedTools.join(', ')}\n`; + if (color) fm += `color: ${color}\n`; + fm += '---'; + + return `${fm}\n${body}`; +} + +function toSingleLine(value) { + return value.replace(/\s+/g, ' ').trim(); +} + +function yamlQuote(value) { + return JSON.stringify(value); +} + +function yamlIdentifier(value) { + const text = String(value).trim(); + if (/^[A-Za-z0-9][A-Za-z0-9-]*$/.test(text)) { + return text; + } + return yamlQuote(text); +} + +function extractFrontmatterAndBody(content) { + if (!content.startsWith('---')) { + return { frontmatter: null, body: content }; + } + + const endIndex = content.indexOf('---', 3); + if (endIndex === -1) { + return { frontmatter: null, body: content }; + } + + return { + frontmatter: content.substring(3, endIndex).trim(), + body: content.substring(endIndex + 3), + }; +} + +function extractFrontmatterField(frontmatter, fieldName) { + const regex = new RegExp(`^${fieldName}:\\s*(.+)$`, 'm'); + const match = frontmatter.match(regex); + if (!match) return null; + return match[1].trim().replace(/^['"]|['"]$/g, ''); +} + +// Tool name mapping from Claude Code to Cursor CLI +const claudeToCursorTools = { + Bash: 'Shell', + Edit: 'StrReplace', + AskUserQuestion: null, // No direct equivalent — use conversational prompting + SlashCommand: null, // No equivalent — skills are auto-discovered +}; + +/** + * Convert a Claude Code tool name to Cursor CLI format + * @returns {string|null} Cursor tool name, or null if tool should be excluded + */ +function convertCursorToolName(claudeTool) { + if (claudeTool in claudeToCursorTools) { + return claudeToCursorTools[claudeTool]; + } + // MCP tools keep their format (Cursor supports MCP) + if (claudeTool.startsWith('mcp__')) { + return claudeTool; + } + // Most tools share the same name (Read, Write, Glob, Grep, Task, WebSearch, WebFetch, TodoWrite) + return claudeTool; +} + +function convertSlashCommandsToCursorSkillMentions(content) { + // Keep leading "/" for slash commands; only normalize gsd: -> gsd-. + // This preserves rendered "next step" commands like "/gsd-execute-phase 17". + return content.replace(/gsd:/gi, 'gsd-'); +} + +function convertClaudeToCursorMarkdown(content) { + let converted = convertSlashCommandsToCursorSkillMentions(content); + // Replace tool name references in body text + converted = converted.replace(/\bBash\(/g, 'Shell('); + converted = converted.replace(/\bEdit\(/g, 'StrReplace('); + converted = converted.replace(/\bAskUserQuestion\b/g, 'conversational prompting'); + // Replace subagent_type from Claude to Cursor format + converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="generalPurpose"'); + converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}'); + // Replace project-level Claude conventions with Cursor equivalents + converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.cursor/rules/`'); + converted = converted.replace(/\.\/CLAUDE\.md/g, '.cursor/rules/'); + converted = converted.replace(/`CLAUDE\.md`/g, '`.cursor/rules/`'); + converted = converted.replace(/\bCLAUDE\.md\b/g, '.cursor/rules/'); + converted = converted.replace(/\.claude\/skills\//g, '.cursor/skills/'); + // Remove Claude Code-specific bug workarounds before brand replacement + converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, ''); + converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, ''); + // Replace "Claude Code" brand references with "Cursor" + converted = converted.replace(/\bClaude Code\b/g, 'Cursor'); + return converted; +} + +function getCursorSkillAdapterHeader(skillName) { + return ` +## A. Skill Invocation +- This skill is invoked when the user mentions \`${skillName}\` or describes a task matching this skill. +- Treat all user text after the skill mention as \`{{GSD_ARGS}}\`. +- If no arguments are present, treat \`{{GSD_ARGS}}\` as empty. + +## B. User Prompting +When the workflow needs user input, prompt the user conversationally: +- Present options as a numbered list in your response text +- Ask the user to reply with their choice +- For multi-select, ask for comma-separated numbers + +## C. Tool Usage +Use these Cursor tools when executing GSD workflows: +- \`Shell\` for running commands (terminal operations) +- \`StrReplace\` for editing existing files +- \`Read\`, \`Write\`, \`Glob\`, \`Grep\`, \`Task\`, \`WebSearch\`, \`WebFetch\`, \`TodoWrite\` as needed + +## D. Subagent Spawning +When the workflow needs to spawn a subagent: +- Use \`Task(subagent_type="generalPurpose", ...)\` +- The \`model\` parameter maps to Cursor's model options (e.g., "fast") +`; +} + +function convertClaudeCommandToCursorSkill(content, skillName) { + const converted = convertClaudeToCursorMarkdown(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); + const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description; + const adapter = getCursorSkillAdapterHeader(skillName); + + return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`; +} + +/** + * Convert a Claude Code command to a Cursor 1.6 slash command (#785). + * + * Cursor slash commands live in `.cursor/commands/.md` and are + * plain markdown — no YAML frontmatter, no adapter header. The filename + * becomes the command name (e.g. `gsd-help.md` → `/gsd-help`). + * + * Applies the same `convertClaudeToCursorMarkdown` transforms as the skill + * converter (tool renames, brand substitution, slash-command normalisation), + * then strips the YAML frontmatter block so only the prose body remains. + * + * @param {string} content raw Claude Code command markdown (may have frontmatter) + * @param {string} _commandName the target command name (unused; present for + * API symmetry with other converters so the runtime-artifact-layout stage + * function can call it uniformly) + * @returns {string} plain markdown body, no frontmatter + */ +function convertClaudeCommandToCursorCommand(content, _commandName) { + const converted = convertClaudeToCursorMarkdown(content); + const { body } = extractFrontmatterAndBody(converted); + return body.trimStart(); +} + +/** + * Convert Claude Code agent markdown to Cursor agent format. + * Strips frontmatter fields Cursor doesn't support (color, skills), + * converts tool references, and adds a role context header. + */ +function convertClaudeAgentToCursorAgent(content) { + const converted = convertClaudeToCursorMarkdown(content); + + const { frontmatter, body } = extractFrontmatterAndBody(converted); + if (!frontmatter) return converted; + + const name = extractFrontmatterField(frontmatter, 'name') || 'unknown'; + const description = extractFrontmatterField(frontmatter, 'description') || ''; + + const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`; + + return `${cleanFrontmatter}\n${body}`; +} + +// --- Windsurf converters --- +// Windsurf uses a tool set similar to Cursor. +// Config lives in .windsurf/ (local) and ~/.codeium/windsurf/ (global). + +// Tool name mapping from Claude Code to Windsurf Cascade +const claudeToWindsurfTools = { + Bash: 'Shell', + Edit: 'StrReplace', + AskUserQuestion: null, // No direct equivalent — use conversational prompting + SlashCommand: null, // No equivalent — skills are auto-discovered +}; + +/** + * Convert a Claude Code tool name to Windsurf Cascade format + * @returns {string|null} Windsurf tool name, or null if tool should be excluded + */ +function convertWindsurfToolName(claudeTool) { + if (claudeTool in claudeToWindsurfTools) { + return claudeToWindsurfTools[claudeTool]; + } + // MCP tools keep their format (Windsurf supports MCP) + if (claudeTool.startsWith('mcp__')) { + return claudeTool; + } + // Most tools share the same name (Read, Write, Glob, Grep, Task, WebSearch, WebFetch, TodoWrite) + return claudeTool; +} + +function convertSlashCommandsToWindsurfSkillMentions(content) { + // Keep leading "/" for slash commands; only normalize gsd: -> gsd-. + return content.replace(/gsd:/gi, 'gsd-'); +} + +function convertClaudeToWindsurfMarkdown(content) { + let converted = convertSlashCommandsToWindsurfSkillMentions(content); + // Replace tool name references in body text + converted = converted.replace(/\bBash\(/g, 'Shell('); + converted = converted.replace(/\bEdit\(/g, 'StrReplace('); + converted = converted.replace(/\bAskUserQuestion\b/g, 'conversational prompting'); + // Replace subagent_type from Claude to Windsurf format + converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="generalPurpose"'); + converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}'); + // Replace project-level Claude conventions with Windsurf/Devin equivalents + // Workspace skills install to .devin/ (Devin Desktop preferred dir, #1085). + // Legacy .windsurf/ is still recognized on read but new installs use .devin/. + converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.devin/rules`'); + converted = converted.replace(/\.\/CLAUDE\.md/g, '.devin/rules'); + converted = converted.replace(/`CLAUDE\.md`/g, '`.devin/rules`'); + converted = converted.replace(/\bCLAUDE\.md\b/g, '.devin/rules'); + converted = converted.replace(/\.claude\/skills\//g, '.devin/skills/'); + converted = converted.replace(/\.\/\.claude\//g, './.devin/'); + converted = converted.replace(/\.claude\//g, '.devin/'); + // Bare forms (no trailing slash) — after slash forms to avoid double-rewrite. + // Use negative lookahead (?![\w-]) to preserve .claude-plugin and .claudeignore. + converted = converted.replace(/~\/\.claude(?![\w-])/g, '~/.devin'); + converted = converted.replace(/\$HOME\/\.claude(?![\w-])/g, '$HOME/.devin'); + // Environment variable name rewrite + converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'WINDSURF_CONFIG_DIR'); + // Remove Claude Code-specific bug workarounds before brand replacement + converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, ''); + converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, ''); + // Replace "Claude Code" brand references with "Windsurf" + converted = converted.replace(/\bClaude Code\b/g, 'Windsurf'); + return converted; +} + +function getWindsurfSkillAdapterHeader(skillName) { + return ` +## A. Skill Invocation +- This skill is invoked when the user mentions \`${skillName}\` or describes a task matching this skill. +- Treat all user text after the skill mention as \`{{GSD_ARGS}}\`. +- If no arguments are present, treat \`{{GSD_ARGS}}\` as empty. + +## B. User Prompting +When the workflow needs user input, prompt the user conversationally: +- Present options as a numbered list in your response text +- Ask the user to reply with their choice +- For multi-select, ask for comma-separated numbers + +## C. Tool Usage +Use these Windsurf tools when executing GSD workflows: +- \`Shell\` for running commands (terminal operations) +- \`StrReplace\` for editing existing files +- \`Read\`, \`Write\`, \`Glob\`, \`Grep\`, \`Task\`, \`WebSearch\`, \`WebFetch\`, \`TodoWrite\` as needed + +## D. Subagent Spawning +When the workflow needs to spawn a subagent: +- Use \`Task(subagent_type="generalPurpose", ...)\` +- The \`model\` parameter maps to Windsurf's model options (e.g., "fast") +`; +} + +function convertClaudeCommandToWindsurfSkill(content, skillName) { + const converted = convertClaudeToWindsurfMarkdown(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); + const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description; + const adapter = getWindsurfSkillAdapterHeader(skillName); + + return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`; +} + +/** + * Convert Claude Code agent markdown to Windsurf agent format. + * Strips frontmatter fields Windsurf doesn't support (color, skills), + * converts tool references, and adds a role context header. + */ +function convertClaudeAgentToWindsurfAgent(content) { + const converted = convertClaudeToWindsurfMarkdown(content); + + const { frontmatter, body } = extractFrontmatterAndBody(converted); + if (!frontmatter) return converted; + + const name = extractFrontmatterField(frontmatter, 'name') || 'unknown'; + const description = extractFrontmatterField(frontmatter, 'description') || ''; + + const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`; + + return `${cleanFrontmatter}\n${body}`; +} + +// --- Augment converters --- +// Augment uses a tool set similar to Cursor/Windsurf. +// Config lives in .augment/ (local) and ~/.augment/ (global). + +const claudeToAugmentTools = { + Bash: 'launch-process', + Edit: 'str-replace-editor', + AskUserQuestion: null, + SlashCommand: null, + TodoWrite: 'add_tasks', +}; + +function convertAugmentToolName(claudeTool) { + if (claudeTool in claudeToAugmentTools) { + return claudeToAugmentTools[claudeTool]; + } + if (claudeTool.startsWith('mcp__')) { + return claudeTool; + } + const toolMapping = { + Read: 'view', + Write: 'save-file', + Glob: 'view', + Grep: 'grep', + Task: null, + WebSearch: 'web-search', + WebFetch: 'web-fetch', + }; + return toolMapping[claudeTool] || claudeTool; +} + +function convertSlashCommandsToAugmentSkillMentions(content) { + return content.replace(/gsd:/gi, 'gsd-'); +} + +function convertClaudeToAugmentMarkdown(content) { + let converted = convertSlashCommandsToAugmentSkillMentions(content); + converted = converted.replace(/\bBash\(/g, 'launch-process('); + converted = converted.replace(/\bEdit\(/g, 'str-replace-editor('); + converted = converted.replace(/\bRead\(/g, 'view('); + converted = converted.replace(/\bWrite\(/g, 'save-file('); + converted = converted.replace(/\bTodoWrite\(/g, 'add_tasks('); + converted = converted.replace(/\bAskUserQuestion\b/g, 'conversational prompting'); + // Replace subagent_type from Claude to Augment format + converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="generalPurpose"'); + converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}'); + // Replace project-level Claude conventions with Augment equivalents + converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.augment/rules/`'); + converted = converted.replace(/\.\/CLAUDE\.md/g, '.augment/rules/'); + converted = converted.replace(/`CLAUDE\.md`/g, '`.augment/rules/`'); + converted = converted.replace(/\bCLAUDE\.md\b/g, '.augment/rules/'); + converted = converted.replace(/\.claude\/skills\//g, '.augment/skills/'); + // Remove Claude Code-specific bug workarounds before brand replacement + converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, ''); + converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, ''); + // Replace "Claude Code" brand references with "Augment" + converted = converted.replace(/\bClaude Code\b/g, 'Augment'); + return converted; +} + +function getAugmentSkillAdapterHeader(skillName) { + return ` +## A. Skill Invocation +- This skill is invoked when the user mentions \`${skillName}\` or describes a task matching this skill. +- Treat all user text after the skill mention as \`{{GSD_ARGS}}\`. +- If no arguments are present, treat \`{{GSD_ARGS}}\` as empty. + +## B. User Prompting +When the workflow needs user input, prompt the user conversationally: +- Present options as a numbered list in your response text +- Ask the user to reply with their choice +- For multi-select, ask for comma-separated numbers + +## C. Tool Usage +Use these Augment tools when executing GSD workflows: +- \`launch-process\` for running commands (terminal operations) +- \`str-replace-editor\` for editing existing files +- \`view\` for reading files and listing directories +- \`save-file\` for creating new files +- \`grep\` for searching code (or use MCP servers for advanced search) +- \`web-search\`, \`web-fetch\` for web queries +- \`add_tasks\`, \`view_tasklist\`, \`update_tasks\` for task management + +## D. Subagent Spawning +When the workflow needs to spawn a subagent: +- Use the built-in subagent spawning capability +- Define agent prompts in \`.augment/agents/\` directory +`; +} + +function convertClaudeCommandToAugmentSkill(content, skillName) { + const converted = convertClaudeToAugmentMarkdown(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); + const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description; + const adapter = getAugmentSkillAdapterHeader(skillName); + + return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`; +} + +/** + * Convert Claude Code agent markdown to Augment agent format. + * Strips frontmatter fields Augment doesn't support (color, skills), + * converts tool references, and cleans up for Augment agents. + */ +function convertClaudeAgentToAugmentAgent(content) { + const converted = convertClaudeToAugmentMarkdown(content); + + const { frontmatter, body } = extractFrontmatterAndBody(converted); + if (!frontmatter) return converted; + + const name = extractFrontmatterField(frontmatter, 'name') || 'unknown'; + const description = extractFrontmatterField(frontmatter, 'description') || ''; + + const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`; + + return `${cleanFrontmatter}\n${body}`; +} + +/** + * Copy Claude commands as Augment skills — one folder per skill with SKILL.md. + * Mirrors copyCommandsAsCursorSkills but uses Augment converters. + */ + +function convertSlashCommandsToTraeSkillMentions(content) { + return content.replace(/\/gsd:([a-z0-9-]+)/g, (_, commandName) => { + return `/gsd-${commandName}`; + }); +} + +function convertClaudeToTraeMarkdown(content) { + let converted = convertSlashCommandsToTraeSkillMentions(content); + converted = converted.replace(/\bBash\(/g, 'Shell('); + converted = converted.replace(/\bEdit\(/g, 'StrReplace('); + // Replace general-purpose subagent type with Trae's equivalent "general_purpose_task" + converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="general_purpose_task"'); + converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}'); + converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.trae/rules/`'); + converted = converted.replace(/\.\/CLAUDE\.md/g, '.trae/rules/'); + converted = converted.replace(/`CLAUDE\.md`/g, '`.trae/rules/`'); + converted = converted.replace(/\bCLAUDE\.md\b/g, '.trae/rules/'); + converted = converted.replace(/\.claude\/skills\//g, '.trae/skills/'); + converted = converted.replace(/\.\/\.claude\//g, './.trae/'); + converted = converted.replace(/\.claude\//g, '.trae/'); + // Bare forms (no trailing slash) — after slash forms to avoid double-rewrite. + // Use negative lookahead (?![\w-]) to preserve .claude-plugin and .claudeignore. + converted = converted.replace(/~\/\.claude(?![\w-])/g, '~/.trae'); + converted = converted.replace(/\$HOME\/\.claude(?![\w-])/g, '$HOME/.trae'); + // Environment variable name rewrite + converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'TRAE_CONFIG_DIR'); + converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, ''); + converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, ''); + converted = converted.replace(/\bClaude Code\b/g, 'Trae'); + return converted; +} + +function convertClaudeCommandToTraeSkill(content, skillName) { + const converted = convertClaudeToTraeMarkdown(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); + const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description; + // #2876: quote so YAML flow indicators (`[BETA] …`) don't break Trae's + // frontmatter parser. + return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n${body}`; +} + +function convertClaudeAgentToTraeAgent(content) { + const converted = convertClaudeToTraeMarkdown(content); + + const { frontmatter, body } = extractFrontmatterAndBody(converted); + if (!frontmatter) return converted; + + const name = extractFrontmatterField(frontmatter, 'name') || 'unknown'; + const description = extractFrontmatterField(frontmatter, 'description') || ''; + + const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`; + + return `${cleanFrontmatter}\n${body}`; +} + +function convertSlashCommandsToCodebuddySkillMentions(content) { + return content.replace(/\/gsd:([a-z0-9-]+)/g, (_, commandName) => { + return `/gsd-${commandName}`; + }); +} + +function convertClaudeToCodebuddyMarkdown(content) { + let converted = convertSlashCommandsToCodebuddySkillMentions(content); + // CodeBuddy uses the same tool names as Claude Code (Bash, Edit, Read, Write, etc.) + // No tool name conversion needed + converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}'); + converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`CODEBUDDY.md`'); + converted = converted.replace(/\.\/CLAUDE\.md/g, 'CODEBUDDY.md'); + converted = converted.replace(/`CLAUDE\.md`/g, '`CODEBUDDY.md`'); + converted = converted.replace(/\bCLAUDE\.md\b/g, 'CODEBUDDY.md'); + converted = converted.replace(/\.claude\/skills\//g, '.codebuddy/skills/'); + converted = converted.replace(/\.\/\.claude\//g, './.codebuddy/'); + converted = converted.replace(/\.claude\//g, '.codebuddy/'); + converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, ''); + converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, ''); + converted = converted.replace(/\bClaude Code\b/g, 'CodeBuddy'); + return converted; +} + +function convertClaudeCommandToCodebuddySkill(content, skillName) { + const converted = convertClaudeToCodebuddyMarkdown(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); + const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description; + // #2876: quote so YAML flow indicators (`[BETA] …`) don't break + // CodeBuddy's frontmatter parser. + // + // #789: mark user-invocable:false so the skill is NOT shown in CodeBuddy's + // '/' menu (it defaults to true). The commands/ surface (#789) is the sole + // '/' entry point; skills remain model-invocable background knowledge, + // avoiding a duplicated /gsd-* entry per workflow. + return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\nuser-invocable: false\n---\n${body}`; +} + +/** + * Convert a Claude Code slash-command (.md) to a CodeBuddy slash-command (.md). + * + * CodeBuddy reads user-level slash commands from ~/.codebuddy/commands/.md + * (https://www.codebuddy.ai/docs/cli/slash-commands). The filename determines the + * command name (gsd-help.md → /gsd-help), so the Claude-specific `name: gsd:` + * frontmatter field is dropped. CodeBuddy command frontmatter supports + * `description` and `argument-hint`; both are preserved when present. The body is + * brand/path-converted via convertClaudeToCodebuddyMarkdown. + * + * @param {string} content raw Claude command markdown + * @param {string} commandName installed command name (e.g. 'gsd-help') + * @returns {string} + */ +function convertClaudeCommandToCodebuddyCommand(content, commandName) { + const converted = convertClaudeToCodebuddyMarkdown(content); + const { frontmatter, body } = extractFrontmatterAndBody(converted); + let description = `Run GSD workflow ${commandName}.`; + let argumentHint = ''; + if (frontmatter) { + const maybeDescription = extractFrontmatterField(frontmatter, 'description'); + if (maybeDescription) description = maybeDescription; + const maybeArgHint = extractFrontmatterField(frontmatter, 'argument-hint'); + if (maybeArgHint) argumentHint = maybeArgHint; + } + description = toSingleLine(description); + const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description; + // #2876: quote values so YAML flow indicators (`[BETA] …`, `[name]`) don't + // break CodeBuddy's frontmatter parser. + const lines = ['---', `description: ${yamlQuote(shortDescription)}`]; + if (argumentHint) lines.push(`argument-hint: ${yamlQuote(toSingleLine(argumentHint))}`); + lines.push('---', body.trimStart()); + return lines.join('\n'); +} + +function convertClaudeAgentToCodebuddyAgent(content) { + const converted = convertClaudeToCodebuddyMarkdown(content); + + const { frontmatter, body } = extractFrontmatterAndBody(converted); + if (!frontmatter) return converted; + + const name = extractFrontmatterField(frontmatter, 'name') || 'unknown'; + const description = extractFrontmatterField(frontmatter, 'description') || ''; + + const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`; + + return `${cleanFrontmatter}\n${body}`; +} + +// ── Cline converters ──────────────────────────────────────────────────────── + +function convertClaudeToCliineMarkdown(content) { + let converted = content; + // Cline uses the same tool names as Claude Code — no tool name conversion needed + converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.clinerules`'); + converted = converted.replace(/\.\/CLAUDE\.md/g, '.clinerules'); + converted = converted.replace(/`CLAUDE\.md`/g, '`.clinerules`'); + converted = converted.replace(/\bCLAUDE\.md\b/g, '.clinerules'); + // Slash forms first (most specific — superset of bare forms) + converted = converted.replace(/\.claude\/skills\//g, '.cline/skills/'); + converted = converted.replace(/\.\/\.claude\//g, './.cline/'); + converted = converted.replace(/\.claude\//g, '.cline/'); + // Bare forms (no trailing slash) — after slash forms to avoid double-rewrite + converted = converted.replace(/~\/\.claude\b/g, '~/.cline'); + converted = converted.replace(/\$HOME\/\.claude\b/g, '$HOME/.cline'); + // Environment variable name rewrite + converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'CLINE_CONFIG_DIR'); + converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, ''); + converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, ''); + converted = converted.replace(/\bClaude Code\b/g, 'Cline'); + return converted; +} + +function convertClaudeAgentToClineAgent(content) { + const converted = convertClaudeToCliineMarkdown(content); + const { frontmatter, body } = extractFrontmatterAndBody(converted); + if (!frontmatter) return converted; + const name = extractFrontmatterField(frontmatter, 'name') || 'unknown'; + const description = extractFrontmatterField(frontmatter, 'description') || ''; + const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`; + return `${cleanFrontmatter}\n${body}`; +} + +/** + * Convert a Claude command (.md) to a Cline skill (SKILL.md). + * Emits ONLY name + description frontmatter per the Cline skills spec + * (https://docs.cline.bot/customization/skills) — no allowed-tools, + * argument-hint, agent, or other Claude-specific fields. + * Body is hyphen-normalised then converted via convertClaudeToCliineMarkdown + * (.claude/→.cline/, "Claude Code"→"Cline", etc.). + * Cline uses Claude-Code-compatible tool names, so no adapter header is needed. + * Targets ~/.cline/skills//SKILL.md for Cline >= v3.48.0. + */ +function convertClaudeCommandToClineSkill(content, skillName, runtime = null, cmdNames = null) { + const { frontmatter, body } = extractFrontmatterAndBody(content); + if (!frontmatter) return content; + + // Hyphen-normalise /gsd: → gsd- references in the body, then + // apply Cline-specific markdown rewrites (.claude/→.cline/, etc.). + const names = cmdNames || readGsdCommandNames(); + const normalizedBody = transformContentToHyphen(body, names); + const clineBody = convertClaudeToCliineMarkdown(normalizedBody); + + // Extract description; fall back to a generic string if absent. + let description = extractFrontmatterField(frontmatter, 'description'); + if (!description) description = `Run GSD workflow ${skillName}.`; + description = toSingleLine(description); + // Cline documented max is 1024 code points (not UTF-16 code units). + // Use Array.from to iterate by code point so that multibyte characters + // (e.g. emoji, astral-plane chars) are never split, which would produce + // lone surrogates and corrupt the YAML output. + const cp = Array.from(description); + const shortDescription = cp.length > 1024 + ? cp.slice(0, 1021).join('') + '...' + : description; + + const fm = `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---`; + return `${fm}\n${clineBody}`; +} + +// ── End Cline converters ───────────────────────────────────────────────────── + +function convertSlashCommandsToCodexSkillMentions(content) { + // Colon-style /gsd: never appears as a filesystem path segment, so no boundary guard is needed (unlike the hyphen-style below). + let converted = content.replace(/\/gsd:([a-z0-9-]+)/gi, (_, commandName) => { + return `$gsd-${String(commandName).toLowerCase()}`; + }); + // Convert hyphen-style command references (workflow output) to Codex $ prefix. + // A real /gsd- MENTION is defined positively by two boundaries, so any + // in-path occurrence is excluded by construction (no denylist of preceding + // chars to maintain — see #712, supersedes the #637/#704 lookbehind treadmill): + // 1. Left boundary: opens at start-of-string, whitespace, or an inline-prose + // delimiter (backtick/quote/paren/bracket) — e.g. `/gsd-execute-phase`. + // 2. Right boundary: the command token is NOT followed by a path separator + // `/` (a path continues: `/gsd-core/bin/...`; a command does not). The + // `(?![a-z0-9/-])` also blocks regex backtracking to a shorter command. + // This converts backtick-wrapped MENTIONS (`/gsd-foo`) while leaving backtick- + // wrapped PATHS (`/gsd-core/workflows/update.md`) untouched (#712). + converted = converted.replace(/(?<=^|[\s`"'([])\/gsd-([a-z0-9-]+)(?![a-z0-9/-])/gi, (_, commandName) => { + return `$gsd-${String(commandName).toLowerCase()}`; + }); + return converted; +} + +const CODEX_GSD_TOOLS_INVOCATION = 'node "$HOME/.codex/gsd-core/bin/gsd-tools.cjs"'; + +function rewriteBareGsdToolsCommandsForCodex(content) { + return content + .replace(/(^[ \t]*)gsd-tools(?=\s)/gm, `$1${CODEX_GSD_TOOLS_INVOCATION}`) + .replace(/(\$\(\s*)gsd-tools(?=\s)/g, `$1${CODEX_GSD_TOOLS_INVOCATION}`) + .replace(/(`\s*)gsd-tools(?=\s)/g, `$1${CODEX_GSD_TOOLS_INVOCATION}`) + .replace(/((?:&&|\|\||[;|])\s*)gsd-tools(?=\s)/g, `$1${CODEX_GSD_TOOLS_INVOCATION}`); +} + +function convertClaudeToCodexMarkdown(content) { + let converted = convertSlashCommandsToCodexSkillMentions(content); + converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}'); + // Remove /clear references — Codex has no equivalent command + // Handle backtick-wrapped: `\/clear` then: → (removed) + converted = converted.replace(/`\/clear`\s*,?\s*then:?\s*\n?/gi, ''); + // Handle bare: /clear then: → (removed) + converted = converted.replace(/\/clear\s*,?\s*then:?\s*\n?/gi, ''); + // Handle standalone /clear on its own line + converted = converted.replace(/^\s*`?\/clear`?\s*$/gm, ''); + // Path replacement: .claude → .codex (#1430) + converted = converted.replace(/\$HOME\/\.claude\//g, '$HOME/.codex/'); + converted = converted.replace(/~\/\.claude\//g, '~/.codex/'); + converted = converted.replace(/\.\/\.claude\//g, './.codex/'); + // Bare ~/.claude without trailing slash (e.g. configDir = ~/.claude) + converted = converted.replace(/\$HOME\/\.claude\b/g, '$HOME/.codex'); + converted = converted.replace(/~\/\.claude\b/g, '~/.codex'); + // Bare/project-relative .claude/... references (#2639). Covers strings like + // "check `.claude/skills/`" where there is no ~/, $HOME/, or ./ anchor. + // Negative lookbehind prevents double-replacing already-anchored forms and + // avoids matching inside URLs or other slash-prefixed paths. + converted = converted.replace(/(? +## A. Skill Invocation +- This skill is invoked by mentioning \`${invocation}\`. +- Treat all user text after \`${invocation}\` as \`{{GSD_ARGS}}\`. +- If no arguments are present, treat \`{{GSD_ARGS}}\` as empty. + +## B. AskUserQuestion → request_user_input Mapping +GSD workflows use \`AskUserQuestion\` (Claude Code syntax). Translate to Codex \`request_user_input\`: + +Parameter mapping: +- \`header\` → \`header\` +- \`question\` → \`question\` +- Options formatted as \`"Label" — description\` → \`{label: "Label", description: "description"}\` +- Generate \`id\` from header: lowercase, replace spaces with underscores + +Batched calls: +- \`AskUserQuestion([q1, q2])\` → single \`request_user_input\` with multiple entries in \`questions[]\` + +Multi-select workaround: +- Codex has no \`multiSelect\`. Use sequential single-selects, or present a numbered freeform list asking the user to enter comma-separated numbers. + +Execute mode fallback: +- When \`request_user_input\` is rejected or unavailable, activate TEXT_MODE: append \`--text\` to \`{{GSD_ARGS}}\` so the workflow's built-in text-mode branching takes over. Present every \`AskUserQuestion\` call as a plain-text numbered list, then stop and wait for the user's reply. Do NOT pick a default and continue (#3018 / #3808). +- You may only proceed without a user answer when one of these is true: + (a) the invocation included an explicit non-interactive flag (\`--auto\` or \`--all\`), + (b) the user has explicitly approved a specific default for this question, or + (c) the workflow's documented contract says defaults are safe (e.g. autonomous lifecycle paths). +- Do NOT write workflow artifacts (CONTEXT.md, DISCUSSION-LOG.md, PLAN.md, checkpoint files) until the user has answered the plain-text questions or one of (a)-(c) above applies. Surfacing the questions and waiting is the correct response — silently defaulting and writing artifacts is the #3018 failure mode. + +## C. Task() → spawn_agent Mapping +GSD workflows use \`Task(...)\` (Claude Code syntax). Translate to Codex collaboration tools: + +**Schema detection (required first step):** Codex exposes two \`spawn_agent\` schemas: +- **agent_type-capable schema** (e.g. \`multi_agent_v2\`): \`spawn_agent\` accepts \`agent_type\`, \`message\`, \`reasoning_effort\`, \`fork_context\`, etc. — typed GSD agent dispatch is available. +- **Generic schema** (\`multi_agent_v1\`): \`spawn_agent\` accepts only \`message\`, \`items\`, \`fork_context\` — there is **no \`agent_type\` field**. Typed GSD agent dispatch is unavailable in this session. + +Before spawning, inspect the \`spawn_agent\` tool's visible parameter schema (via \`tool_search\` or the tool list) to determine which form is active. + +Typed mapping (agent_type-capable schema only): +- \`Task(subagent_type="X", prompt="Y")\` → \`spawn_agent(agent_type="X", message="Y")\` +- \`Agent(subagent_type="X", prompt="Y")\` → \`spawn_agent(agent_type="X", message="Y")\` +- \`Task(model="...")\` → omit. \`spawn_agent\` has no inline \`model\` parameter; + GSD embeds the resolved per-agent model directly into each agent's \`.toml\` + at install time so \`model_overrides\` from \`.planning/config.json\` and + \`~/.gsd/defaults.json\` are honored automatically by Codex's agent router. +- Resolved \`reasoning_effort="low|medium|high|xhigh"\` (\`xhigh\` is a GSD/Codex tier, not a generic runtime enum) → pass \`reasoning_effort\` + to \`spawn_agent\` when the runtime/tool supports it. Omit missing, empty, + inherited, or unsupported values; do not invent one-off effort literals in + workflow prose. +- \`fork_context: false\` by default — GSD agents load their own context via \`\` blocks +- \`Task(isolation="worktree")\` / \`Agent(isolation="worktree")\` → no direct Codex mapping. + Codex \`spawn_agent\` does not create or bind a git worktree automatically. + Workflows that require this isolation must fail closed or use an explicit + manual worktree protocol before spawning (#3360). + +Generic-agent workaround (multi_agent_v1 schema — NO agent_type field): +When only the generic \`multi_agent_v1\` schema is available, typed GSD agent dispatch +(\`gsd-planner\`, \`gsd-executor\`, etc.) is NOT possible. This is a known Codex limitation +(openai/codex#15250). **This workaround is NOT equivalent to typed gsd-planner/gsd-executor +execution** — GSD agents carry project-aware prompts, audit logging, and workflow context +that a generic subagent lacks. Use the following fallback: +1. Resolve your active Codex config root — the directory that contains your \`config.toml\`. + This directory is determined in priority order: \`$CODEX_HOME\` (if set), the path given + by \`--config-dir\` (if passed on invocation), a local \`.codex\` directory in the current + project (if \`--local\` was used), or the default global config directory. Read + \`agents/.toml\` relative to that config root to extract the agent's system + instructions. +2. Inject those instructions as a role-preamble into a generic \`spawn_agent(message=...)\` call. +3. Label results and logs clearly as "generic-agent workaround" so the orchestrator and user + know full typed-agent guarantees are not in effect. +4. Where typed dispatch is mandatory for correctness (e.g. worktree isolation), fail closed + and report the schema limitation rather than silently degrading. + +Spawn restriction: +- Codex restricts \`spawn_agent\` to cases where the user has explicitly + requested sub-agents. When automatic spawning is not permitted, do the + work inline in the current agent rather than attempting to force a spawn. +- In some Codex sessions, multi-agent tooling can be deferred. If \`spawn_agent\` + is not currently visible, discover tools first via \`tool_search\` before + defaulting to inline execution. + +Parallel fan-out: +- Spawn multiple agents → collect agent IDs → \`wait(ids)\` for all to complete + +Result parsing: +- Look for structured markers in agent output: \`CHECKPOINT\`, \`PLAN COMPLETE\`, \`SUMMARY\`, etc. +- \`close_agent(id)\` after collecting results from each agent +`; +} + +function convertClaudeCommandToCodexSkill(content, skillName) { + const converted = convertClaudeToCodexMarkdown(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); + const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description; + const adapter = getCodexSkillAdapterHeader(skillName); + + return `---\nname: ${yamlQuote(skillName)}\ndescription: ${yamlQuote(description)}\nmetadata:\n short-description: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`; +} + +function neutralizeAgentReferences(content, instructionFile) { + let c = content; + // Replace standalone "Claude" (the agent) but preserve product/model names. + // Negative lookahead avoids: Claude Code, Claude Opus/Sonnet/Haiku, Claude native, Claude-based + c = c.replace(/\bClaude(?! Code| Opus| Sonnet| Haiku| native| based|-)\b(?!\.md)/g, 'the agent'); + // Replace CLAUDE.md with runtime-appropriate instruction file + if (instructionFile) { + c = c.replace(/CLAUDE\.md/g, instructionFile); + } + // Remove instructions that conflict with AGENTS.md-based runtimes + c = c.replace(/Do NOT load full `AGENTS\.md` files[^\n]*/g, ''); + return c; +} + +function convertClaudeToOpencodeFrontmatter(content, { isAgent = false, modelOverride = null } = {}) { + // Replace tool name references in content (applies to all files) + let convertedContent = content; + convertedContent = convertedContent.replace(/\bAskUserQuestion\b/g, 'question'); + convertedContent = convertedContent.replace(/\bSlashCommand\b/g, 'skill'); + convertedContent = convertedContent.replace(/\bTodoWrite\b/g, 'todowrite'); + // Replace /gsd-command colon variant with /gsd-command for opencode (flat command structure) + convertedContent = convertedContent.replace(/\/gsd:/g, '/gsd-'); + // Replace ~/.claude and $HOME/.claude with OpenCode's config location + convertedContent = convertedContent.replace(/~\/\.claude\b/g, '~/.config/opencode'); + convertedContent = convertedContent.replace(/\$HOME\/\.claude\b/g, '$HOME/.config/opencode'); + // Replace general-purpose subagent type with OpenCode's equivalent "general" + convertedContent = convertedContent.replace(/subagent_type="general-purpose"/g, 'subagent_type="general"'); + // Runtime-neutral agent name replacement (#766) + convertedContent = neutralizeAgentReferences(convertedContent, 'AGENTS.md'); + + // Check if content has frontmatter + if (!convertedContent.startsWith('---')) { + return convertedContent; + } + + // Find the end of frontmatter + const endIndex = convertedContent.indexOf('---', 3); + if (endIndex === -1) { + return convertedContent; + } + + const frontmatter = convertedContent.substring(3, endIndex).trim(); + const body = convertedContent.substring(endIndex + 3); + + // Parse frontmatter line by line (simple YAML parsing) + const lines = frontmatter.split('\n'); + const newLines = []; + let inAllowedTools = false; + let inSkippedArray = false; + const allowedTools = []; + + for (const line of lines) { + const trimmed = line.trim(); + + // For agents: skip commented-out lines (e.g. hooks blocks) + if (isAgent && trimmed.startsWith('#')) { + continue; + } + + // Detect start of allowed-tools array + if (trimmed.startsWith('allowed-tools:')) { + inAllowedTools = true; + continue; + } + + // Detect inline tools: field (comma-separated string) + if (trimmed.startsWith('tools:')) { + if (isAgent) { + // Agents: strip tools entirely (not supported in OpenCode agent frontmatter) + inSkippedArray = true; + continue; + } + const toolsValue = trimmed.substring(6).trim(); + if (toolsValue) { + // Parse comma-separated tools + const tools = toolsValue.split(',').map(t => t.trim()).filter(t => t); + allowedTools.push(...tools); + } + continue; + } + + // For agents: strip skills:, color:, memory:, maxTurns:, permissionMode:, disallowedTools: + if (isAgent && /^(skills|color|memory|maxTurns|permissionMode|disallowedTools):/.test(trimmed)) { + inSkippedArray = true; + continue; + } + + // Skip continuation lines of a stripped array/object field + if (inSkippedArray) { + if (trimmed.startsWith('- ') || trimmed.startsWith('#') || /^\s/.test(line)) { + continue; + } + inSkippedArray = false; + } + + // For commands: remove name: field (opencode uses filename for command name) + // For agents: keep name: (required by OpenCode agents) + if (!isAgent && trimmed.startsWith('name:')) { + continue; + } + + // Strip model: field — OpenCode doesn't support Claude Code model aliases + // like 'haiku', 'sonnet', 'opus', or 'inherit'. Omitting lets OpenCode use + // its configured default model. See #1156. + if (trimmed.startsWith('model:')) { + continue; + } + + // Convert color names to hex for opencode (commands only; agents strip color above) + if (trimmed.startsWith('color:')) { + const colorValue = trimmed.substring(6).trim().toLowerCase(); + const hexColor = colorNameToHex[colorValue]; + if (hexColor) { + newLines.push(`color: "${hexColor}"`); + } else if (colorValue.startsWith('#')) { + // Validate hex color format (#RGB or #RRGGBB) + if (/^#[0-9a-f]{3}$|^#[0-9a-f]{6}$/i.test(colorValue)) { + // Already hex and valid, keep as is + newLines.push(line); + } + // Skip invalid hex colors + } + // Skip unknown color names + continue; + } + + // Collect allowed-tools items + if (inAllowedTools) { + if (trimmed.startsWith('- ')) { + allowedTools.push(trimmed.substring(2).trim()); + continue; + } else if (trimmed && !trimmed.startsWith('-')) { + // End of array, new field started + inAllowedTools = false; + } + } + + // Keep other fields + if (!inAllowedTools) { + newLines.push(line); + } + } + + // For agents: add required OpenCode agent fields + // Note: Do NOT add 'model: inherit' — OpenCode does not recognize the 'inherit' + // keyword and throws ProviderModelNotFoundError. Omitting model: lets OpenCode + // use its default model for subagents. See #1156. + if (isAgent) { + newLines.push('mode: subagent'); + // Embed model override from ~/.gsd/defaults.json so model_overrides is + // respected on OpenCode (which uses static agent frontmatter, not inline + // Task() model parameters). See #2256. + if (modelOverride) { + newLines.push(['model:', modelOverride].join(' ')); + } + } + + // For commands: add tools object if we had allowed-tools or tools + if (!isAgent && allowedTools.length > 0) { + newLines.push('tools:'); + for (const tool of allowedTools) { + newLines.push(` ${convertToolName(tool)}: true`); + } + } + + // Rebuild frontmatter (body already has tool names converted) + const newFrontmatter = newLines.join('\n').trim(); + return `---\n${newFrontmatter}\n---${body}`; +} + +// Kilo CLI — same conversion logic as OpenCode, different config paths. +function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) { + // Replace tool name references in content (applies to all files) + let convertedContent = content; + convertedContent = convertedContent.replace(/\bAskUserQuestion\b/g, 'question'); + convertedContent = convertedContent.replace(/\bSlashCommand\b/g, 'skill'); + convertedContent = convertedContent.replace(/\bTodoWrite\b/g, 'todowrite'); + // Replace /gsd-command colon variant with /gsd-command for Kilo (flat command structure) + convertedContent = convertedContent.replace(/\/gsd:/g, '/gsd-'); + // Replace ~/.claude and $HOME/.claude with Kilo's config location + convertedContent = convertedContent.replace(/~\/\.claude\b/g, '~/.config/kilo'); + convertedContent = convertedContent.replace(/\$HOME\/\.claude\b/g, '$HOME/.config/kilo'); + convertedContent = convertedContent.replace(/\.\/\.claude\//g, './.kilo/'); + // Normalize both Claude skill directory variants to Kilo's canonical skills dir. + convertedContent = replaceRelativePathReference(convertedContent, '.claude/skills/', '.kilo/skills/'); + convertedContent = replaceRelativePathReference(convertedContent, '.agents/skills/', '.kilo/skills/'); + convertedContent = replaceRelativePathReference(convertedContent, '.claude/agents/', '.kilo/agents/'); + // Replace general-purpose subagent type with Kilo's equivalent "general" + convertedContent = convertedContent.replace(/subagent_type="general-purpose"/g, 'subagent_type="general"'); + // Runtime-neutral agent name replacement (#766) + convertedContent = neutralizeAgentReferences(convertedContent, 'AGENTS.md'); + + // Check if content has frontmatter + if (!convertedContent.startsWith('---')) { + return convertedContent; + } + + // Find the end of frontmatter + const endIndex = convertedContent.indexOf('---', 3); + if (endIndex === -1) { + return convertedContent; + } + + const frontmatter = convertedContent.substring(3, endIndex).trim(); + const body = convertedContent.substring(endIndex + 3); + + // Parse frontmatter line by line (simple YAML parsing) + const lines = frontmatter.split('\n'); + const newLines = []; + let inAllowedTools = false; + let inAgentTools = false; + let inSkippedArray = false; + const allowedTools = []; + const agentTools = []; + + for (const line of lines) { + const trimmed = line.trim(); + + // For agents: skip commented-out lines (e.g. hooks blocks) + if (isAgent && trimmed.startsWith('#')) { + continue; + } + + // Detect start of allowed-tools array + if (trimmed.startsWith('allowed-tools:')) { + inAllowedTools = true; + continue; + } + + if (isAgent && inAgentTools) { + if (trimmed.startsWith('- ')) { + agentTools.push(trimmed.substring(2).trim()); + continue; + } + if (trimmed && !trimmed.startsWith('-')) { + inAgentTools = false; + } + } + + // Detect inline tools: field (comma-separated string) + if (trimmed.startsWith('tools:')) { + if (isAgent) { + const toolsValue = trimmed.substring(6).trim(); + if (toolsValue) { + const tools = toolsValue.split(',').map(t => t.trim()).filter(t => t); + agentTools.push(...tools); + } else { + inAgentTools = true; + } + continue; + } + const toolsValue = trimmed.substring(6).trim(); + if (toolsValue) { + // Parse comma-separated tools + const tools = toolsValue.split(',').map(t => t.trim()).filter(t => t); + allowedTools.push(...tools); + } + continue; + } + + // For agents: strip skills:, color:, memory:, maxTurns:, permissionMode:, disallowedTools: + if (isAgent && /^(skills|color|memory|maxTurns|permissionMode|disallowedTools):/.test(trimmed)) { + inSkippedArray = true; + continue; + } + + // Skip continuation lines of a stripped array/object field + if (inSkippedArray) { + if (trimmed.startsWith('- ') || trimmed.startsWith('#') || /^\s/.test(line)) { + continue; + } + inSkippedArray = false; + } + + // For commands: remove name: field (Kilo uses filename for command name) + // For agents: keep name: (required by Kilo agents) + if (!isAgent && trimmed.startsWith('name:')) { + continue; + } + + // Strip model: field — Kilo doesn't support Claude Code model aliases + // like 'haiku', 'sonnet', 'opus', or 'inherit'. Omitting lets Kilo use + // its configured default model. + if (trimmed.startsWith('model:')) { + continue; + } + + // Convert color names to hex for Kilo (commands only; agents strip color above) + if (trimmed.startsWith('color:')) { + const colorValue = trimmed.substring(6).trim().toLowerCase(); + const hexColor = colorNameToHex[colorValue]; + if (hexColor) { + newLines.push(`color: "${hexColor}"`); + } else if (colorValue.startsWith('#')) { + // Validate hex color format (#RGB or #RRGGBB) + if (/^#[0-9a-f]{3}$|^#[0-9a-f]{6}$/i.test(colorValue)) { + // Already hex and valid, keep as is + newLines.push(line); + } + // Skip invalid hex colors + } + // Skip unknown color names + continue; + } + + // Collect allowed-tools items + if (inAllowedTools) { + if (trimmed.startsWith('- ')) { + const tool = trimmed.substring(2).trim(); + if (isAgent) { + agentTools.push(tool); + } else { + allowedTools.push(tool); + } + continue; + } else if (trimmed && !trimmed.startsWith('-')) { + // End of array, new field started + inAllowedTools = false; + } + } + + // Keep other fields + if (!inAllowedTools) { + newLines.push(line); + } + } + + // For agents: add required Kilo agent fields + if (isAgent) { + newLines.push('mode: subagent'); + newLines.push(...buildKiloAgentPermissionBlock(agentTools)); + } + + // For commands: add tools object if we had allowed-tools or tools + if (!isAgent && allowedTools.length > 0) { + newLines.push('tools:'); + for (const tool of allowedTools) { + newLines.push(` ${convertToolName(tool)}: true`); + } + } + + // Rebuild frontmatter (body already has tool names converted) + const newFrontmatter = newLines.join('\n').trim(); + 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), + ); +} + + +export = { + yamlIdentifier, + yamlQuote, + toSingleLine, + extractFrontmatterAndBody, + extractFrontmatterField, + skillFrontmatterName, + convertClaudeToCopilotContent, + convertClaudeCommandToCopilotSkill, + convertClaudeToAntigravityContent, + convertClaudeCommandToAntigravitySkill, + convertClaudeCommandToClaudeSkill, + convertClaudeCommandToKimiSkill, + buildKimiAgentArtifacts, + convertClaudeToCursorMarkdown, + convertClaudeCommandToCursorSkill, + convertClaudeCommandToCursorCommand, + convertClaudeToWindsurfMarkdown, + convertClaudeCommandToWindsurfSkill, + convertClaudeToAugmentMarkdown, + convertClaudeCommandToAugmentSkill, + convertClaudeToTraeMarkdown, + convertClaudeCommandToTraeSkill, + convertClaudeToCodebuddyMarkdown, + convertClaudeCommandToCodebuddySkill, + convertClaudeCommandToCodebuddyCommand, + convertClaudeToCliineMarkdown, + convertClaudeCommandToClineSkill, + convertSlashCommandsToCodexSkillMentions, + getCodexSkillAdapterHeader, + convertClaudeToCodexMarkdown, + convertClaudeCommandToCodexSkill, + neutralizeAgentReferences, + convertClaudeCommandToOpencodeSkill, + convertClaudeCommandToKiloSkill, + readGsdCommandNames, + transformContentToHyphen, +}; diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index 6e082fbda..926fb2e9a 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -24,6 +24,11 @@ const { stageSkillsForRuntimeAsSkills, stageCommandsForRuntimeFlat, } = installProfiles; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import runtimeArtifactConversion = require('./runtime-artifact-conversion.cjs'); +const conversionExports = runtimeArtifactConversion as Record & { + readGsdCommandNames?: () => string[]; +}; // In .cts (CommonJS output) files, `require` is available as a global. const _require: NodeRequire = require; @@ -33,7 +38,6 @@ const _require: NodeRequire = require; // --------------------------------------------------------------------------- interface InstallExports { - readGsdCommandNames: () => string[]; computePathPrefix: (opts: { isGlobal: boolean; isOpencode: boolean; isWindowsHost: boolean; resolvedTarget: string; homeDir: string }) => string; applyRuntimeContentRewritesInPlace: (stagedDir: string, runtime: string, pathPrefix: string) => void; [converterName: string]: unknown; @@ -194,8 +198,7 @@ function kimiAgentsKind(destSubpath: string, prefix: string, configDir: string): destSubpath, prefix, stage: (resolved) => { - const installExports = getInstallExports(); - const buildKimiAgentArtifacts = installExports['buildKimiAgentArtifacts'] as (opts: { + const buildKimiAgentArtifacts = conversionExports['buildKimiAgentArtifacts'] as (opts: { rootAgent?: string; subagents?: Array<{ path: string; content: string }>; }) => { @@ -237,7 +240,7 @@ function kimiAgentsKind(destSubpath: string, prefix: string, configDir: string): * * @param destSubpath * @param prefix - * @param converterName name of converter function in bin/install.js exports + * @param converterName name of converter function in Runtime Artifact Conversion exports * @param runtime canonical runtime ID (gates Hermes/Qwen branding in converter) * @param configDir runtime config dir (for .gsd-source marker resolution) * @param nested if true, nest concrete skills under their ns-* routers (#69) @@ -260,15 +263,16 @@ function skillsKind( destSubpath, prefix, stage: (resolved) => { - const installExports = getInstallExports(); - const realConverter = installExports[converterName] as (content: string, skillName: string, runtime: string, cmdNames: string[], isGlobal: boolean) => string; + const realConverter = conversionExports[converterName] as (content: string, skillName: string, runtime: string, cmdNames: string[], isGlobal: boolean) => string; // Compute cmdNames once per stage call for performance (#3583). // Extra trailing args are ignored by converters that don't need them. The // isGlobal flag is the 5th positional (NOT the 3rd): the 3rd positional is // `runtime` for the claude/kimi/cline converters, so the scope-aware // converters (antigravity, copilot) read isGlobal from position 5 to avoid // colliding with `runtime` and always taking the global branch. - const cmdNames = installExports.readGsdCommandNames(); + const cmdNames = conversionExports.readGsdCommandNames + ? conversionExports.readGsdCommandNames() + : []; const isGlobal = scope === 'global'; const wrappedConverter = (content: string, skillName: string): string => realConverter(content, skillName, runtime, cmdNames, isGlobal); @@ -282,7 +286,7 @@ function skillsKind( * commands directory with per-file conversion (e.g. Cursor 1.6 slash commands). * * Unlike `commandsKind` (which passes raw source files through), this kind - * applies `converterName` from bin/install.js exports to each file during + * applies `converterName` from Runtime Artifact Conversion exports to each file during * staging, writing flat `${prefix}${stem}.md` files to the staged directory. * * The staged files are then written by `_copyStaged` (commands branch) which @@ -290,7 +294,7 @@ function skillsKind( * * @param destSubpath destination subpath within configDir (e.g. 'commands') * @param prefix filename prefix, e.g. 'gsd-' - * @param converterName name of converter function in bin/install.js exports + * @param converterName name of converter function in Runtime Artifact Conversion exports * @param configDir runtime config dir (for .gsd-source marker resolution) */ function convertedCommandsKind( @@ -304,8 +308,7 @@ function convertedCommandsKind( destSubpath, prefix, stage: (resolved) => { - const installExports = getInstallExports(); - const converter = installExports[converterName] as (content: string, commandName: string) => string; + const converter = conversionExports[converterName] as (content: string, commandName: string) => string; return stageCommandsForRuntimeFlat(findInstallSourceRoot(configDir), resolved, converter, prefix); }, };