diff --git a/.changeset/kimi-runtime-support.md b/.changeset/kimi-runtime-support.md new file mode 100644 index 000000000..364b350b9 --- /dev/null +++ b/.changeset/kimi-runtime-support.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 743 +--- +**Kimi CLI runtime support is now documented and installable** — users can install global GSD Agent Skills with `--kimi --global`, invoke them as `/skill:gsd-*`, and launch the generated custom agent explicitly with `kimi --agent-file`. The custom-agent (`--agent-file`) surface targets the legacy/Python `kimi-cli` contract; newer Kimi Code (`@moonshot-ai/kimi-code`) consumes the same `/skill:gsd-*` skills via `--skills-dir` instead. diff --git a/README.md b/README.md index c111e412e..db7187704 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **English** · [Português](README.pt-BR.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [한국어](README.ko-KR.md) -**A light-weight meta-prompting, context engineering, and spec-driven development system for Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf, and more.** +**A light-weight meta-prompting, context engineering, and spec-driven development system for Claude Code, OpenCode, Gemini CLI, Kimi CLI, Kilo, Codex, Copilot, Cursor, Windsurf, and more.** [![npm version](https://img.shields.io/npm/v/%40opengsd%2Fgsd-core?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/@opengsd/gsd-core) [![npm downloads](https://img.shields.io/npm/dm/%40opengsd%2Fgsd-core?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/@opengsd/gsd-core) @@ -21,7 +21,7 @@ ## What is GSD Core -GSD Core is a context-engineering and spec-driven development framework that drives AI coding agents (Claude Code, Codex, Gemini CLI, Copilot, Cursor, and more) through a disciplined phase loop. It solves [context rot](docs/explanation/context-engineering.md) — the quality degradation that accumulates as an AI fills its context window — by running all heavy research, planning, and execution work in fresh-context subagents while keeping your main session lean. +GSD Core is a context-engineering and spec-driven development framework that drives AI coding agents (Claude Code, Codex, Gemini CLI, Kimi CLI, Copilot, Cursor, and more) through a disciplined phase loop. It solves [context rot](docs/explanation/context-engineering.md) — the quality degradation that accumulates as an AI fills its context window — by running all heavy research, planning, and execution work in fresh-context subagents while keeping your main session lean. --- @@ -43,7 +43,7 @@ Each milestone repeats the same five-step loop, one phase at a time: npx @opengsd/gsd-core@latest ``` -The installer prompts for your runtime (Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf, and more) and whether to install globally or locally. The installer is required for cross-runtime compatibility — do not copy files from `agents/` or `commands/` directly. +The installer prompts for your runtime (Claude Code, OpenCode, Gemini CLI, Kimi CLI, Kilo, Codex, Copilot, Cursor, Windsurf, and more) and whether to install globally or locally. The installer is required for cross-runtime compatibility — do not copy files from `agents/` or `commands/` directly. On another runtime or without Node.js? See [Install on your runtime](docs/how-to/install-on-your-runtime.md). diff --git a/bin/install.js b/bin/install.js index 50ba46122..37e26aebd 100755 --- a/bin/install.js +++ b/bin/install.js @@ -31,6 +31,7 @@ const { const { resolveAntigravityGlobalDir, getGlobalConfigDir, + getGlobalSkillsBase, } = require('../gsd-core/bin/lib/runtime-homes.cjs'); const { applyWorktreeBaseRef, @@ -348,23 +349,6 @@ const { const args = process.argv.slice(2); const hasGlobal = args.includes('--global') || args.includes('-g'); const hasLocal = args.includes('--local') || args.includes('-l'); -const hasOpencode = args.includes('--opencode'); -const hasClaude = args.includes('--claude'); -const hasGemini = args.includes('--gemini'); -const hasKilo = args.includes('--kilo'); -const hasCodex = args.includes('--codex'); -const hasCopilot = args.includes('--copilot'); -const hasAntigravity = args.includes('--antigravity'); -const hasCursor = args.includes('--cursor'); -const hasWindsurf = args.includes('--windsurf'); -const hasAugment = args.includes('--augment'); -const hasTrae = args.includes('--trae'); -const hasQwen = args.includes('--qwen'); -const hasHermes = args.includes('--hermes'); -const hasCodebuddy = args.includes('--codebuddy'); -const hasCline = args.includes('--cline'); -const hasBoth = args.includes('--both'); // Legacy flag, keeps working -const hasAll = args.includes('--all'); const hasUninstall = args.includes('--uninstall') || args.includes('-u'); const hasSkillsRoot = args.includes('--skills-root'); const hasPortableHooks = args.includes('--portable-hooks') || process.env.GSD_PORTABLE_HOOKS === '1'; @@ -391,30 +375,37 @@ if (hasMinimal && _profileArgRaw) { process.exit(1); } -// Runtime selection - can be set by flags or interactive prompt -let selectedRuntimes = []; -if (hasAll) { - selectedRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline']; -} else if (hasBoth) { - selectedRuntimes = ['claude', 'opencode']; -} else { - if (hasClaude) selectedRuntimes.push('claude'); - if (hasOpencode) selectedRuntimes.push('opencode'); - if (hasGemini) selectedRuntimes.push('gemini'); - if (hasKilo) selectedRuntimes.push('kilo'); - if (hasCodex) selectedRuntimes.push('codex'); - if (hasCopilot) selectedRuntimes.push('copilot'); - if (hasAntigravity) selectedRuntimes.push('antigravity'); - if (hasCursor) selectedRuntimes.push('cursor'); - if (hasWindsurf) selectedRuntimes.push('windsurf'); - if (hasAugment) selectedRuntimes.push('augment'); - if (hasTrae) selectedRuntimes.push('trae'); - if (hasQwen) selectedRuntimes.push('qwen'); - if (hasHermes) selectedRuntimes.push('hermes'); - if (hasCodebuddy) selectedRuntimes.push('codebuddy'); - if (hasCline) selectedRuntimes.push('cline'); +function selectRuntimesFromArgs(runtimeArgs) { + if (runtimeArgs.includes('--all')) { + return ['claude', 'kimi', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline']; + } + if (runtimeArgs.includes('--both')) { + return ['claude', 'opencode']; + } + + const selected = []; + if (runtimeArgs.includes('--claude')) selected.push('claude'); + if (runtimeArgs.includes('--opencode')) selected.push('opencode'); + if (runtimeArgs.includes('--gemini')) selected.push('gemini'); + if (runtimeArgs.includes('--kilo')) selected.push('kilo'); + if (runtimeArgs.includes('--codex')) selected.push('codex'); + if (runtimeArgs.includes('--copilot')) selected.push('copilot'); + if (runtimeArgs.includes('--antigravity')) selected.push('antigravity'); + if (runtimeArgs.includes('--cursor')) selected.push('cursor'); + if (runtimeArgs.includes('--windsurf')) selected.push('windsurf'); + if (runtimeArgs.includes('--augment')) selected.push('augment'); + if (runtimeArgs.includes('--trae')) selected.push('trae'); + if (runtimeArgs.includes('--qwen')) selected.push('qwen'); + if (runtimeArgs.includes('--hermes')) selected.push('hermes'); + if (runtimeArgs.includes('--kimi')) selected.push('kimi'); + if (runtimeArgs.includes('--codebuddy')) selected.push('codebuddy'); + if (runtimeArgs.includes('--cline')) selected.push('cline'); + return selected; } +// Runtime selection - can be set by flags or interactive prompt +let selectedRuntimes = selectRuntimesFromArgs(args); + // WSL + Windows Node.js detection // When Windows-native Node runs on WSL, os.homedir() and path.join() produce // backslash paths that don't resolve correctly on the Linux filesystem. @@ -463,6 +454,7 @@ function getDirName(runtime) { if (runtime === 'trae') return '.trae'; if (runtime === 'qwen') return '.qwen'; if (runtime === 'hermes') return '.hermes'; + if (runtime === 'kimi') return '.kimi-code'; if (runtime === 'codebuddy') return '.codebuddy'; if (runtime === 'cline') return '.cline'; return '.claude'; @@ -509,9 +501,17 @@ function getConfigDirFromHome(runtime, isGlobal) { if (runtime === 'hermes') return "'.hermes'"; if (runtime === 'codebuddy') return "'.codebuddy'"; if (runtime === 'cline') return "'.cline'"; + if (runtime === 'kimi') return "'.config', 'agents'"; return "'.claude'"; } +/** + * Compatibility seam for tests and older installer consumers. + * Runtime home resolution now lives in runtime-homes.cjs. + */ +function getGlobalDir(runtime, explicitDir = null) { + return getGlobalConfigDir(runtime, explicitDir); +} const banner = '\n' + cyan + ' ██████╗ ███████╗██████╗\n' + ' ██╔════╝ ██╔════╝██╔══██╗\n' + @@ -523,7 +523,7 @@ const banner = '\n' + ' GSD Core ' + dim + 'v' + pkg.version + reset + '\n' + ' Git. Ship. Done.\n' + ' A meta-prompting, context engineering and spec-driven\n' + - ' development workflows for Claude Code, OpenCode, Gemini, Kilo, Codex, Copilot, Antigravity, Cursor, Windsurf, Augment, Trae, Qwen Code, Hermes Agent, Cline and CodeBuddy.\n'; + ' development workflows for Claude Code, OpenCode, Gemini, Kimi CLI, Kilo, Codex, Copilot, Antigravity, Cursor, Windsurf, Augment, Trae, Qwen Code, Hermes Agent, Cline and CodeBuddy.\n'; // Pure seam: parse --config-dir / -c from an arbitrary args array. // Returns the path string, '' for an empty equals-form value, or null when the @@ -580,7 +580,7 @@ if (hasUninstall) { // Show help if requested if (hasHelp) { - console.log(` ${yellow}Usage:${reset} npx ${pkg.name} [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir ${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n (for WSL/Docker bind-mount setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--profile=${reset} Install a named skill profile. Profiles:\n core — ${PROFILES.core.length} main-loop skills incl. phase (~130 desc tokens)\n standard — ${PROFILES.standard.length} skills incl. phase, review, config (~700)\n full — all skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`gsd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx ${pkg.name}\n\n ${dim}# Install for Claude Code globally${reset}\n npx ${pkg.name} --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx ${pkg.name} --gemini --global\n\n ${dim}# Install for Kilo globally${reset}\n npx ${pkg.name} --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx ${pkg.name} --codex --global\n\n ${dim}# Install for Copilot globally${reset}\n npx ${pkg.name} --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx ${pkg.name} --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx ${pkg.name} --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx ${pkg.name} --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx ${pkg.name} --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx ${pkg.name} --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx ${pkg.name} --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx ${pkg.name} --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx ${pkg.name} --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx ${pkg.name} --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx ${pkg.name} --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx ${pkg.name} --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx ${pkg.name} --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx ${pkg.name} --hermes --local\n\n ${dim}# Install for Cline globally${reset}\n npx ${pkg.name} --cline --global\n\n ${dim}# Install for Cline locally${reset}\n npx ${pkg.name} --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx ${pkg.name} --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx ${pkg.name} --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx ${pkg.name} --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx ${pkg.name} --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx ${pkg.name} --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx ${pkg.name} --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / GEMINI_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / COPILOT_CONFIG_DIR / COPILOT_HOME / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n`); + console.log(` ${yellow}Usage:${reset} npx ${pkg.name} [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--kimi${reset} Install for Kimi CLI only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir ${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n (for WSL/Docker bind-mount setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--profile=${reset} Install a named skill profile. Profiles:\n core — ${PROFILES.core.length} main-loop skills incl. phase (~130 desc tokens)\n standard — ${PROFILES.standard.length} skills incl. phase, review, config (~700)\n full — all skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`gsd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx ${pkg.name}\n\n ${dim}# Install for Claude Code globally${reset}\n npx ${pkg.name} --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx ${pkg.name} --gemini --global\n\n ${dim}# Install for Kilo globally${reset}\n npx ${pkg.name} --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx ${pkg.name} --codex --global\n\n ${dim}# Install for Kimi CLI globally${reset}\n npx ${pkg.name} --kimi --global\n\n ${dim}# Install for Kimi CLI under ~/.kimi-code${reset}\n npx ${pkg.name} --kimi --global --config-dir ~/.kimi-code\n\n ${dim}# Install for Copilot globally${reset}\n npx ${pkg.name} --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx ${pkg.name} --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx ${pkg.name} --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx ${pkg.name} --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx ${pkg.name} --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx ${pkg.name} --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx ${pkg.name} --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx ${pkg.name} --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx ${pkg.name} --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx ${pkg.name} --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx ${pkg.name} --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx ${pkg.name} --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx ${pkg.name} --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx ${pkg.name} --hermes --local\n\n ${dim}# Install for Cline globally${reset}\n npx ${pkg.name} --cline --global\n\n ${dim}# Install for Cline locally${reset}\n npx ${pkg.name} --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx ${pkg.name} --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx ${pkg.name} --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx ${pkg.name} --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx ${pkg.name} --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx ${pkg.name} --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx ${pkg.name} --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / GEMINI_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / KIMI_CONFIG_DIR / COPILOT_CONFIG_DIR / COPILOT_HOME / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n Kimi CLI defaults to the first existing generic skills root: ${cyan}~/.config/agents/skills${reset}, then ${cyan}~/.agents/skills${reset}; if neither exists, GSD creates ${cyan}~/.config/agents${reset}.\n Use ${cyan}--config-dir ~/.kimi-code${reset} or ${cyan}KIMI_CONFIG_DIR=~/.kimi-code${reset} for brand-specific Kimi installs.\n`); process.exit(0); } @@ -1896,6 +1896,35 @@ const claudeToGeminiTools = { 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.) @@ -1945,6 +1974,63 @@ function convertGeminiToolName(claudeTool) { 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', @@ -2225,6 +2311,283 @@ function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null, c 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. @@ -7100,7 +7463,7 @@ function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { walkAndRewrite(fullPath); - } else if (entry.name === 'SKILL.md') { + } else if (entry.name.endsWith('.md')) { let content = fs.readFileSync(fullPath, 'utf8'); content = _applyRuntimeRewrites(content, runtime, pathPrefix); fs.writeFileSync(fullPath, content); @@ -7291,6 +7654,16 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) { content = processAttribution(content, getCommitAttribution(runtime)); break; + case 'kimi': + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); + content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix); + content = content.replace(/\.\/\.claude\b/g, `./${dirName}`); + content = processAttribution(content, getCommitAttribution(runtime)); + break; + default: // Unknown runtime — no rewrites. // OpenCode/Kilo are intentionally absent: their skills are written by @@ -7314,6 +7687,7 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) { * encodes the GSD namespace as its last segment (e.g. `commands/gsd`), in * which case write as `${stem}.md` (directory IS the namespace). * - agents: write as-is (files already carry their own `gsd-` prefix). + * For kimi-agents kind: recursively copy generated YAML/prompt files. */ function _copyStaged(stagedDir, destDir, kind) { if (!fs.existsSync(stagedDir)) return; @@ -7330,6 +7704,11 @@ function _copyStaged(stagedDir, destDir, kind) { return; } + if (kind.kind === 'kimi-agents') { + fs.cpSync(stagedDir, destDir, { recursive: true }); + return; + } + // commands or agents const entries = fs.readdirSync(stagedDir, { withFileTypes: true }); // For commands: apply prefix unless the destSubpath's last segment already @@ -7366,6 +7745,21 @@ function _copyStaged(stagedDir, destDir, kind) { */ function _removeGsdEntries(destDir, kind) { if (!fs.existsSync(destDir)) return; + if (kind.kind === 'kimi-agents') { + for (const fileName of ['gsd.yaml', 'gsd.md']) { + fs.rmSync(path.join(destDir, fileName), { force: true }); + } + const subagentsDir = path.join(destDir, 'subagents'); + if (fs.existsSync(subagentsDir)) { + for (const entry of fs.readdirSync(subagentsDir, { withFileTypes: true })) { + if (!entry.isFile()) continue; + if (!entry.name.startsWith('gsd-')) continue; + if (!entry.name.endsWith('.yaml') && !entry.name.endsWith('.md')) continue; + fs.rmSync(path.join(subagentsDir, entry.name), { force: true }); + } + } + return; + } if (kind.prefix === '') { // Whole-namespace removal (Hermes nested case — destSubpath is skills/gsd) // The directory itself is the GSD namespace, so remove it entirely. @@ -7562,7 +7956,7 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) { // stagedForCopy: the directory to copy from (may differ from staged if rewrites // produce a temp copy — see applyRuntimeContentRewritesForCommandsInPlace). let stagedForCopy = staged; - if (kind.kind === 'skills') { + if (kind.kind === 'skills' || kind.kind === 'kimi-agents') { applyRuntimeContentRewritesInPlace(staged, runtime, pathPrefix); } else if (kind.kind === 'commands') { // Returns a temp dir with rewritten content so source files are never mutated. @@ -7795,6 +8189,9 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand content = content.replace(globalClaudeRegex, pathPrefix); content = content.replace(globalClaudeHomeRegex, pathPrefix); content = content.replace(localClaudeRegex, `./${dirName}/`); + content = content.replace(/~\/\.claude\b/g, pathPrefix.replace(/\/$/, '')); + content = content.replace(/\$HOME\/\.claude\b/g, pathPrefix.replace(/\/$/, '')); + content = content.replace(/\.\/\.claude\b/g, `./${dirName}`); content = content.replace(/~\/\.qwen\//g, pathPrefix); content = content.replace(/\$HOME\/\.qwen\//g, pathPrefix); content = content.replace(/\.\/\.qwen\//g, `./${dirName}/`); @@ -8130,6 +8527,7 @@ function uninstall(isGlobal, runtime = 'claude') { if (runtime === 'trae') runtimeLabel = 'Trae'; if (runtime === 'qwen') runtimeLabel = 'Qwen Code'; if (runtime === 'hermes') runtimeLabel = 'Hermes Agent'; + if (runtime === 'kimi') runtimeLabel = 'Kimi CLI'; if (runtime === 'codebuddy') runtimeLabel = 'CodeBuddy'; console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`); @@ -9063,6 +9461,7 @@ function writeManifest(configDir, runtime = 'claude', options = {}) { const isWindsurf = runtime === 'windsurf'; const isTrae = runtime === 'trae'; const isCline = runtime === 'cline'; + const isKimi = runtime === 'kimi'; const isHermes = runtime === 'hermes'; const gsdDir = path.join(configDir, 'gsd-core'); const commandsDir = path.join(configDir, 'commands', 'gsd'); @@ -9127,7 +9526,16 @@ function writeManifest(configDir, runtime = 'claude', options = {}) { } } } - if (fs.existsSync(agentsDir)) { + if (isKimi && fs.existsSync(agentsDir)) { + const agentHashes = generateManifest(agentsDir); + for (const [rel, hash] of Object.entries(agentHashes)) { + const isRootAgent = rel === 'gsd.yaml' || rel === 'gsd.md'; + const isSubagent = /^subagents\/gsd-[^/]+\.(yaml|md)$/.test(rel); + if (isRootAgent || isSubagent) { + manifest.files['agents/' + rel] = hash; + } + } + } else if (fs.existsSync(agentsDir)) { for (const file of fs.readdirSync(agentsDir)) { if (file.startsWith('gsd-') && (file.endsWith('.md') || file.endsWith('.toml'))) { manifest.files['agents/' + file] = fileHash(path.join(agentsDir, file)); @@ -9148,7 +9556,7 @@ function writeManifest(configDir, runtime = 'claude', options = {}) { // Track hook files so saveLocalPatches() can detect user modifications // Hooks are only installed for runtimes that use settings.json (not Codex/Copilot/Cline) - if (!isCodex && !isCopilot && !isCline) { + if (!isCodex && !isCopilot && !isCline && !isKimi) { const hooksDir = path.join(configDir, 'hooks'); if (fs.existsSync(hooksDir)) { for (const file of fs.readdirSync(hooksDir)) { @@ -9466,6 +9874,8 @@ function reportLocalPatches(configDir, runtime = 'claude') { ? '$gsd-update --reapply' : runtime === 'cursor' ? 'gsd-update --reapply (mention the skill name)' + : runtime === 'kimi' + ? '/skill:gsd-update --reapply' : '/gsd-update --reapply'; console.log(''); console.log(' ' + yellow + 'Local patches detected' + reset + ' (from v' + meta.from_version + '):'); @@ -9496,6 +9906,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { const isOpencode = runtime === 'opencode'; const isGemini = runtime === 'gemini'; const isKilo = runtime === 'kilo'; + const isKimi = runtime === 'kimi'; const isCodex = runtime === 'codex'; const isCopilot = runtime === 'copilot'; const isAntigravity = runtime === 'antigravity'; @@ -9511,6 +9922,23 @@ function install(isGlobal, runtime = 'claude', options = {}) { const dirName = getDirName(runtime); const src = path.join(__dirname, '..'); + if (isKimi && !isGlobal) { + console.log(` ${yellow}⚠${reset} Kimi local install is deferred for Phase 2.`); + console.log(` No .kimi-code/skills or .agents/skills project artifacts were written.`); + console.log(` Project-level Kimi install semantics remain deferred.`); + return { + runtime, + skipped: true, + reason: 'kimi_local_deferred', + configDir: null, + settingsPath: null, + settings: null, + statuslineCommand: null, + updateBannerCommand: null, + rollbackInstallerMigrations: () => {}, + }; + } + // Reusable helper to copy hooks/lib/ (git-cmd.js + gsd-graphify-rebuild.sh). // Defined early so it is visible to both the main and Codex code paths. // `allowlist` (when non-empty) restricts copying to the named top-level entries, @@ -9637,6 +10065,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { if (isTrae) runtimeLabel = 'Trae'; if (isQwen) runtimeLabel = 'Qwen Code'; if (isHermes) runtimeLabel = 'Hermes Agent'; + if (isKimi) runtimeLabel = 'Kimi'; if (isCodebuddy) runtimeLabel = 'CodeBuddy'; if (isCline) runtimeLabel = 'Cline'; @@ -9926,6 +10355,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { // Cline global: emit skills to ~/.cline/skills/ (Cline >= v3.48.0 — #782). const _isSkillsRuntime = isCodex || isCopilot || isAntigravity || isCursor || isWindsurf || isAugment || isTrae || isCodebuddy || isQwen || isHermes || + isKimi || (runtime === 'claude' && isGlobal) || (isCline && isGlobal); @@ -9964,6 +10394,26 @@ function install(isGlobal, runtime = 'claude', options = {}) { } else { failures.push('skills/gsd/*'); } + } else if (isKimi) { + const skillsDir = path.join(targetDir, 'skills'); + const rootAgentPath = path.join(targetDir, 'agents', 'gsd.yaml'); + if (fs.existsSync(skillsDir)) { + const count = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; + if (count > 0) { + console.log(` ${green}✓${reset} Installed ${count} Kimi skills to skills/`); + } else { + failures.push('skills/gsd-*'); + } + } else { + failures.push('skills/gsd-*'); + } + if (fs.existsSync(rootAgentPath)) { + console.log(` ${green}✓${reset} Generated Kimi root agent: ${rootAgentPath}`); + console.log(` Launch with: kimi --agent-file ${rootAgentPath}`); + } else { + failures.push('agents/gsd.yaml'); + } } else { const skillsDir = path.join(targetDir, 'skills'); if (fs.existsSync(skillsDir)) { @@ -10200,7 +10650,9 @@ function install(isGlobal, runtime = 'claude', options = {}) { } } - if (isMinimalMode(_effectiveInstallMode)) { + if (isKimi) { + console.log(` ${dim}↳${reset} Kimi custom agent YAML/prompt artifacts were installed via runtime artifact layout`); + } else if (isMinimalMode(_effectiveInstallMode)) { // Codex registers agents in `config.toml` via `[agents.gsd-*]` sections. // Without stripping them here, a full → minimal reinstall would leave the // runtime advertising the old full agent surface even though the agent @@ -10343,7 +10795,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { failures.push('VERSION'); } - if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline) { + if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && !isKimi) { // Write package.json to force CommonJS mode for GSD scripts // Prevents "require is not defined" errors when project has "type": "module" // Node.js walks up looking for package.json - this stops inheritance from project @@ -10439,7 +10891,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { // the hooks/lib/ helpers — otherwise the Codex comment downstream // ("we deliberately do *not* copy hooks/lib/ for Codex") is contradicted in practice. const hooksLibSrc = path.join(src, 'hooks', 'lib'); - if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && fs.existsSync(hooksLibSrc)) { + if (!isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline && !isKimi && fs.existsSync(hooksLibSrc)) { const hooksLibDest = path.join(targetDir, 'hooks', 'lib'); fs.mkdirSync(hooksLibDest, { recursive: true }); copyLibDir(hooksLibSrc, hooksLibDest, GSD_HOOK_LIB_FILES); @@ -10967,7 +11419,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { } if (configIntent.installSurface === 'profile-marker-only') { - // Windsurf/Trae use skills — no config.toml, no settings.json hooks needed + // Windsurf/Trae/Kimi use artifact-only surfaces — no config.toml or settings.json hooks needed. persistActiveProfileMarker(); return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } @@ -11734,7 +12186,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS const isCline = runtime === 'cline'; const configIntent = resolveRuntimeConfigIntent(runtime); - if (shouldInstallStatusline && !isOpencode && !isKilo && !isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae) { + if (shouldInstallStatusline && configIntent.writesSharedSettings && !isOpencode) { if (!isGlobal && !forceStatusline) { // Local installs skip statusLine by default: repo settings.json takes precedence over // profile-level settings.json in Claude Code, so writing here would silently clobber @@ -11760,7 +12212,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS // settings.json hooks block — opencode/kilo/codex/cursor/windsurf/trae/ // cline either lack the surface or use a different config schema. const { shouldInstallBanner, bannerCommand } = bannerOpts; - if (shouldInstallBanner && settings && !isOpencode && !isKilo && !isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isCline) { + if (shouldInstallBanner && settings && configIntent.writesSharedSettings && !isOpencode) { if (!bannerCommand) { console.warn(` ${yellow}⚠${reset} Skipped update banner registration — Node executable path unavailable. See #2979 / #3002.`); } else { @@ -11800,7 +12252,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS // {type: 'command', command: null} items that the runtime hook schema // rejects at parse time. validateHookFields filters those out so the file // we write is always schema-valid. - if (configIntent.writesSharedSettings) { + if (settingsPath && settings && configIntent.writesSharedSettings) { writeSettings(settingsPath, validateHookFields(settings)); } @@ -11849,6 +12301,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS if (runtime === 'cline') program = 'Cline'; if (runtime === 'qwen') program = 'Qwen Code'; if (runtime === 'hermes') program = 'Hermes Agent'; + if (runtime === 'kimi') program = 'Kimi CLI'; let command = '/gsd-new-project'; if (runtime === 'opencode') command = '/gsd-new-project'; @@ -11864,6 +12317,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS if (runtime === 'cline') command = '/gsd-new-project'; if (runtime === 'qwen') command = '/gsd-new-project'; if (runtime === 'hermes') command = '/gsd-new-project'; + if (runtime === 'kimi') command = '/skill:gsd-new-project'; // Claude Code global installs use the skills/ format (CC 2.1.88+). // Restart is required for CC to pick up newly-installed skills, and the @@ -11878,6 +12332,16 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS return; } + if (runtime === 'kimi') { + const agentPath = configDir ? path.join(configDir, 'agents', 'gsd.yaml') : 'agents/gsd.yaml'; + console.log(` + ${green}Done!${reset} Start ${program} with ${cyan}kimi --agent-file ${agentPath}${reset}, then run ${cyan}${command}${reset}. + + ${cyan}Join the community:${reset} https://discord.gg/mYgfVNfA2r +`); + return; + } + console.log(` ${green}Done!${reset} Open a blank directory in ${program} and run ${cyan}${command}${reset}. @@ -11954,14 +12418,15 @@ const runtimeMap = { '8': 'cursor', '9': 'gemini', '10': 'hermes', - '11': 'kilo', - '12': 'opencode', - '13': 'qwen', - '14': 'trae', - '15': 'windsurf' + '11': 'kimi', + '12': 'kilo', + '13': 'opencode', + '14': 'qwen', + '15': 'trae', + '16': 'windsurf' }; -const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'gemini', 'hermes', 'kilo', 'opencode', 'qwen', 'trae', 'windsurf']; -const ALL_RUNTIMES_OPTION = '16'; +const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'gemini', 'hermes', 'kimi', 'kilo', 'opencode', 'qwen', 'trae', 'windsurf']; +const ALL_RUNTIMES_OPTION = '17'; /** * Build the runtime-selection prompt text shown by the interactive installer. @@ -11979,12 +12444,13 @@ function buildRuntimePromptText() { ${cyan}8${reset}) Cursor ${dim}(~/.cursor)${reset} ${cyan}9${reset}) Gemini ${dim}(~/.gemini)${reset} ${cyan}10${reset}) Hermes Agent ${dim}(~/.hermes)${reset} - ${cyan}11${reset}) Kilo ${dim}(~/.config/kilo)${reset} - ${cyan}12${reset}) OpenCode ${dim}(~/.config/opencode)${reset} - ${cyan}13${reset}) Qwen Code ${dim}(~/.qwen)${reset} - ${cyan}14${reset}) Trae ${dim}(~/.trae)${reset} - ${cyan}15${reset}) Windsurf ${dim}(~/.codeium/windsurf)${reset} - ${cyan}16${reset}) All + ${cyan}11${reset}) Kimi ${dim}(~/.config/agents, then ~/.agents if existing)${reset} + ${cyan}12${reset}) Kilo ${dim}(~/.config/kilo)${reset} + ${cyan}13${reset}) OpenCode ${dim}(~/.config/opencode)${reset} + ${cyan}14${reset}) Qwen Code ${dim}(~/.qwen)${reset} + ${cyan}15${reset}) Trae ${dim}(~/.trae)${reset} + ${cyan}16${reset}) Windsurf ${dim}(~/.codeium/windsurf)${reset} + ${cyan}17${reset}) All ${dim}Select multiple: 1,2,6 or 1 2 6${reset} `; @@ -11995,7 +12461,7 @@ function buildRuntimePromptText() { * Pure function — exported so tests can verify split/dedupe/fallback behavior. * - Accepts comma- and/or whitespace-separated choices * - Deduplicates while preserving order - * - Maps option 16 ("All") to every runtime + * - Maps option 17 ("All") to every runtime * - Falls back to ['claude'] when nothing valid is selected */ function parseRuntimeInput(answer) { @@ -12447,6 +12913,8 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) { try { const printSummaries = () => { for (const result of results) { + if (result && result.skipped) continue; + if (!result) continue; const useStatusline = statuslineRuntimes.includes(result.runtime) && shouldInstallStatusline; finishInstall( result.settingsPath, @@ -12560,6 +13028,10 @@ module.exports = { uninstall, convertSlashCommandsToCodexSkillMentions, convertClaudeCommandToCodexSkill, + convertClaudeCommandToKimiSkill, + convertKimiToolName, + mapClaudeToolsToKimiTools, + buildKimiAgentArtifacts, convertClaudeToOpencodeFrontmatter, convertClaudeToKiloFrontmatter, convertClaudeCommandToOpencodeSkill, @@ -12573,6 +13045,7 @@ module.exports = { GSD_CODEX_MARKER, CODEX_AGENT_SANDBOX, getDirName, + getGlobalDir, getConfigDirFromHome, resolveKiloConfigPath, configureKiloPermissions, @@ -12639,6 +13112,7 @@ module.exports = { maybeSuggestPathExport, runtimeMap, allRuntimes, + selectRuntimesFromArgs, GSD_UNINSTALL_HOOKS, parseRuntimeInput, buildRuntimePromptText, @@ -12689,12 +13163,11 @@ if (require.main === module && !process.env.GSD_TEST_MODE) { console.error('Usage: node install.js --skills-root '); process.exit(1); } - const globalDir = getGlobalConfigDir(runtimeArg, null); - // Hermes nests GSD skills under skills/gsd/ as a single category (#2841). - // Other runtimes use a flat skills/ root. - const skillsRoot = runtimeArg === 'hermes' - ? path.join(globalDir, 'skills', 'gsd') - : path.join(globalDir, 'skills'); + const skillsRoot = getGlobalSkillsBase(runtimeArg); + if (skillsRoot === null) { + console.error(`${runtimeArg} does not use a skills directory`); + process.exit(1); + } console.log(skillsRoot); } else if (hasGlobal && hasLocal) { console.error(` ${yellow}Cannot specify both --global and --local${reset}`); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 145f7208f..c9b6d5c0c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -21,7 +21,7 @@ ## System Overview -GSD Core is a **meta-prompting framework** that sits between the user and AI coding agents (Claude Code, Gemini CLI, OpenCode, Kilo, Codex, Copilot, Antigravity, Trae, Cline, Augment Code). It provides: +GSD Core is a **meta-prompting framework** that sits between the user and AI coding agents (Claude Code, Gemini CLI, Kimi CLI, OpenCode, Kilo, Codex, Copilot, Antigravity, Trae, Cline, Augment Code). It provides: 1. **Context engineering** — Structured artifacts that give the AI everything it needs per task (see [Context engineering](explanation/context-engineering.md)) 2. **Multi-agent orchestration** — Thin orchestrators that spawn specialized agents with fresh context windows (see [Multi-agent orchestration](explanation/multi-agent-orchestration.md)) @@ -116,6 +116,7 @@ User-facing entry points. Each file contains YAML frontmatter (name, description - **Codex:** Skills (`$gsd-command-name`) - **Copilot:** Slash commands (hyphen form, `/gsd-command-name`) - **Gemini CLI:** Slash commands under the `gsd:` namespace (colon form, `/gsd:command-name`) — Gemini namespaces all custom commands under their plugin id, so the install path rewrites every body-text reference to colon form +- **Kimi CLI:** Agent Skills (`/skill:gsd-command-name`) plus an explicit custom agent launch with `kimi --agent-file` - **Antigravity:** Skills **Total commands:** see [`docs/INVENTORY.md`](INVENTORY.md#commands) for the authoritative count and full roster. @@ -575,6 +576,7 @@ Equivalent paths for other runtimes: - **OpenCode:** `~/.config/opencode/` global or `./.opencode/` local - **Kilo:** `~/.config/kilo/` global or `./.kilo/` local - **Gemini CLI:** `~/.gemini/` global or `./.gemini/` local +- **Kimi CLI:** first-existing generic global root (`~/.config/agents/` recommended, then `~/.agents/` if its `skills/` directory already exists); local install is deferred and guarded - **Codex:** `~/.codex/` global or `./.codex/` local - **Copilot:** `~/.copilot/` global or `./.github/` local - **Antigravity:** auto-detected global root (`~/.gemini/antigravity/`, `~/.gemini/antigravity-ide/`, or `~/.gemini/antigravity-cli/`) or `./.agent/` local @@ -669,7 +671,7 @@ verification. The installer (`bin/install.js`, ~10,700 lines) handles: -1. **Runtime detection** — Interactive prompt or CLI flags (`--claude`, `--opencode`, `--gemini`, `--kilo`, `--codex`, `--copilot`, `--antigravity`, `--cursor`, `--windsurf`, `--augment`, `--trae`, `--qwen`, `--hermes`, `--codebuddy`, `--cline`, `--all`) +1. **Runtime detection** — Interactive prompt or CLI flags (`--claude`, `--opencode`, `--gemini`, `--kimi`, `--kilo`, `--codex`, `--copilot`, `--antigravity`, `--cursor`, `--windsurf`, `--augment`, `--trae`, `--qwen`, `--hermes`, `--codebuddy`, `--cline`, `--all`) 2. **Location selection** — Global (`--global`) or local (`--local`) 3. **File deployment** — Copies commands, skills, workflows, references, templates, agents, and hooks 4. **Runtime adaptation** — Transforms file content per runtime: @@ -677,6 +679,7 @@ The installer (`bin/install.js`, ~10,700 lines) handles: - OpenCode: Converts commands/agents to OpenCode-compatible flat command + subagent format - Kilo: Reuses the OpenCode conversion pipeline with Kilo config paths - Codex: Generates TOML config + skills from commands + - Kimi CLI: Generates Agent Skills under `skills/gsd-*/SKILL.md`, custom agent YAML/prompt files, and explicit `kimi_cli.tools.*` module paths - Copilot: Maps tool names (Read→read, Bash→execute, etc.) - Gemini: Adjusts hook event names (`AfterTool` instead of `PostToolUse`) - Antigravity: Skills-first with Google model equivalents @@ -812,6 +815,7 @@ The migration-specific ownership and source snapshots live in | OpenCode | `~/.config/opencode` | `./.opencode` | `command/gsd-*.md` | `agents/gsd-*.md` | `opencode.json` or `opencode.jsonc`; no GSD hooks | | Kilo | `~/.config/kilo` | `./.kilo` | `command/gsd-*.md` | `agents/gsd-*.md` | `kilo.json` or `kilo.jsonc`; no GSD hooks | | Gemini CLI | `~/.gemini` | `./.gemini` | `commands/gsd/*.toml` | `agents/gsd-*.md` | `settings.json` feature flag, hooks, and statusline | +| Kimi CLI | First-existing generic root: `~/.config/agents` recommended, then `~/.agents` when `~/.agents/skills` exists and `~/.config/agents/skills` does not | Deferred and guarded | `skills/gsd-*/SKILL.md` (flat) invoked as `/skill:gsd-*` | `agents/gsd.yaml`, `agents/gsd.md`, and `agents/subagents/gsd-*` YAML/prompt pairs | Explicit `kimi --agent-file /agents/gsd.yaml`; no GSD hooks or statusline | | Codex | `~/.codex` | `./.codex` | `skills/gsd-*/SKILL.md` (flat) | `agents/` source markdown plus per-agent TOML | `config.toml` `[agents.gsd-*]`, `[features].hooks` (canonical; legacy alias `codex_hooks` is recognized and migrated forward on reinstall, #3566), and hook tables | | GitHub Copilot | `~/.copilot` | `./.github` | `skills/gsd-*/SKILL.md` (flat), `copilot-instructions.md`, and `AGENTS.md` (repo root, local) | `.agent.md` files | Self-contained `sessionStart` hook (`hooks/gsd-session.json`, inline `command` type); no statusline | | Antigravity | auto-detected: `~/.gemini/antigravity`, `~/.gemini/antigravity-ide`, or `~/.gemini/antigravity-cli` | `./.agent` | `skills/gsd-ns-*/SKILL.md` (6 routers) + `skills/gsd-ns-*/skills//SKILL.md` (nested concretes) | `agents/gsd-*.md` | Gemini-style `settings.json` hook entries when installed by GSD | @@ -827,12 +831,17 @@ The migration-specific ownership and source snapshots live in ### Upstream Contract Sources Runtime install expectations are checked against primary documentation where -available. The current source snapshot is 2026-05-11: +available. The current source snapshot is 2026-05-11, with Kimi CLI rechecked +on 2026-06-07: - Claude Code: Anthropic slash commands, settings, hooks, and subagents docs. - OpenCode and Kilo: OpenCode config docs and Kilo custom subagent docs. - Gemini CLI and Qwen Code: command/config docs; Qwen command docs were last updated 2026-05-06. +- Kimi CLI: Agent Skills docs for user-level brand roots and first-existing + generic roots (`~/.config/agents/skills/` recommended, then + `~/.agents/skills/`), plus Agents docs for YAML files, `system_prompt_path`, + `kimi_cli.tools.*` module paths, and explicit `kimi --agent-file` launch. - Codex: OpenAI Codex docs and `config-schema.json`; the installer also carries Codex 0.124.0 compatibility for agent table shape. - Copilot, Cursor, Cline, Augment, Hermes, and CodeBuddy: vendor docs for diff --git a/docs/README.md b/docs/README.md index 5d773af02..aebdd7d81 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,7 +15,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md) ## How-to guides -- [Install on your runtime](how-to/install-on-your-runtime.md) — runtime-specific install steps for all 15 supported runtimes +- [Install on your runtime](how-to/install-on-your-runtime.md) — runtime-specific install steps for all 16 supported runtimes - [Install a minimal GSD and add skills later](how-to/install-minimal-and-add-skills.md) — install only the core skills, then grow the surface with profiles and `/gsd:surface` - [Discuss a phase](how-to/discuss-a-phase.md) — capture implementation decisions before planning begins - [Plan a phase](how-to/plan-a-phase.md) — run research, decompose work, and verify plan quality diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index b2f53f5d5..6a89f9c1c 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -212,6 +212,63 @@ All registered hooks are managed by GSD and are removed cleanly on `--uninstall` --- +### Kimi CLI + +> **Support boundary — legacy `kimi-cli` vs Kimi Code.** This integration targets the legacy/Python `kimi-cli` custom-agent contract. The `kimi --agent-file /agents/gsd.yaml` launch shown below is accepted by `kimi-cli`. The newer npm Kimi Code (`@moonshot-ai/kimi-code`, e.g. `0.11.0`) does **not** accept `--agent-file`; it discovers skills through fixed skill roots and `--skills-dir`. The generated `/skill:gsd-*` skills work in both, but the custom-agent (`--agent-file`) surface is specific to legacy `kimi-cli`. For Kimi Code, point it at the installed skills root with `--skills-dir /skills` instead of using `--agent-file`. + +```bash +npx @opengsd/gsd-core@latest --kimi --global +``` + +Skills land in Kimi's first existing generic user skills root: + +- `~/.config/agents/skills/gsd-*/SKILL.md` when `~/.config/agents/skills` already exists, or when neither generic root exists yet +- `~/.agents/skills/gsd-*/SKILL.md` when `~/.agents/skills` already exists and `~/.config/agents/skills` does not + +Start a new Kimi CLI session after install, then invoke GSD skills with `/skill:gsd-*`, for example: + +```text +/skill:gsd-new-project +``` + +The installer also writes the GSD custom agent definition to the same selected config root: `/agents/gsd.yaml` with its prompt at `/agents/gsd.md`; subagents land under `/agents/subagents/gsd-*.yaml` and `/agents/subagents/gsd-*.md`. + +Kimi custom agents do not auto-activate just because the files exist. Launch Kimi with the generated agent file when you want the GSD agent surface: + +```bash +kimi --agent-file ~/.config/agents/agents/gsd.yaml +``` + +If your machine already uses `~/.agents/skills` and does not have `~/.config/agents/skills`, GSD installs there instead and the launch command becomes: + +```bash +kimi --agent-file ~/.agents/agents/gsd.yaml +``` + +Kimi also discovers user skills from the brand-specific `~/.kimi-code` directory. If your Kimi setup is already centered on `~/.kimi-code`, install there explicitly: + +```bash +npx @opengsd/gsd-core@latest --kimi --global --config-dir ~/.kimi-code +``` + +Then launch the generated agent from that directory: + +```bash +kimi --agent-file ~/.kimi-code/agents/gsd.yaml +``` + +For brand-specific scripted installs, use: + +```bash +KIMI_CONFIG_DIR=~/.kimi-code npx @opengsd/gsd-core@latest --kimi --global +``` + +Avoid arbitrary `KIMI_CONFIG_DIR` roots unless your Kimi configuration also adds the matching `skills/` directory to Kimi's extra skill directories. GSD can write files there, but Kimi will not auto-discover skills outside its documented generic and brand-specific roots without that Kimi-side configuration. + +`--kimi --local` is intentionally deferred and guarded in v1; use the global install path above for Kimi CLI. + +--- + ### GitHub Copilot ```bash diff --git a/docs/installer-migrations.md b/docs/installer-migrations.md index 2d8c33197..da1251338 100644 --- a/docs/installer-migrations.md +++ b/docs/installer-migrations.md @@ -345,7 +345,8 @@ only the owned portion. ## Runtime Configuration Contract Registry -Last upstream documentation check: 2026-05-11. +Last upstream documentation check: 2026-05-11. Kimi CLI was rechecked on +2026-06-07 against the MoonshotAI docs. This registry is the source of truth for migrations that touch host runtime configuration. Each row records: @@ -367,6 +368,7 @@ for the new shape before changing migration behavior. | OpenCode | Flat markdown commands in `command/gsd-*.md`; agents in `agents/gsd-*.md`; config updates in `opencode.json` or `opencode.jsonc` | Global `OPENCODE_CONFIG_DIR`, `dirname(OPENCODE_CONFIG)`, `XDG_CONFIG_HOME/opencode`, or `~/.config/opencode`; local `./.opencode` | GSD owns generated command/agent files and GSD entries in structured config only | [Config](https://opencode.ai/docs/config/); docs published 2026-05, checked 2026-05-11 | | Kilo | OpenCode-style flat markdown commands in `command/gsd-*.md`; agents in `agents/gsd-*.md`; config updates in `kilo.json` or `kilo.jsonc` | Global `KILO_CONFIG_DIR`, `dirname(KILO_CONFIG)`, `XDG_CONFIG_HOME/kilo`, or `~/.config/kilo`; local `./.kilo` | GSD owns generated command/agent files and GSD entries in structured config only | [Custom subagents](https://docs.kilo.ai/docs/customize/custom-subagents); docs not versioned, checked 2026-05-11 | | Gemini CLI | TOML slash commands in `commands/gsd/*.toml`; agents in `agents/gsd-*.md`; `settings.json` feature flag, hooks, and statusline | Global `GEMINI_CONFIG_DIR` or `~/.gemini`; local `./.gemini` | GSD owns generated commands/agents/hooks and only GSD settings entries; local command copy may be skipped when global GSD commands already exist | [Custom commands](https://google-gemini.github.io/gemini-cli/docs/cli/custom-commands.html), [configuration](https://google-gemini.github.io/gemini-cli/docs/cli/configuration.html); docs checked 2026-05-11 | +| Kimi CLI | Agent Skills in `skills/gsd-*/SKILL.md`; explicit custom agent YAML/prompt artifacts in `agents/gsd.yaml`, `agents/gsd.md`, and `agents/subagents/gsd-*`; `gsd-core/` payload files referenced by generated skills; manifest, pristine, local-patch, and migration journal files from the normal installer safety pipeline | Global `KIMI_CONFIG_DIR`, explicit `--config-dir`, or first-existing generic skills root: `~/.config/agents` when `~/.config/agents/skills` exists or no generic skills root exists yet, otherwise `~/.agents` when `~/.agents/skills` exists and `~/.config/agents/skills` does not; `KIMI_CONFIG_DIR` and `--config-dir` are GSD write-location overrides and arbitrary roots require Kimi-side `--skills-dir` or `extra_skill_dirs` configuration for skill discovery; local `--kimi --local` is guarded and writes no project-level artifacts | GSD owns only generated `skills/gsd-*`, `agents/gsd.*`, `agents/subagents/gsd-*`, installed `gsd-core/` payload files, and manifest/preservation/migration records. GSD does not own Kimi config files, hooks, settings, rules, statusline, update-banner registration, or non-GSD Kimi skills/agents. Reinstall/update must preserve locally modified generated Kimi artifacts through manifest-backed `gsd-local-patches/`; uninstall removes only GSD-owned Kimi artifacts and preserves non-GSD user content. | [Agent Skills](https://moonshotai.github.io/kimi-cli/en/customization/skills.html), [Agents and Subagents](https://moonshotai.github.io/kimi-cli/en/customization/agents.html), [Tools](https://moonshotai.github.io/kimi-code/en/reference/tools.html); docs checked 2026-06-07 | | Codex | Skills in `skills/gsd-*/SKILL.md`; agents as source markdown plus per-agent TOML in `agents/`; `[agents.gsd-*]` and hooks in `config.toml` | Global `CODEX_HOME` or `~/.codex`; local `./.codex` | GSD owns generated skills, generated agent TOML, `agents.gsd-*` config sections, `[features].hooks` when added by GSD (canonical; legacy alias `codex_hooks` is recognized and migrated forward, #3566), and GSD hook entries | [Codex config schema](https://developers.openai.com/codex/config-schema.json), [Codex developer docs](https://developers.openai.com/codex/); docs not versioned, checked 2026-05-15; installer compatibility sentinel: Codex 0.130.0 features.hooks key (legacy `codex_hooks` recognized) | | GitHub Copilot | Skills in `skills/gsd-*/SKILL.md`; agents as `.agent.md`; repository instructions in `copilot-instructions.md` | Global `COPILOT_CONFIG_DIR`, `COPILOT_HOME`, or `~/.copilot`; local `./.github` | GSD owns generated skill/agent files and GSD-authored instruction files; no hook/statusline ownership | [Repository custom instructions](https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions), [Copilot CLI custom instructions](https://docs.github.com/en/copilot/how-tos/copilot-cli/add-custom-instructions); GitHub Docs product docs, checked 2026-05-11 | | Antigravity | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; Gemini-style `settings.json` hooks when installed by GSD | Global `ANTIGRAVITY_CONFIG_DIR` or `~/.gemini/antigravity`; local `./.agent` | GSD owns generated skills/agents/hooks and GSD settings entries only | Public Antigravity install/config docs for this file layout were not stable or complete as of 2026-05-11; installer compatibility therefore uses GSD's Gemini-compatible settings policy, documented shim baseline. | diff --git a/docs/ja-JP/README.md b/docs/ja-JP/README.md index 96a7174da..363ae0727 100644 --- a/docs/ja-JP/README.md +++ b/docs/ja-JP/README.md @@ -15,7 +15,7 @@ ## How-to guides -- [ランタイムへのインストール](how-to/install-on-your-runtime.md) — サポートされる全 15 ランタイムのランタイム別インストール手順 +- [ランタイムへのインストール](how-to/install-on-your-runtime.md) — サポートされる全 16 ランタイムのランタイム別インストール手順 - [フェーズを議論する](how-to/discuss-a-phase.md) — 計画を始める前に実装上の決定事項を記録する - [フェーズを計画する](how-to/plan-a-phase.md) — リサーチを実行し、作業を分解し、計画の品質を検証する - [フェーズを実行する](how-to/execute-a-phase.md) — 新鮮なコンテキストのサブエージェントで並列ウェーブとして計画を実行する diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md index 2132cb38f..cde3e49b7 100644 --- a/docs/zh-CN/README.md +++ b/docs/zh-CN/README.md @@ -15,7 +15,7 @@ ## How-to guides -- [在你的运行时上安装](how-to/install-on-your-runtime.md) — 适用于全部 15 个受支持运行时的安装步骤 +- [在你的运行时上安装](how-to/install-on-your-runtime.md) — 适用于全部 16 个受支持运行时的安装步骤 - [讨论一个阶段](how-to/discuss-a-phase.md) — 在规划开始前记录实现决策 - [规划一个阶段](how-to/plan-a-phase.md) — 执行调研、分解工作并验证计划质量 - [执行一个阶段](how-to/execute-a-phase.md) — 使用全新上下文的子代理以并行波次运行计划 diff --git a/gsd-core/bin/shared/model-catalog.json b/gsd-core/bin/shared/model-catalog.json index 8e0d37d97..ac67efe7f 100644 --- a/gsd-core/bin/shared/model-catalog.json +++ b/gsd-core/bin/shared/model-catalog.json @@ -52,6 +52,11 @@ "sonnet": null, "haiku": null }, + "kimi": { + "opus": null, + "sonnet": null, + "haiku": null + }, "cursor": { "opus": null, "sonnet": null, diff --git a/gsd-core/bin/shared/runtime-aliases.manifest.json b/gsd-core/bin/shared/runtime-aliases.manifest.json index 2af00c148..3192f64ce 100644 --- a/gsd-core/bin/shared/runtime-aliases.manifest.json +++ b/gsd-core/bin/shared/runtime-aliases.manifest.json @@ -64,6 +64,9 @@ "hermes-agent", "hermes-cli" ], + "kimi": [ + "kimi" + ], "codebuddy": [ "codebuddy", "codebuddy-cli" diff --git a/src/core.cts b/src/core.cts index e07e6baaf..1e9f23288 100644 --- a/src/core.cts +++ b/src/core.cts @@ -241,7 +241,18 @@ function checkAgentsInstalled(runtime?: string): AgentsInstalledResult { const agentFile = path.join(agentsDir, `${agent}.md`); const agentFileCopilot = path.join(agentsDir, `${agent}.agent.md`); const agentFileCodex = path.join(agentsDir, `${agent}.toml`); - if (fs.existsSync(agentFile) || fs.existsSync(agentFileCopilot) || fs.existsSync(agentFileCodex)) { + const agentFileKimiYaml = path.join(agentsDir, 'subagents', `${agent}.yaml`); + const agentFileKimiPrompt = path.join(agentsDir, 'subagents', `${agent}.md`); + const kimiAgentInstalled = + resolvedRuntime === 'kimi' && + fs.existsSync(agentFileKimiYaml) && + fs.existsSync(agentFileKimiPrompt); + if ( + fs.existsSync(agentFile) || + fs.existsSync(agentFileCopilot) || + fs.existsSync(agentFileCodex) || + kimiAgentInstalled + ) { installed.push(agent); } else { missing.push(agent); diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index edc1868cf..3249f6065 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -15,6 +15,7 @@ import path from 'node:path'; import fs from 'node:fs'; +import os from 'node:os'; // eslint-disable-next-line @typescript-eslint/no-require-imports import installProfiles = require('./install-profiles.cjs'); const { @@ -67,6 +68,7 @@ function getInstallExports(): InstallExports { // --------------------------------------------------------------------------- type ArtifactKindName = 'commands' | 'agents' | 'skills'; +type KimiArtifactKindName = ArtifactKindName | 'kimi-agents'; // Mirrors the (unexported) ResolvedProfile in install-profiles.cts. // Must stay in sync if that shape changes. @@ -77,7 +79,7 @@ interface ResolvedProfile { } interface ArtifactKind { - kind: ArtifactKindName; + kind: KimiArtifactKindName; destSubpath: string; prefix: string; stage: (resolvedProfile: ResolvedProfile) => string; @@ -171,7 +173,7 @@ function findAgentsSourceRoot(runtimeConfigDir?: string): string { const ALLOWED_RUNTIMES = new Set([ 'claude', 'cursor', 'gemini', 'codex', 'copilot', 'antigravity', 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', - 'cline', 'opencode', 'kilo', + 'cline', 'kimi', 'opencode', 'kilo', ]); // --------------------------------------------------------------------------- @@ -196,6 +198,50 @@ function agentsKind(destSubpath: string, prefix: string, configDir: string): Art }; } +function kimiAgentsKind(destSubpath: string, prefix: string, configDir: string): ArtifactKind { + return { + kind: 'kimi-agents', + destSubpath, + prefix, + stage: (resolved) => { + const installExports = getInstallExports(); + const buildKimiAgentArtifacts = installExports['buildKimiAgentArtifacts'] as (opts: { + rootAgent?: string; + subagents?: Array<{ path: string; content: string }>; + }) => { + root: { yaml: string; prompt: string }; + subagents: Array<{ name: string; yaml: string; prompt: string }>; + }; + const stagedAgents = stageAgentsForProfile(findAgentsSourceRoot(configDir), resolved); + const subagents: Array<{ path: string; content: string }> = []; + if (fs.existsSync(stagedAgents)) { + for (const entry of fs.readdirSync(stagedAgents, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.md')) continue; + const agentPath = path.join(stagedAgents, entry.name); + subagents.push({ + path: path.join('agents', entry.name).replace(/\\/g, '/'), + content: fs.readFileSync(agentPath, 'utf8'), + }); + } + } + + const rootAgent = `---\nname: gsd\ndescription: Run GSD workflows in Kimi CLI.\ntools: Agent\n---\n\n# GSD for Kimi CLI\n\nCoordinate installed /skill:gsd-* workflows and route work to generated GSD subagents when a workflow requires an agent handoff.\n`; + const artifacts = buildKimiAgentArtifacts({ rootAgent, subagents }); + const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-agents-')); + installProfiles.STAGED_DIRS.add(stageDir); + fs.writeFileSync(path.join(stageDir, 'gsd.yaml'), artifacts.root.yaml); + fs.writeFileSync(path.join(stageDir, 'gsd.md'), artifacts.root.prompt); + const subagentsDir = path.join(stageDir, 'subagents'); + fs.mkdirSync(subagentsDir, { recursive: true }); + for (const artifact of artifacts.subagents) { + fs.writeFileSync(path.join(subagentsDir, `${artifact.name}.yaml`), artifact.yaml); + fs.writeFileSync(path.join(subagentsDir, `${artifact.name}.md`), artifact.prompt); + } + return stageDir; + }, + }; +} + /** * Build a skills kind descriptor. * @@ -401,6 +447,15 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope: kinds = scope === 'global' ? [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClineSkill', 'cline', configDir, true /* #69 nested */)] : []; break; + case 'kimi': + kinds = scope === 'global' + ? [ + skillsKind('skills', 'gsd-', 'convertClaudeCommandToKimiSkill', 'kimi', configDir), + kimiAgentsKind('agents', 'gsd', configDir), + ] + : []; + break; + case 'opencode': // OpenCode reads flat slash commands from command/ and on-demand skills // from skills//SKILL.md (https://opencode.ai/docs/skills). Emit both. diff --git a/src/runtime-config-adapter-registry.cts b/src/runtime-config-adapter-registry.cts index 68d1b4d90..bce78fd63 100644 --- a/src/runtime-config-adapter-registry.cts +++ b/src/runtime-config-adapter-registry.cts @@ -14,7 +14,7 @@ * 'cursor-hooks-json' → early-return after writing .cursor/hooks.json (issue #777). * 'profile-marker-only' → early-return after writing only the profile marker. * - `writesSharedSettings` is the finishInstall writeSettings gate: - * false for codex / copilot / kilo / cursor / windsurf / trae / cline (legacy exclusion list). + * false for codex / copilot / kilo / cursor / windsurf / trae / cline / kimi (legacy exclusion list). * true for all other runtimes. * - `finishPermissionWriter` names the finishInstall-phase dedicated config writer: * 'opencode' → writes BOTH shared settings AND its own permissions file. @@ -69,13 +69,14 @@ const REGISTRY: Record> = Object.freeze({ cursor: Object.freeze({ installSurface: 'cursor-hooks-json', writesSharedSettings: false, finishPermissionWriter: null } as const), windsurf: Object.freeze({ installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null } as const), trae: Object.freeze({ installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null } as const), + kimi: Object.freeze({ installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null } as const), }); // --------------------------------------------------------------------------- // Exports // --------------------------------------------------------------------------- -/** The complete set of 15 supported runtimes for config-adapter dispatch. */ +/** The complete set of 16 supported runtimes for config-adapter dispatch. */ const ALLOWED_CONFIG_RUNTIMES: ReadonlySet = new Set(Object.keys(REGISTRY)); /** All valid installSurface values. */ diff --git a/src/runtime-homes.cts b/src/runtime-homes.cts index 5171bdd1b..9eaf68637 100644 --- a/src/runtime-homes.cts +++ b/src/runtime-homes.cts @@ -14,6 +14,11 @@ * cline — Skills-capable since v3.48.0 (#782). SKILL.md files live at * ~/.cline/skills//SKILL.md (same flat layout as cursor/codex). * .clinerules is also emitted (rules-based compatibility layer). + * kimi — Agent Skills are discovered from Kimi's generic user roots: + * ~/.config/agents/skills (recommended) then ~/.agents/skills, + * with Kimi selecting the first existing generic skills directory. + * ~/.kimi-code/skills is brand-specific and can be selected as a + * GSD write target with --config-dir or KIMI_CONFIG_DIR. */ import os from 'node:os'; @@ -35,6 +40,12 @@ export interface ResolveAntigravityOpts { existsSync?: (p: string) => boolean; } +export interface ResolveKimiOpts { + env?: Record; + home?: string; + existsSync?: (p: string) => boolean; +} + /** * Resolve Antigravity global config dir across 1.x and 2.x layouts. */ @@ -58,6 +69,38 @@ export function resolveAntigravityGlobalDir(opts: ResolveAntigravityOpts = {}): return path.join(base, 'antigravity'); } +/** + * Resolve Kimi's generic user root using Kimi CLI's documented first-existing + * generic skills directory policy: + * + * 1. ~/.config/agents/skills (recommended) + * 2. ~/.agents/skills + * + * If neither generic skills directory exists yet, install to the recommended + * ~/.config/agents root so the generated skills become the first generic + * candidate Kimi discovers. + * + * KIMI_CONFIG_DIR is a GSD installer write-location override. It is not Kimi's + * upstream data-root variable, and arbitrary roots are discoverable by Kimi only + * when the user also configures Kimi --skills-dir or extra_skill_dirs. + */ +export function resolveKimiGlobalDir(opts: ResolveKimiOpts = {}): string { + const env: Record = opts.env ?? process.env; + const home = opts.home ?? os.homedir(); + const existsSyncFn = opts.existsSync ?? fs.existsSync; + + if (env['KIMI_CONFIG_DIR']) return expandTilde(env['KIMI_CONFIG_DIR']); + + const recommendedRoot = path.join(home, '.config', 'agents'); + const fallbackRoot = path.join(home, '.agents'); + const candidates = [recommendedRoot, fallbackRoot]; + for (const candidate of candidates) { + if (existsSyncFn(path.join(candidate, 'skills'))) return candidate; + } + + return recommendedRoot; +} + /** * Return the global config base directory for the given runtime. * Respects the same env-var overrides as bin/install.js getGlobalDir(). @@ -134,6 +177,11 @@ export function getGlobalConfigDir(runtime: string, explicitDir?: string | null) case 'cline': return env['CLINE_CONFIG_DIR'] ? expandTilde(env['CLINE_CONFIG_DIR']) : path.join(home, '.cline'); + // ── Kimi CLI (generic agents user root) ──────────────────────────────── + case 'kimi': { + return resolveKimiGlobalDir({ env, home }); + } + // ── OpenCode (XDG) ─────────────────────────────────────────────────────── case 'opencode': { if (env['OPENCODE_CONFIG_DIR']) return expandTilde(env['OPENCODE_CONFIG_DIR']); diff --git a/src/runtime-name-policy.cts b/src/runtime-name-policy.cts index 199515482..68cb1a850 100644 --- a/src/runtime-name-policy.cts +++ b/src/runtime-name-policy.cts @@ -28,6 +28,7 @@ const FALLBACK_ALIASES: Readonly> = { trae: ['trae', 'trae-cli'], qwen: ['qwen', 'qwen-code', 'qwen-cli'], hermes: ['hermes', 'hermes-agent', 'hermes-cli'], + kimi: ['kimi'], codebuddy: ['codebuddy', 'codebuddy-cli'], cline: ['cline', 'cline-cli'], }; diff --git a/tests/agent-install-validation.test.cjs b/tests/agent-install-validation.test.cjs index 17c03a858..4dd8bce6f 100644 --- a/tests/agent-install-validation.test.cjs +++ b/tests/agent-install-validation.test.cjs @@ -10,12 +10,17 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); +const os = require('os'); const path = require('path'); +const { spawnSync } = require('node:child_process'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const { installerEnv } = require('./helpers/install-shared.cjs'); const AGENTS_DIR_NAME = 'agents'; const MODEL_PROFILES = require('../gsd-core/bin/lib/model-profiles.cjs').MODEL_PROFILES; const EXPECTED_AGENTS = Object.keys(MODEL_PROFILES); +const ROOT = path.join(__dirname, '..'); +const INSTALL_SCRIPT = path.join(ROOT, 'bin', 'install.js'); /** * Create a fake GSD install directory structure that mirrors what the installer @@ -276,6 +281,76 @@ describe('checkAgentsInstalled: Copilot .agent.md format (#1512)', () => { }); }); +// ─── Kimi agents/subagents detection (#743 review) ───────────────────────── + +describe('checkAgentsInstalled: Kimi agents/subagents layout', () => { + test('Kimi install is detected by init, validate agents, and health checks', () => { + const tmpDir = createTempProject('gsd-kimi-agent-status-project-'); + const tmpConfig = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-agent-status-config-')); + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-agent-status-home-')); + const env = installerEnv({ + HOME: tmpHome, + USERPROFILE: tmpHome, + KIMI_CONFIG_DIR: tmpConfig, + }); + + try { + const installResult = spawnSync( + process.execPath, + [INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', tmpConfig, '--no-sdk'], + { + cwd: tmpDir, + encoding: 'utf8', + env, + }, + ); + assert.strictEqual( + installResult.status, + 0, + `Kimi install failed\nstdout: ${installResult.stdout}\nstderr: ${installResult.stderr}`, + ); + + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ runtime: 'kimi', model_profile: 'balanced' }, null, 2), + ); + + const initResult = runGsdTools('init new-workspace --raw', tmpDir, env); + assert.ok(initResult.success, `init failed: ${initResult.error}`); + const initOutput = JSON.parse(initResult.output); + assert.strictEqual(initOutput.agent_runtime, 'kimi'); + assert.strictEqual(initOutput.agents_dir, path.join(tmpConfig, 'agents')); + assert.strictEqual(initOutput.agents_installed, true); + assert.deepStrictEqual(initOutput.missing_agents, []); + + const validateResult = runGsdTools('validate agents --raw', tmpDir, { + ...env, + GSD_RUNTIME: 'kimi', + }); + assert.ok(validateResult.success, `validate agents failed: ${validateResult.error}`); + const validateOutput = JSON.parse(validateResult.output); + assert.strictEqual(validateOutput.agents_dir, path.join(tmpConfig, 'agents')); + assert.strictEqual(validateOutput.agents_found, true); + assert.deepStrictEqual(validateOutput.missing, []); + + const healthResult = runGsdTools('validate health --raw', tmpDir, { + ...env, + GSD_RUNTIME: 'kimi', + }); + assert.ok(healthResult.success, `validate health failed: ${healthResult.error}`); + const healthOutput = JSON.parse(healthResult.output); + const agentWarnings = (healthOutput.warnings || []).filter( + (warning) => warning.code === 'W010' || /GSD agents/i.test(warning.message || ''), + ); + assert.deepStrictEqual(agentWarnings, []); + } finally { + cleanup(tmpDir); + cleanup(tmpConfig); + cleanup(tmpHome); + } + }); +}); + // ─── validate agents subcommand ───────────────────────────────────────────── describe('validate agents subcommand (#1371)', () => { diff --git a/tests/bug-kimi-path-layout-local-guard.test.cjs b/tests/bug-kimi-path-layout-local-guard.test.cjs new file mode 100644 index 000000000..db97faa77 --- /dev/null +++ b/tests/bug-kimi-path-layout-local-guard.test.cjs @@ -0,0 +1,418 @@ +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const { cleanup } = require('./helpers.cjs'); +const { installerEnv } = require('./helpers/install-shared.cjs'); + +const ROOT = path.join(__dirname, '..'); +const INSTALL_SCRIPT = path.join(ROOT, 'bin', 'install.js'); + +const { + getGlobalConfigDir, + getGlobalSkillsBase, + getGlobalSkillDir, + resolveKimiGlobalDir, +} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs')); +const { + resolveRuntimeArtifactLayout, +} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-artifact-layout.cjs')); +const { + getGlobalDir, + getConfigDirFromHome, +} = require('../bin/install.js'); + +function withEnv(updates, fn) { + const saved = {}; + for (const key of Object.keys(updates)) { + saved[key] = process.env[key]; + const value = updates[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + return fn(); + } finally { + for (const key of Object.keys(updates)) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + } +} + +describe('Kimi runtime homes', () => { + test('default Kimi global root is recommended ~/.config/agents when no generic skills root exists', () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-home-default-')); + try { + withEnv({ KIMI_CONFIG_DIR: undefined, XDG_CONFIG_HOME: undefined, HOME: tmpHome, USERPROFILE: tmpHome }, () => { + assert.strictEqual( + getGlobalConfigDir('kimi'), + path.join(tmpHome, '.config', 'agents'), + ); + assert.strictEqual( + getGlobalSkillsBase('kimi'), + path.join(tmpHome, '.config', 'agents', 'skills'), + ); + assert.strictEqual( + getGlobalSkillDir('kimi', 'gsd-help'), + path.join(tmpHome, '.config', 'agents', 'skills', 'gsd-help'), + ); + assert.notStrictEqual( + getGlobalSkillsBase('kimi'), + path.join(tmpHome, '.kimi-code', 'skills'), + ); + }); + } finally { + cleanup(tmpHome); + } + }); + + test('Kimi root resolution follows first-existing generic skills directory order', () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-home-existing-')); + try { + const recommendedRoot = path.join(tmpHome, '.config', 'agents'); + const fallbackRoot = path.join(tmpHome, '.agents'); + assert.strictEqual( + resolveKimiGlobalDir({ env: {}, home: tmpHome, existsSync: fs.existsSync }), + recommendedRoot, + ); + + fs.mkdirSync(path.join(fallbackRoot, 'skills'), { recursive: true }); + assert.strictEqual( + resolveKimiGlobalDir({ env: {}, home: tmpHome, existsSync: fs.existsSync }), + fallbackRoot, + ); + + fs.mkdirSync(path.join(recommendedRoot, 'skills'), { recursive: true }); + assert.strictEqual( + resolveKimiGlobalDir({ env: {}, home: tmpHome, existsSync: fs.existsSync }), + recommendedRoot, + ); + } finally { + cleanup(tmpHome); + } + }); + + test('KIMI_CONFIG_DIR can select the brand-specific ~/.kimi-code root', () => { + withEnv({ KIMI_CONFIG_DIR: '/tmp/custom-kimi-code', XDG_CONFIG_HOME: undefined }, () => { + assert.strictEqual(getGlobalConfigDir('kimi'), '/tmp/custom-kimi-code'); + assert.strictEqual( + getGlobalSkillsBase('kimi'), + path.join('/tmp/custom-kimi-code', 'skills'), + ); + assert.strictEqual(getGlobalDir('kimi'), '/tmp/custom-kimi-code'); + }); + }); + + test('XDG_CONFIG_HOME does not change Kimi default root', () => { + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-home-xdg-')); + try { + withEnv({ KIMI_CONFIG_DIR: undefined, XDG_CONFIG_HOME: '/tmp/xdg-home', HOME: tmpHome, USERPROFILE: tmpHome }, () => { + assert.strictEqual( + getGlobalConfigDir('kimi'), + path.join(tmpHome, '.config', 'agents'), + ); + assert.strictEqual( + getConfigDirFromHome('kimi', true), + "'.config', 'agents'", + ); + }); + } finally { + cleanup(tmpHome); + } + }); + + test('Kimi global install reuses existing ~/.agents/skills when recommended root is absent', () => { + const tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-existing-agents-project-')); + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-existing-agents-home-')); + try { + fs.mkdirSync(path.join(tmpHome, '.agents', 'skills'), { recursive: true }); + const result = spawnSync( + process.execPath, + [INSTALL_SCRIPT, '--kimi', '--global', '--no-sdk'], + { + cwd: tmpProject, + encoding: 'utf8', + env: installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome }), + }, + ); + + assert.strictEqual( + result.status, + 0, + `expected --kimi --global to reuse existing ~/.agents/skills\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, + ); + assert.strictEqual( + fs.existsSync(path.join(tmpHome, '.agents', 'skills', 'gsd-new-project', 'SKILL.md')), + true, + ); + assert.strictEqual( + fs.existsSync(path.join(tmpHome, '.config', 'agents', 'skills', 'gsd-new-project', 'SKILL.md')), + false, + ); + } finally { + cleanup(tmpProject); + cleanup(tmpHome); + } + }); +}); + +describe('Kimi runtime artifact layout', () => { + test('global layout stages Kimi skills and agents while local layout remains guarded', () => { + const globalLayout = resolveRuntimeArtifactLayout('kimi', '/tmp/kimi-config', 'global'); + assert.strictEqual(globalLayout.runtime, 'kimi'); + assert.strictEqual(globalLayout.configDir, '/tmp/kimi-config'); + assert.strictEqual(globalLayout.kinds.length, 2); + assert.strictEqual(globalLayout.kinds[0].kind, 'skills'); + assert.strictEqual(globalLayout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(globalLayout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof globalLayout.kinds[0].stage, 'function'); + assert.strictEqual(globalLayout.kinds[1].kind, 'kimi-agents'); + assert.strictEqual(globalLayout.kinds[1].destSubpath, 'agents'); + assert.strictEqual(globalLayout.kinds[1].prefix, 'gsd'); + assert.strictEqual(typeof globalLayout.kinds[1].stage, 'function'); + + const localLayout = resolveRuntimeArtifactLayout('kimi', '/tmp/kimi-config', 'local'); + assert.strictEqual(localLayout.runtime, 'kimi'); + assert.deepStrictEqual(localLayout.kinds, []); + }); +}); + +describe('Kimi local install guard', () => { + test('--kimi --local exits successfully without writing local Kimi project artifacts', () => { + const tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-local-project-')); + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-local-home-')); + try { + const result = spawnSync( + process.execPath, + [INSTALL_SCRIPT, '--kimi', '--local', '--no-sdk'], + { + cwd: tmpProject, + encoding: 'utf8', + env: installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome }), + }, + ); + + assert.strictEqual( + result.status, + 0, + `expected --kimi --local guard to no-op successfully\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, + ); + const combined = `${result.stdout}\n${result.stderr}`; + assert.match(combined, /Kimi local install/i); + assert.match(combined, /deferred/i); + + assert.ok(!fs.existsSync(path.join(tmpProject, '.kimi')), 'must not create legacy .kimi/'); + assert.ok(!fs.existsSync(path.join(tmpProject, '.kimi-code')), 'must not create .kimi-code/'); + assert.ok(!fs.existsSync(path.join(tmpProject, '.agents')), 'must not create .agents/'); + assert.ok(!fs.existsSync(path.join(tmpProject, '.claude')), 'must not fall back to Claude local install'); + } finally { + cleanup(tmpProject); + cleanup(tmpHome); + } + }); + + test('--kimi --global writes converted Kimi skills and agent artifacts', () => { + const tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-global-project-')); + const tmpConfig = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-global-config-')); + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-global-home-')); + try { + const result = spawnSync( + process.execPath, + [INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', tmpConfig, '--no-sdk'], + { + cwd: tmpProject, + encoding: 'utf8', + env: installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome }), + }, + ); + + assert.strictEqual( + result.status, + 0, + `expected --kimi --global to install Kimi skills successfully\nstdout: ${result.stdout}\nstderr: ${result.stderr}`, + ); + const combined = `${result.stdout}\n${result.stderr}`; + assert.match(combined, /Installing for .*Kimi/i); + assert.match(combined, /Installed \d+ Kimi skills to skills\//i); + assert.match(combined, /Generated Kimi root agent: .*agents.*gsd\.yaml/i); + assert.match(combined, /kimi --agent-file/i); + assert.match(combined, /Wrote file manifest/i); + + const skillFile = path.join(tmpConfig, 'skills', 'gsd-new-project', 'SKILL.md'); + assert.ok(fs.existsSync(skillFile), 'must write gsd-new-project/SKILL.md'); + const skillContent = fs.readFileSync(skillFile, 'utf8'); + assert.match(skillContent, /^name: gsd-new-project$/m); + assert.match(skillContent, /\/skill:gsd-new-project/); + assert.match(skillContent, /gsd-core\/workflows\/new-project\.md/); + assert.doesNotMatch(skillContent, /@~\/\.claude\/gsd-core|@\$HOME\/\.claude\/gsd-core/); + assert.doesNotMatch(skillContent, /@[^\r\n]*\\/, 'serialized Kimi payload references must use forward slashes'); + assert.doesNotMatch(skillContent, /kimi_cli\.tools|system_prompt_path|^version: 1$/m); + + const rootYaml = path.join(tmpConfig, 'agents', 'gsd.yaml'); + const rootPrompt = path.join(tmpConfig, 'agents', 'gsd.md'); + const executorYaml = path.join(tmpConfig, 'agents', 'subagents', 'gsd-executor.yaml'); + const executorPrompt = path.join(tmpConfig, 'agents', 'subagents', 'gsd-executor.md'); + assert.ok(fs.existsSync(rootYaml), 'must write agents/gsd.yaml'); + assert.ok(fs.existsSync(rootPrompt), 'must write agents/gsd.md'); + assert.ok(fs.existsSync(executorYaml), 'must write agents/subagents/gsd-executor.yaml'); + assert.ok(fs.existsSync(executorPrompt), 'must write agents/subagents/gsd-executor.md'); + + const rootYamlContent = fs.readFileSync(rootYaml, 'utf8'); + assert.match(rootYamlContent, /^version: 1$/m); + assert.match(rootYamlContent, /^agent:$/m); + assert.match(rootYamlContent, /extend: default/); + assert.match(rootYamlContent, /system_prompt_path: \.\/gsd\.md/); + assert.match(rootYamlContent, /tools:/); + assert.match(rootYamlContent, /subagents:/); + assert.match(rootYamlContent, /kimi_cli\.tools\./); + assert.doesNotMatch(rootYamlContent, /mcp__/); + + const executorYamlContent = fs.readFileSync(executorYaml, 'utf8'); + assert.match(executorYamlContent, /system_prompt_path: \.\/gsd-executor\.md/); + assert.match(executorYamlContent, /kimi_cli\.tools\./); + assert.doesNotMatch(executorYamlContent, /mcp__/); + + assert.ok(fs.existsSync(path.join(tmpConfig, 'gsd-core', 'workflows', 'new-project.md')), 'must write workflow payloads used by Kimi skills'); + const manifestPath = path.join(tmpConfig, 'gsd-file-manifest.json'); + assert.ok(fs.existsSync(manifestPath), 'must write gsd-file-manifest.json for Kimi installs'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + assert.ok(manifest.files['skills/gsd-new-project/SKILL.md'], 'manifest tracks generated Kimi skill'); + assert.ok(manifest.files['agents/gsd.yaml'], 'manifest tracks Kimi root agent YAML'); + assert.ok(manifest.files['agents/gsd.md'], 'manifest tracks Kimi root agent prompt'); + assert.ok(manifest.files['agents/subagents/gsd-executor.yaml'], 'manifest tracks Kimi subagent YAML'); + assert.ok(manifest.files['agents/subagents/gsd-executor.md'], 'manifest tracks Kimi subagent prompt'); + assert.ok(manifest.files['gsd-core/workflows/new-project.md'], 'manifest tracks installed workflow payload'); + assert.ok(!fs.existsSync(path.join(tmpConfig, 'hooks')), 'must not write hooks under the Kimi root'); + assert.ok(!fs.existsSync(path.join(tmpConfig, 'settings.json')), 'must not write settings.json under the Kimi root'); + assert.ok(!fs.existsSync(path.join(tmpConfig, '.clinerules')), 'must not write rules under the Kimi root'); + } finally { + cleanup(tmpProject); + cleanup(tmpConfig); + cleanup(tmpHome); + } + }); + + test('--kimi --global backs up local edits to generated skills and agent artifacts on reinstall', () => { + const tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-reinstall-project-')); + const tmpConfig = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-reinstall-config-')); + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-reinstall-home-')); + const env = installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome }); + + try { + const installArgs = [INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', tmpConfig, '--no-sdk']; + const first = spawnSync(process.execPath, installArgs, { + cwd: tmpProject, + encoding: 'utf8', + env, + }); + assert.strictEqual( + first.status, + 0, + `first install failed\nstdout: ${first.stdout}\nstderr: ${first.stderr}`, + ); + + const skillFile = path.join(tmpConfig, 'skills', 'gsd-new-project', 'SKILL.md'); + const agentPrompt = path.join(tmpConfig, 'agents', 'subagents', 'gsd-executor.md'); + fs.appendFileSync(skillFile, '\nUSER LOCAL KIMI SKILL EDIT\n'); + fs.appendFileSync(agentPrompt, '\nUSER LOCAL KIMI AGENT EDIT\n'); + + const second = spawnSync(process.execPath, installArgs, { + cwd: tmpProject, + encoding: 'utf8', + env, + }); + assert.strictEqual( + second.status, + 0, + `second install failed\nstdout: ${second.stdout}\nstderr: ${second.stderr}`, + ); + const secondOutput = `${second.stdout}\n${second.stderr}`; + assert.match(secondOutput, /locally modified GSD file/i); + assert.match(secondOutput, /\/skill:gsd-update --reapply/); + assert.doesNotMatch(secondOutput, /Run\s+\/gsd-update --reapply/); + + const skillBackup = path.join(tmpConfig, 'gsd-local-patches', 'skills', 'gsd-new-project', 'SKILL.md'); + const agentBackup = path.join(tmpConfig, 'gsd-local-patches', 'agents', 'subagents', 'gsd-executor.md'); + assert.match(fs.readFileSync(skillBackup, 'utf8'), /USER LOCAL KIMI SKILL EDIT/); + assert.match(fs.readFileSync(agentBackup, 'utf8'), /USER LOCAL KIMI AGENT EDIT/); + + const meta = JSON.parse(fs.readFileSync(path.join(tmpConfig, 'gsd-local-patches', 'backup-meta.json'), 'utf8')); + assert.ok(meta.files.includes('skills/gsd-new-project/SKILL.md')); + assert.ok(meta.files.includes('agents/subagents/gsd-executor.md')); + } finally { + cleanup(tmpProject); + cleanup(tmpConfig); + cleanup(tmpHome); + } + }); + + test('--kimi --global --uninstall removes GSD artifacts and preserves non-GSD Kimi content', () => { + const tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-uninstall-project-')); + const tmpConfig = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-uninstall-config-')); + const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-uninstall-home-')); + const env = installerEnv({ HOME: tmpHome, USERPROFILE: tmpHome }); + + try { + const installArgs = [INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', tmpConfig, '--no-sdk']; + const installResult = spawnSync(process.execPath, installArgs, { + cwd: tmpProject, + encoding: 'utf8', + env, + }); + assert.strictEqual( + installResult.status, + 0, + `install failed\nstdout: ${installResult.stdout}\nstderr: ${installResult.stderr}`, + ); + + const foreignSkill = path.join(tmpConfig, 'skills', 'user-custom-skill', 'SKILL.md'); + const foreignRootAgent = path.join(tmpConfig, 'agents', 'user-agent.yaml'); + const foreignSubagent = path.join(tmpConfig, 'agents', 'subagents', 'user-agent.yaml'); + fs.mkdirSync(path.dirname(foreignSkill), { recursive: true }); + fs.mkdirSync(path.dirname(foreignSubagent), { recursive: true }); + fs.writeFileSync(foreignSkill, '# user skill\n', 'utf8'); + fs.writeFileSync(foreignRootAgent, 'version: 1\n', 'utf8'); + fs.writeFileSync(foreignSubagent, 'version: 1\n', 'utf8'); + + const uninstallResult = spawnSync( + process.execPath, + [INSTALL_SCRIPT, '--kimi', '--global', '--config-dir', tmpConfig, '--uninstall'], + { + cwd: tmpProject, + encoding: 'utf8', + env, + }, + ); + assert.strictEqual( + uninstallResult.status, + 0, + `uninstall failed\nstdout: ${uninstallResult.stdout}\nstderr: ${uninstallResult.stderr}`, + ); + assert.match(`${uninstallResult.stdout}\n${uninstallResult.stderr}`, /Kimi CLI/); + + assert.ok(!fs.existsSync(path.join(tmpConfig, 'skills', 'gsd-new-project')), 'must remove generated Kimi skills'); + assert.ok(!fs.existsSync(path.join(tmpConfig, 'agents', 'gsd.yaml')), 'must remove Kimi root agent YAML'); + assert.ok(!fs.existsSync(path.join(tmpConfig, 'agents', 'gsd.md')), 'must remove Kimi root agent prompt'); + assert.ok(!fs.existsSync(path.join(tmpConfig, 'agents', 'subagents', 'gsd-executor.yaml')), 'must remove generated Kimi subagent YAML'); + assert.ok(!fs.existsSync(path.join(tmpConfig, 'agents', 'subagents', 'gsd-executor.md')), 'must remove generated Kimi subagent prompt'); + assert.ok(!fs.existsSync(path.join(tmpConfig, 'gsd-core')), 'must remove installed workflow payload'); + assert.ok(!fs.existsSync(path.join(tmpConfig, 'gsd-file-manifest.json')), 'must remove the manifest'); + + assert.ok(fs.existsSync(foreignSkill), 'must preserve non-GSD Kimi skills'); + assert.ok(fs.existsSync(foreignRootAgent), 'must preserve non-GSD root agents'); + assert.ok(fs.existsSync(foreignSubagent), 'must preserve non-GSD subagents'); + } finally { + cleanup(tmpProject); + cleanup(tmpConfig); + cleanup(tmpHome); + } + }); +}); diff --git a/tests/helpers/install-shared.cjs b/tests/helpers/install-shared.cjs index 93f1dc977..eb8287027 100644 --- a/tests/helpers/install-shared.cjs +++ b/tests/helpers/install-shared.cjs @@ -49,6 +49,7 @@ const RUNTIME_META = { cursor: { localDir: '.cursor', globalSuffix: '.cursor' }, gemini: { localDir: '.gemini', globalSuffix: '.gemini' }, hermes: { localDir: '.hermes', globalSuffix: '.hermes' }, + kimi: { localDir: '.kimi-code', globalSuffix: path.join('.config', 'agents') }, kilo: { localDir: '.kilo', globalSuffix: path.join('.config', 'kilo') }, opencode: { localDir: '.opencode', globalSuffix: path.join('.config', 'opencode') }, qwen: { localDir: '.qwen', globalSuffix: '.qwen' }, diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index d3bee37bb..8752e3667 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -50,13 +50,13 @@ const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST }); const SKILLS_RUNTIMES_LAYOUT = [ 'claude', 'cursor', 'codex', 'copilot', 'antigravity', - 'windsurf', 'augment', 'trae', 'qwen', 'codebuddy', + 'windsurf', 'augment', 'trae', 'qwen', 'kimi', 'codebuddy', ]; const ALL_RUNTIMES_LAYOUT = [ 'claude', 'cursor', 'gemini', 'codex', 'copilot', 'antigravity', 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', - 'cline', 'opencode', 'kilo', + 'cline', 'kimi', 'opencode', 'kilo', ]; function countPrefixedEntries(destDir, prefix) { @@ -95,6 +95,40 @@ describe('installRuntimeArtifacts — skills runtimes write gsd-prefixed skill d `${runtime}: ${skillsKind.prefix}help/SKILL.md must exist` ); + if (runtime === 'kimi') { + const newProjectSkill = path.join(destDir, 'gsd-new-project', 'SKILL.md'); + assert.ok(fs.existsSync(newProjectSkill), 'kimi: gsd-new-project/SKILL.md must exist'); + const content = fs.readFileSync(newProjectSkill, 'utf8'); + assert.match(content, /^name: gsd-new-project$/m); + assert.match(content, /\/skill:gsd-new-project/); + assert.doesNotMatch(content, /kimi_cli\.tools|system_prompt_path|^version: 1$/m); + + const agentsDir = path.join(configDir, 'agents'); + const rootYaml = path.join(agentsDir, 'gsd.yaml'); + const rootPrompt = path.join(agentsDir, 'gsd.md'); + const executorYaml = path.join(agentsDir, 'subagents', 'gsd-executor.yaml'); + const executorPrompt = path.join(agentsDir, 'subagents', 'gsd-executor.md'); + assert.ok(fs.existsSync(rootYaml), 'kimi: agents/gsd.yaml must exist'); + assert.ok(fs.existsSync(rootPrompt), 'kimi: agents/gsd.md must exist'); + assert.ok(fs.existsSync(executorYaml), 'kimi: agents/subagents/gsd-executor.yaml must exist'); + assert.ok(fs.existsSync(executorPrompt), 'kimi: agents/subagents/gsd-executor.md must exist'); + + const rootYamlContent = fs.readFileSync(rootYaml, 'utf8'); + assert.match(rootYamlContent, /^version: 1$/m); + assert.match(rootYamlContent, /^agent:$/m); + assert.match(rootYamlContent, /extend: default/); + assert.match(rootYamlContent, /system_prompt_path: \.\/gsd\.md/); + assert.match(rootYamlContent, /tools:/); + assert.match(rootYamlContent, /subagents:/); + assert.match(rootYamlContent, /kimi_cli\.tools\./); + assert.doesNotMatch(rootYamlContent, /mcp__/); + + const executorYamlContent = fs.readFileSync(executorYaml, 'utf8'); + assert.match(executorYamlContent, /system_prompt_path: \.\/gsd-executor\.md/); + assert.match(executorYamlContent, /kimi_cli\.tools\./); + assert.doesNotMatch(executorYamlContent, /mcp__/); + } + if (RESOLVED_CORE.skills !== '*') { const prefixedCount = countPrefixedEntries(destDir, skillsKind.prefix || 'gsd-'); assert.strictEqual(prefixedCount, RESOLVED_CORE.skills.size, @@ -312,6 +346,14 @@ describe('uninstallRuntimeArtifacts — removes gsd-owned entries, preserves for const foreignDir = path.join(destDir, 'user-custom-skill'); fs.mkdirSync(foreignDir, { recursive: true }); fs.writeFileSync(path.join(foreignDir, 'SKILL.md'), '# user\n'); + } else if (kind.kind === 'kimi-agents') { + fs.mkdirSync(path.join(destDir, 'subagents'), { recursive: true }); + fs.writeFileSync(path.join(destDir, 'gsd.yaml'), 'version: 1\n'); + fs.writeFileSync(path.join(destDir, 'gsd.md'), '# gsd\n'); + fs.writeFileSync(path.join(destDir, 'subagents', 'gsd-executor.yaml'), 'version: 1\n'); + fs.writeFileSync(path.join(destDir, 'subagents', 'gsd-executor.md'), '# executor\n'); + fs.writeFileSync(path.join(destDir, 'user-agent.yaml'), 'version: 1\n'); + fs.writeFileSync(path.join(destDir, 'subagents', 'user-agent.yaml'), 'version: 1\n'); } else { writeCommandEntry(destDir, kind.prefix, 'help'); writeCommandEntry(destDir, kind.prefix, 'phase'); @@ -327,6 +369,13 @@ describe('uninstallRuntimeArtifacts — removes gsd-owned entries, preserves for assert.ok(!fs.existsSync(path.join(destDir, `${kind.prefix}help`))); assert.ok(!fs.existsSync(path.join(destDir, `${kind.prefix}phase`))); assert.ok(fs.existsSync(path.join(destDir, 'user-custom-skill', 'SKILL.md'))); + } else if (kind.kind === 'kimi-agents') { + assert.ok(!fs.existsSync(path.join(destDir, 'gsd.yaml'))); + assert.ok(!fs.existsSync(path.join(destDir, 'gsd.md'))); + assert.ok(!fs.existsSync(path.join(destDir, 'subagents', 'gsd-executor.yaml'))); + assert.ok(!fs.existsSync(path.join(destDir, 'subagents', 'gsd-executor.md'))); + assert.ok(fs.existsSync(path.join(destDir, 'user-agent.yaml'))); + assert.ok(fs.existsSync(path.join(destDir, 'subagents', 'user-agent.yaml'))); } else { assert.ok(!fs.existsSync(path.join(destDir, `${kind.prefix}help.md`))); assert.ok(!fs.existsSync(path.join(destDir, `${kind.prefix}phase.md`))); diff --git a/tests/install.test.cjs b/tests/install.test.cjs index b43e91000..733baa6c7 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -92,7 +92,7 @@ describe('getGlobalConfigDir — all runtimes default paths', () => { } }); - for (const runtime of allRuntimes) { + for (const runtime of allRuntimes.filter(runtime => runtime !== 'kimi')) { test(`getGlobalConfigDir('${runtime}') returns expected home-relative path`, () => { const expected = path.join(os.homedir(), RUNTIME_META[runtime].globalSuffix); assert.strictEqual(getGlobalConfigDir(runtime), expected); @@ -676,17 +676,17 @@ describe('Kilo source integration assertions', () => { path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'update-context.cjs'), 'utf8'); test('--kilo flag parsing exists', () => { - assert.ok(src.includes("args.includes('--kilo')")); + assert.ok(src.includes("runtimeArgs.includes('--kilo')")); }); - test('runtimeMap has Kilo as option 11', () => { - assert.strictEqual(runtimeMap['11'], 'kilo'); + test('runtimeMap has Kilo as option 12 after Kimi', () => { + assert.strictEqual(runtimeMap['12'], 'kilo'); }); test('prompt text shows Kilo above OpenCode without marketing copy', () => { const plain = stripAnsi(buildRuntimePromptText()); - assert.ok(/\b11\)\s*Kilo\b/.test(plain)); - assert.ok(plain.indexOf('11) Kilo') < plain.indexOf('OpenCode')); + assert.ok(/\b12\)\s*Kilo\b/.test(plain)); + assert.ok(plain.indexOf('12) Kilo') < plain.indexOf('OpenCode')); assert.ok(!plain.includes('the #1 AI coding platform on OpenRouter')); }); diff --git a/tests/installer-migration-install-integration.test.cjs b/tests/installer-migration-install-integration.test.cjs index f9802657a..d701beb25 100644 --- a/tests/installer-migration-install-integration.test.cjs +++ b/tests/installer-migration-install-integration.test.cjs @@ -34,6 +34,7 @@ const RUNTIME_INSTALL_CONTRACTS = { cursor: { surface: 'flat-skills', settings: false, packageJson: false }, gemini: { surface: 'commands-gsd', settings: true, packageJson: true }, hermes: { surface: 'hermes-skills', settings: true, packageJson: true }, + kimi: { surface: 'kimi-skills-agents', settings: false, packageJson: false }, kilo: { surface: 'flat-command', settings: false, packageJson: true }, opencode: { surface: 'flat-command', settings: true, packageJson: true }, qwen: { surface: 'flat-skills', settings: true, packageJson: true }, @@ -179,27 +180,35 @@ function assertFreshInstallContract(runtime, targetDir) { const contract = RUNTIME_INSTALL_CONTRACTS[runtime]; assert.ok(contract, `missing runtime install contract for ${runtime}`); - assert.equal( - fs.readFileSync(path.join(targetDir, 'gsd-core', 'VERSION'), 'utf8'), - pkg.version, - `${runtime} should install the package VERSION` - ); - assert.ok( - fs.existsSync(path.join(targetDir, 'gsd-core', 'bin', 'gsd-tools.cjs')), - `${runtime} should install the GSD tool payload` - ); - assert.ok( - fs.existsSync(path.join(targetDir, 'gsd-file-manifest.json')), - `${runtime} should write the install manifest` - ); + if (contract.workflowPayload !== false) { + assert.equal( + fs.readFileSync(path.join(targetDir, 'gsd-core', 'VERSION'), 'utf8'), + pkg.version, + `${runtime} should install the package VERSION` + ); + assert.ok( + fs.existsSync(path.join(targetDir, 'gsd-core', 'bin', 'gsd-tools.cjs')), + `${runtime} should install the GSD tool payload` + ); + assert.ok( + fs.existsSync(path.join(targetDir, 'gsd-file-manifest.json')), + `${runtime} should write the install manifest` + ); - const manifest = JSON.parse(fs.readFileSync(path.join(targetDir, 'gsd-file-manifest.json'), 'utf8')); - assert.equal(manifest.version, pkg.version, `${runtime} manifest should record the package version`); - assert.equal(manifest.mode, 'full', `${runtime} manifest should record a full install`); - assert.ok( - manifest.files['gsd-core/VERSION'], - `${runtime} manifest should track the installed VERSION file` - ); + const manifest = JSON.parse(fs.readFileSync(path.join(targetDir, 'gsd-file-manifest.json'), 'utf8')); + assert.equal(manifest.version, pkg.version, `${runtime} manifest should record the package version`); + assert.equal(manifest.mode, 'full', `${runtime} manifest should record a full install`); + assert.ok( + manifest.files['gsd-core/VERSION'], + `${runtime} manifest should track the installed VERSION file` + ); + } else { + assert.equal( + fs.existsSync(path.join(targetDir, 'gsd-core')), + false, + `${runtime} should not install the GSD workflow payload` + ); + } if (contract.surface === 'flat-skills') { // Pre-#3562: codex was special-cased to expect zero gsd-* skill dirs @@ -231,6 +240,20 @@ function assertFreshInstallContract(runtime, targetDir) { listDirNames(targetDir, path.join('commands', 'gsd')).length > 0, `${runtime} should install commands/gsd entries` ); + } else if (contract.surface === 'kimi-skills-agents') { + assertHasGsdDirectory(targetDir, 'skills'); + assert.ok( + fs.existsSync(path.join(targetDir, 'agents', 'gsd.yaml')), + 'Kimi should install the root agent YAML' + ); + assert.ok( + fs.existsSync(path.join(targetDir, 'agents', 'gsd.md')), + 'Kimi should install the root agent prompt' + ); + assert.ok( + fs.existsSync(path.join(targetDir, 'agents', 'subagents', 'gsd-executor.yaml')), + 'Kimi should install GSD subagent YAML' + ); } else if (contract.surface === 'clinerules') { // #787: Cline now uses the .clinerules/ directory form (rules at gsd.md). assert.match( @@ -240,10 +263,12 @@ function assertFreshInstallContract(runtime, targetDir) { ); } - assert.ok( - listDirNames(targetDir, 'agents').some((name) => name.startsWith('gsd-')), - `${runtime} full install should install agents` - ); + if (contract.surface !== 'kimi-skills-agents') { + assert.ok( + listDirNames(targetDir, 'agents').some((name) => name.startsWith('gsd-')), + `${runtime} full install should install agents` + ); + } assert.equal( fs.existsSync(path.join(targetDir, 'settings.json')), @@ -428,7 +453,11 @@ describe('installer migration install integration', { concurrency: false }, () = assert.match(output, /Installing for /); assert.match(output, /Installer migrations/); assert.match(output, /removed\s+hooks\/statusline\.js/); - assert.match(output, /Installed workflow assets/); + if (runtime === 'kimi') { + assert.match(output, /Generated Kimi root agent/); + } else { + assert.match(output, /Installed workflow assets/); + } assert.match(output, /Done!/); assert.equal(fs.existsSync(path.join(targetDir, 'hooks/statusline.js')), false); diff --git a/tests/kimi-agent-converter.test.cjs b/tests/kimi-agent-converter.test.cjs new file mode 100644 index 000000000..46bb4751c --- /dev/null +++ b/tests/kimi-agent-converter.test.cjs @@ -0,0 +1,159 @@ +/** + * Kimi CLI agent artifact contract tests. + * + * Kimi custom agents are explicit YAML files loaded with `kimi --agent-file`. + * This suite tests the in-memory artifact contract only; install/layout wiring + * belongs to the later Phase 3 slices. + */ + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + buildKimiAgentArtifacts, +} = require('../bin/install.js'); + +const ROOT_AGENT = `--- +name: gsd +description: Root GSD agent for Kimi CLI +tools: Agent, mcp__github__search, DefinitelyUnknownTool +color: blue +--- + +# GSD Root + +Coordinate GSD workflows through subagents. +Read ~/.claude/gsd-core when source-package context is needed.`; + +const EXECUTOR_AGENT = `--- +name: gsd-executor +description: Execute planned GSD task slices with atomic commits. +tools: Read, Write, Edit, Bash, Grep, Glob +color: yellow +--- + + +You are a GSD plan executor. +`; + +const INVALID_AGENT = `--- +name: not a valid kimi agent +description: Invalid name should be diagnosed and skipped. +tools: Agent +--- + +This should not become a Kimi custom subagent.`; + +describe('buildKimiAgentArtifacts', () => { + test('builds a root Kimi agent YAML contract with explicit subagent paths', () => { + const result = buildKimiAgentArtifacts({ + rootAgent: ROOT_AGENT, + subagents: [ + { path: 'agents/gsd-executor.md', content: EXECUTOR_AGENT }, + ], + requestedSubagents: ['gsd-executor', 'gsd-missing'], + }); + + assert.equal(result.root.yamlPath, 'agents/gsd.yaml'); + assert.equal(result.root.promptPath, 'agents/gsd.md'); + assert.ok(result.root.yaml.includes('version: 1'), 'root YAML includes Kimi version marker'); + assert.ok(result.root.yaml.includes('agent:'), 'root YAML includes agent object'); + assert.ok(result.root.yaml.includes('name: gsd'), 'root agent name is gsd'); + assert.ok(result.root.yaml.includes('extend: default'), 'root agent extends default Kimi behavior'); + assert.ok(result.root.yaml.includes('system_prompt_path: ./gsd.md'), 'root prompt path is relative'); + assert.ok(result.root.yaml.includes('tools:'), 'root YAML has a tools field'); + assert.ok(result.root.yaml.includes('kimi_cli.tools.agent:Agent'), 'root can call Kimi subagents'); + assert.ok( + result.root.yaml.includes('kimi_cli.tools.agent:Agent'), + 'root tools use Kimi module paths' + ); + assert.ok(!result.root.yaml.includes('- Agent'), 'root YAML does not emit raw Claude Agent tool'); + assert.ok(result.root.yaml.includes('subagents:'), 'root YAML declares custom subagents'); + assert.ok(result.root.yaml.includes('gsd-executor:'), 'known GSD subagent key is canonical'); + assert.ok( + result.root.yaml.includes('path: ./subagents/gsd-executor.yaml'), + 'known GSD subagent path is relative to root YAML' + ); + assert.ok(!result.root.yaml.includes('gsd-missing'), 'unknown requested subagent is excluded'); + }); + + test('emits separate frontmatter-free Markdown prompts for root and subagents', () => { + const result = buildKimiAgentArtifacts({ + rootAgent: ROOT_AGENT, + subagents: [ + { path: 'agents/gsd-executor.md', content: EXECUTOR_AGENT }, + ], + requestedSubagents: ['gsd-executor'], + }); + + const executor = result.subagents.find((artifact) => artifact.name === 'gsd-executor'); + assert.ok(executor, 'executor subagent artifact exists'); + + assert.equal(executor.yamlPath, 'agents/subagents/gsd-executor.yaml'); + assert.equal(executor.promptPath, 'agents/subagents/gsd-executor.md'); + assert.ok(executor.yaml.includes('system_prompt_path: ./gsd-executor.md')); + assert.ok(executor.yaml.includes('kimi_cli.tools.file:ReadFile'), 'Read maps to Kimi file tool'); + assert.ok(executor.yaml.includes('kimi_cli.tools.file:WriteFile'), 'Write maps to Kimi file tool'); + assert.ok(executor.yaml.includes('kimi_cli.tools.file:StrReplaceFile'), 'Edit maps to Kimi file tool'); + assert.ok(executor.yaml.includes('kimi_cli.tools.shell:Shell'), 'Bash maps to Kimi shell tool'); + assert.ok(executor.yaml.includes('kimi_cli.tools.file:Grep'), 'Grep maps to Kimi grep tool'); + assert.ok(executor.yaml.includes('kimi_cli.tools.file:Glob'), 'Glob maps to Kimi glob tool'); + assert.ok(!executor.yaml.includes('- Read'), 'subagent YAML does not emit raw Claude Read tool'); + assert.ok(!executor.yaml.includes('- Bash'), 'subagent YAML does not emit raw Claude Bash tool'); + assert.ok(!executor.yaml.includes('kimi_cli.tools.agent:Agent'), 'subagent does not inherit nested Agent tool'); + + for (const prompt of [result.root.prompt, executor.prompt]) { + assert.ok(!prompt.trimStart().startsWith('---'), 'source frontmatter is removed'); + assert.ok(!prompt.includes('tools:'), 'source frontmatter tools do not leak into prompt'); + assert.ok(!prompt.includes('color:'), 'source frontmatter color does not leak into prompt'); + } + assert.ok(result.root.prompt.includes('# GSD Root'), 'root body content is preserved'); + assert.ok(executor.prompt.includes('You are a GSD plan executor.'), 'subagent body content is preserved'); + assert.ok(!result.root.prompt.includes('~/.claude/gsd-core'), 'Claude-specific path is neutralized'); + }); + + test('diagnoses unknown subagents and unsupported inputs instead of emitting invalid names', () => { + const result = buildKimiAgentArtifacts({ + rootAgent: ROOT_AGENT, + subagents: [ + { path: 'agents/gsd-executor.md', content: EXECUTOR_AGENT }, + { path: 'agents/not-valid.md', content: INVALID_AGENT }, + ], + requestedSubagents: ['gsd-executor', 'gsd-missing'], + }); + + assert.deepEqual( + result.subagents.map((artifact) => artifact.name), + ['gsd-executor'], + 'invalid and unknown subagents are not emitted' + ); + assert.ok( + result.diagnostics.some((item) => item.code === 'kimi_unknown_subagent' && item.value === 'gsd-missing'), + 'unknown requested subagent is diagnosed' + ); + assert.ok( + result.diagnostics.some((item) => item.code === 'kimi_invalid_subagent_name'), + 'invalid source subagent name is diagnosed' + ); + assert.ok( + result.diagnostics.some((item) => item.code === 'kimi_mcp_tool_excluded'), + 'MCP-managed tools are diagnosed and excluded' + ); + assert.ok( + result.diagnostics.some((item) => item.reason === 'mcp_managed'), + 'MCP diagnostics expose mapper reason' + ); + assert.ok( + result.diagnostics.some((item) => item.code === 'kimi_unsupported_tool'), + 'unsupported tools are diagnosed and excluded' + ); + assert.ok( + result.diagnostics.some((item) => item.reason === 'unsupported_tool'), + 'unsupported diagnostics expose mapper reason' + ); + assert.ok(!result.root.yaml.includes('mcp__github__search'), 'MCP tool names are not emitted'); + assert.ok(!result.root.yaml.includes('DefinitelyUnknownTool'), 'unsupported tool names are not emitted'); + }); +}); diff --git a/tests/kimi-skill-converter.test.cjs b/tests/kimi-skill-converter.test.cjs new file mode 100644 index 000000000..dc5974321 --- /dev/null +++ b/tests/kimi-skill-converter.test.cjs @@ -0,0 +1,94 @@ +/** + * Kimi CLI skill converter tests. + * + * Kimi Agent Skills use gsd-/SKILL.md directories with lowercase + * hyphenated frontmatter names and /skill: invocation syntax. + */ + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + convertClaudeCommandToKimiSkill, +} = require('../bin/install.js'); + +function sampleCommand() { + return [ + '---', + 'name: gsd:new-project', + 'description: Create a new GSD project', + 'allowed-tools:', + ' - Read', + ' - Bash', + 'agent: gsd-planner', + 'tools: Read, Bash', + 'model: opus', + 'color: blue', + 'skills:', + ' - gsd-planner-workflow', + 'hooks:', + ' PostToolUse:', + ' - matcher: "Write|Edit"', + '---', + '', + 'Invoke /gsd:new-project from slash form.', + 'Invoke gsd:new-project from bare colon form.', + 'Invoke /gsd-new-project from hyphen slash form.', + 'Invoke $gsd-new-project from shell-style form.', + 'Do not rewrite /gsd-tools, gsd-new-projector, or $gsd-planets.', + ].join('\n'); +} + +describe('convertClaudeCommandToKimiSkill', () => { + test('emits Kimi SKILL.md frontmatter with name and description only', () => { + const result = convertClaudeCommandToKimiSkill(sampleCommand(), 'gsd-new-project'); + + assert.ok(result.startsWith('---\n'), 'frontmatter starts with ---'); + assert.ok(result.includes('\n---\n'), 'frontmatter closes with ---'); + assert.ok(result.includes('name: gsd-new-project'), 'frontmatter name uses Kimi-safe hyphen form'); + assert.ok( + result.includes('description: "Create a new GSD project"'), + 'description is preserved from source frontmatter' + ); + + for (const unsupported of [ + 'allowed-tools', + 'agent', + 'tools', + 'model', + 'color', + 'skills', + 'hooks', + ]) { + assert.ok(!result.includes(`${unsupported}:`), `${unsupported}: is stripped from Kimi frontmatter`); + } + }); + + test('rewrites GSD command references to Kimi skill invocation syntax', () => { + const result = convertClaudeCommandToKimiSkill(sampleCommand(), 'gsd-new-project'); + + assert.equal( + (result.match(/\/skill:gsd-new-project/g) || []).length, + 5, + 'self invocation hint and all supported source invocation forms are emitted' + ); + assert.ok(!result.includes('/gsd:new-project'), 'slash colon form is removed'); + assert.ok(!result.includes('gsd:new-project'), 'bare colon form is removed'); + assert.ok(!result.includes('/gsd-new-project'), 'slash hyphen form is removed'); + assert.ok(!result.includes('$gsd-new-project'), 'shell-style form is removed'); + }); + + test('does not rewrite unrelated words or introduce Phase 3 artifacts', () => { + const result = convertClaudeCommandToKimiSkill(sampleCommand(), 'gsd-new-project'); + + assert.ok(result.includes('/gsd-tools'), 'non-command slash token is preserved'); + assert.ok(result.includes('gsd-new-projector'), 'longer hyphenated word is preserved'); + assert.ok(result.includes('$gsd-planets'), 'unrelated shell-style token is preserved'); + assert.ok(!result.includes('type: flow'), 'Phase 2 does not create Kimi flow skills'); + assert.ok(!result.includes('kimi_cli.tools'), 'Phase 2 does not emit Kimi tool module paths'); + assert.ok(!result.includes('system_prompt_path'), 'Phase 2 does not emit custom agent YAML'); + assert.ok(!result.includes('version: 1'), 'Phase 2 does not emit Kimi agent YAML markers'); + }); +}); diff --git a/tests/kimi-tool-mapping.test.cjs b/tests/kimi-tool-mapping.test.cjs new file mode 100644 index 000000000..ef1692f0e --- /dev/null +++ b/tests/kimi-tool-mapping.test.cjs @@ -0,0 +1,107 @@ +/** + * Kimi CLI tool module mapper tests. + * + * Kimi custom agent YAML requires tool module paths such as + * `kimi_cli.tools.file:ReadFile`; raw Claude tool names and MCP-managed tools + * must not be emitted. + */ + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + convertKimiToolName, + mapClaudeToolsToKimiTools, +} = require('../bin/install.js'); + +describe('convertKimiToolName', () => { + test('maps Claude and GSD tool names to documented Kimi module paths', () => { + const expectedMappings = new Map([ + ['Read', 'kimi_cli.tools.file:ReadFile'], + ['Write', 'kimi_cli.tools.file:WriteFile'], + ['Edit', 'kimi_cli.tools.file:StrReplaceFile'], + ['MultiEdit', 'kimi_cli.tools.file:StrReplaceFile'], + ['Bash', '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'], + ['WebSearch', 'kimi_cli.tools.web:SearchWeb'], + ['WebFetch', 'kimi_cli.tools.web:FetchURL'], + ['TaskList', 'kimi_cli.tools.background:TaskList'], + ['TaskOutput', 'kimi_cli.tools.background:TaskOutput'], + ['TaskStop', 'kimi_cli.tools.background:TaskStop'], + ]); + + for (const [claudeTool, kimiTool] of expectedMappings) { + assert.equal(convertKimiToolName(claudeTool), kimiTool, `${claudeTool} maps to Kimi module path`); + } + }); + + test('returns null for MCP-managed and unsupported tools', () => { + assert.equal(convertKimiToolName('mcp__context7__resolve-library-id'), null); + assert.equal(convertKimiToolName('DefinitelyUnknownTool'), null); + }); +}); + +describe('mapClaudeToolsToKimiTools', () => { + test('deduplicates mapped tools while preserving first-seen order', () => { + const result = mapClaudeToolsToKimiTools([ + 'Read', + 'ReadFile', + 'Edit', + 'MultiEdit', + 'Bash', + 'Task', + 'Agent', + 'WebFetch', + ]); + + assert.deepEqual(result.tools, [ + 'kimi_cli.tools.file:ReadFile', + 'kimi_cli.tools.file:StrReplaceFile', + 'kimi_cli.tools.shell:Shell', + 'kimi_cli.tools.agent:Agent', + 'kimi_cli.tools.web:FetchURL', + ]); + assert.deepEqual(result.diagnostics, []); + }); + + test('excludes MCP-managed tools with diagnostics', () => { + const result = mapClaudeToolsToKimiTools([ + 'Read', + 'mcp__context7__resolve-library-id', + ]); + + assert.deepEqual(result.tools, ['kimi_cli.tools.file:ReadFile']); + assert.ok( + result.diagnostics.some((item) => + item.reason === 'mcp_managed' && + item.code === 'kimi_mcp_tool_excluded' && + item.value === 'mcp__context7__resolve-library-id' + ), + 'MCP-managed exclusion is diagnosed' + ); + }); + + test('excludes unsupported tools with diagnostics', () => { + const result = mapClaudeToolsToKimiTools([ + 'Read', + 'DefinitelyUnknownTool', + ]); + + assert.deepEqual(result.tools, ['kimi_cli.tools.file:ReadFile']); + assert.ok( + result.diagnostics.some((item) => + item.reason === 'unsupported_tool' && + item.code === 'kimi_unsupported_tool' && + item.value === 'DefinitelyUnknownTool' + ), + 'unsupported tool exclusion is diagnosed' + ); + }); +}); diff --git a/tests/model-catalog-runtime-defaults.test.cjs b/tests/model-catalog-runtime-defaults.test.cjs index f071ed6f8..a619bb98b 100644 --- a/tests/model-catalog-runtime-defaults.test.cjs +++ b/tests/model-catalog-runtime-defaults.test.cjs @@ -9,6 +9,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { catalog, KNOWN_RUNTIMES } = require('../gsd-core/bin/lib/model-catalog.cjs'); +const { allRuntimes } = require('../bin/install.js'); const ROOT = path.join(__dirname, '..'); const SETTINGS_ADVANCED = fs.readFileSync(path.join(ROOT, 'gsd-core', 'workflows', 'settings-advanced.md'), 'utf8'); @@ -17,9 +18,14 @@ const CONFIG_DOC = fs.readFileSync(path.join(ROOT, 'docs', 'CONFIGURATION.md'), describe('model catalog runtime defaults parity (#3229)', () => { test('known runtimes include hermes and match catalog keys', () => { assert.ok(KNOWN_RUNTIMES.has('hermes')); + assert.ok(KNOWN_RUNTIMES.has('kimi')); assert.deepStrictEqual([...KNOWN_RUNTIMES].sort(), Object.keys(catalog.runtimeTierDefaults).sort()); }); + test('installer-supported runtimes are all known to the model catalog', () => { + assert.deepStrictEqual([...allRuntimes].sort(), [...KNOWN_RUNTIMES].sort()); + }); + test('settings-advanced runtime defaults table matches catalog for concrete runtimes', () => { for (const [runtime, tiers] of Object.entries(catalog.runtimeTierDefaults)) { if (!tiers.opus) continue; // Group B runtimes intentionally have no built-ins diff --git a/tests/multi-runtime-select.test.cjs b/tests/multi-runtime-select.test.cjs index 1e15d046b..66eaa1050 100644 --- a/tests/multi-runtime-select.test.cjs +++ b/tests/multi-runtime-select.test.cjs @@ -18,6 +18,7 @@ const assert = require('node:assert/strict'); const { runtimeMap, allRuntimes, + selectRuntimesFromArgs, parseRuntimeInput, buildRuntimePromptText, } = require('../bin/install.js'); @@ -48,7 +49,7 @@ describe('multi-runtime selection parsing', () => { test('space-separated choices return multiple runtimes', () => { assert.deepStrictEqual(parseRuntimeInput('1 7 9'), ['claude', 'copilot', 'gemini']); - assert.deepStrictEqual(parseRuntimeInput('8 11'), ['cursor', 'kilo']); + assert.deepStrictEqual(parseRuntimeInput('8 12'), ['cursor', 'kilo']); }); test('mixed comma and space separators work', () => { @@ -61,38 +62,42 @@ describe('multi-runtime selection parsing', () => { }); test('single choice for kilo', () => { - assert.deepStrictEqual(parseRuntimeInput('11'), ['kilo']); + assert.deepStrictEqual(parseRuntimeInput('12'), ['kilo']); }); test('single choice for opencode', () => { - assert.deepStrictEqual(parseRuntimeInput('12'), ['opencode']); + assert.deepStrictEqual(parseRuntimeInput('13'), ['opencode']); }); test('single choice for qwen', () => { - assert.deepStrictEqual(parseRuntimeInput('13'), ['qwen']); + assert.deepStrictEqual(parseRuntimeInput('14'), ['qwen']); }); test('single choice for trae', () => { - assert.deepStrictEqual(parseRuntimeInput('14'), ['trae']); + assert.deepStrictEqual(parseRuntimeInput('15'), ['trae']); }); test('single choice for windsurf', () => { - assert.deepStrictEqual(parseRuntimeInput('15'), ['windsurf']); + assert.deepStrictEqual(parseRuntimeInput('16'), ['windsurf']); }); - test('choice 16 returns all runtimes', () => { - assert.deepStrictEqual(parseRuntimeInput('16'), allRuntimes); + test('single choice for kimi', () => { + assert.deepStrictEqual(parseRuntimeInput('11'), ['kimi']); }); - test('choice 16 returns all runtimes when mixed with separators or other tokens', () => { - // CR feedback: tokenized inputs that include 16 (e.g. trailing comma, or + test('choice 17 returns all runtimes', () => { + assert.deepStrictEqual(parseRuntimeInput('17'), allRuntimes); + }); + + test('choice 17 returns all runtimes when mixed with separators or other tokens', () => { + // CR feedback: tokenized inputs that include 17 (e.g. trailing comma, or // alongside other choices) must still expand to all-runtimes — previously - // only the bare "16" matched, so "16," or "16 1" silently installed a + // only the bare all-runtimes option matched, so "17," or "17 1" silently installed a // subset. - assert.deepStrictEqual(parseRuntimeInput('16,'), allRuntimes); - assert.deepStrictEqual(parseRuntimeInput('16 1'), allRuntimes); - assert.deepStrictEqual(parseRuntimeInput('1,16'), allRuntimes); - assert.deepStrictEqual(parseRuntimeInput(' 16 '), allRuntimes); + assert.deepStrictEqual(parseRuntimeInput('17,'), allRuntimes); + assert.deepStrictEqual(parseRuntimeInput('17 1'), allRuntimes); + assert.deepStrictEqual(parseRuntimeInput('1,17'), allRuntimes); + assert.deepStrictEqual(parseRuntimeInput(' 17 '), allRuntimes); }); test('empty input defaults to claude', () => { @@ -101,13 +106,13 @@ describe('multi-runtime selection parsing', () => { }); test('invalid choices are ignored, falls back to claude if all invalid', () => { - assert.deepStrictEqual(parseRuntimeInput('17'), ['claude']); + assert.deepStrictEqual(parseRuntimeInput('18'), ['claude']); assert.deepStrictEqual(parseRuntimeInput('0'), ['claude']); assert.deepStrictEqual(parseRuntimeInput('abc'), ['claude']); }); test('invalid choices mixed with valid are filtered out', () => { - assert.deepStrictEqual(parseRuntimeInput('1,17,7'), ['claude', 'copilot']); + assert.deepStrictEqual(parseRuntimeInput('1,18,7'), ['claude', 'copilot']); assert.deepStrictEqual(parseRuntimeInput('abc 3 xyz'), ['augment']); }); @@ -118,7 +123,7 @@ describe('multi-runtime selection parsing', () => { test('preserves selection order', () => { assert.deepStrictEqual(parseRuntimeInput('9,1,7'), ['gemini', 'claude', 'copilot']); - assert.deepStrictEqual(parseRuntimeInput('11,2,8'), ['kilo', 'antigravity', 'cursor']); + assert.deepStrictEqual(parseRuntimeInput('12,2,8'), ['kilo', 'antigravity', 'cursor']); }); }); @@ -134,16 +139,17 @@ describe('install.js exports multi-select runtime metadata', () => { '8': 'cursor', '9': 'gemini', '10': 'hermes', - '11': 'kilo', - '12': 'opencode', - '13': 'qwen', - '14': 'trae', - '15': 'windsurf', + '11': 'kimi', + '12': 'kilo', + '13': 'opencode', + '14': 'qwen', + '15': 'trae', + '16': 'windsurf', }; const expectedRuntimes = [ 'claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', - 'copilot', 'cursor', 'gemini', 'hermes', 'kilo', 'opencode', 'qwen', - 'trae', 'windsurf', + 'copilot', 'cursor', 'gemini', 'hermes', 'kimi', 'kilo', 'opencode', + 'qwen', 'trae', 'windsurf', ]; test('runtimeMap exports every option key bound to the right runtime', () => { @@ -160,20 +166,35 @@ describe('install.js exports multi-select runtime metadata', () => { 'allRuntimes has no duplicates'); }); - test('"All" shortcut (option 16) selects every runtime', () => { - assert.deepStrictEqual(parseRuntimeInput('16'), allRuntimes); + test('"All" shortcut (option 17) selects every runtime', () => { + assert.deepStrictEqual(parseRuntimeInput('17'), allRuntimes); }); - test('prompt lists Hermes Agent (10), Qwen Code (13), Trae (14), and All (16)', () => { + test('--kimi flag selects Kimi without interactive prompt', () => { + assert.deepStrictEqual(selectRuntimesFromArgs(['--kimi']), ['kimi']); + }); + + test('--all flag includes Kimi exactly once', () => { + const selected = selectRuntimesFromArgs(['--all']); + assert.ok(selected.includes('kimi'), '--all includes kimi'); + assert.strictEqual(selected.filter((runtime) => runtime === 'kimi').length, 1, + '--all includes kimi exactly once'); + }); + + test('prompt lists Hermes Agent (10), Kimi (11), Qwen Code (14), Trae (15), and All (17)', () => { const prompt = stripAnsi(buildRuntimePromptText()); assert.ok(/\b10\)\s*Hermes Agent\b/.test(prompt), 'prompt lists Hermes Agent as option 10'); - assert.ok(/\b13\)\s*Qwen Code\b/.test(prompt), - 'prompt lists Qwen Code as option 13'); - assert.ok(/\b14\)\s*Trae\b/.test(prompt), - 'prompt lists Trae as option 14'); - assert.ok(/\b16\)\s*All\b/.test(prompt), - 'prompt lists All as option 16'); + assert.ok(/\b11\)\s*Kimi\b/.test(prompt), + 'prompt lists Kimi as option 11'); + assert.ok(/Kimi\s+\(~\/\.config\/agents, then ~\/\.agents if existing\)/.test(prompt), + 'prompt shows the Kimi first-existing generic root policy'); + assert.ok(/\b14\)\s*Qwen Code\b/.test(prompt), + 'prompt lists Qwen Code as option 14'); + assert.ok(/\b15\)\s*Trae\b/.test(prompt), + 'prompt lists Trae as option 15'); + assert.ok(/\b17\)\s*All\b/.test(prompt), + 'prompt lists All as option 17'); }); test('prompt text shows multi-select hint', () => { diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index e02f02c37..782c4e31d 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -23,6 +23,8 @@ const fs = require('fs'); const path = require('path'); const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); +const installProfiles = require('../gsd-core/bin/lib/install-profiles.cjs'); +const { cleanup } = require('./helpers.cjs'); const FAKE_DIR = '/tmp/fake-config-dir'; @@ -189,6 +191,28 @@ describe('resolveRuntimeArtifactLayout — qwen', () => { }); }); +describe('resolveRuntimeArtifactLayout — kimi', () => { + test('returns global skills layout and guarded empty local layout for kimi', () => { + const globalLayout = resolveRuntimeArtifactLayout('kimi', FAKE_DIR, 'global'); + assert.strictEqual(globalLayout.runtime, 'kimi'); + assert.strictEqual(globalLayout.configDir, FAKE_DIR); + assert.strictEqual(globalLayout.kinds.length, 2); + assert.strictEqual(globalLayout.kinds[0].kind, 'skills'); + assert.strictEqual(globalLayout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(globalLayout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof globalLayout.kinds[0].stage, 'function'); + assert.strictEqual(globalLayout.kinds[1].kind, 'kimi-agents'); + assert.strictEqual(globalLayout.kinds[1].destSubpath, 'agents'); + assert.strictEqual(globalLayout.kinds[1].prefix, 'gsd'); + assert.strictEqual(typeof globalLayout.kinds[1].stage, 'function'); + + const localLayout = resolveRuntimeArtifactLayout('kimi', FAKE_DIR, 'local'); + assert.strictEqual(localLayout.runtime, 'kimi'); + assert.strictEqual(localLayout.configDir, FAKE_DIR); + assert.deepStrictEqual(localLayout.kinds, []); + }); +}); + describe('resolveRuntimeArtifactLayout — hermes', () => { test('returns correct layout for hermes', () => { const layout = resolveRuntimeArtifactLayout('hermes', FAKE_DIR); @@ -444,6 +468,87 @@ describe('stage — skills kind (claude global)', () => { }); }); +describe('stage — skills kind (kimi global)', () => { + test('stage returns Kimi SKILL.md dirs and agent YAML/prompt artifacts', () => { + const layout = resolveRuntimeArtifactLayout('kimi', FAKE_STAGE_DIR, 'global'); + const skillsKind = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skillsKind, 'should have a skills kind'); + + const stagedDir = skillsKind.stage(PROFILE_CORE); + assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist'); + const skillMd = path.join(stagedDir, 'gsd-new-project', 'SKILL.md'); + assert.ok(fs.existsSync(skillMd), 'gsd-new-project/SKILL.md must exist'); + const content = fs.readFileSync(skillMd, 'utf8'); + assert.match(content, /^name: gsd-new-project$/m); + assert.match(content, /\/skill:gsd-new-project/); + assert.doesNotMatch(content, /kimi_cli\.tools|system_prompt_path|^version: 1$/m); + + const agentsKind = layout.kinds.find(k => k.kind === 'kimi-agents'); + assert.ok(agentsKind, 'should have a kimi-agents kind'); + + const stagedAgentsDir = agentsKind.stage(PROFILE_FULL); + const rootYamlPath = path.join(stagedAgentsDir, 'gsd.yaml'); + const rootPromptPath = path.join(stagedAgentsDir, 'gsd.md'); + const executorYamlPath = path.join(stagedAgentsDir, 'subagents', 'gsd-executor.yaml'); + const executorPromptPath = path.join(stagedAgentsDir, 'subagents', 'gsd-executor.md'); + assert.ok(fs.existsSync(rootYamlPath), 'agents/gsd.yaml must be staged'); + assert.ok(fs.existsSync(rootPromptPath), 'agents/gsd.md must be staged'); + assert.ok(fs.existsSync(executorYamlPath), 'agents/subagents/gsd-executor.yaml must be staged'); + assert.ok(fs.existsSync(executorPromptPath), 'agents/subagents/gsd-executor.md must be staged'); + + const rootYaml = fs.readFileSync(rootYamlPath, 'utf8'); + assert.match(rootYaml, /^version: 1$/m); + assert.match(rootYaml, /^agent:$/m); + assert.match(rootYaml, /extend: default/); + assert.match(rootYaml, /system_prompt_path: \.\/gsd\.md/); + assert.match(rootYaml, /tools:/); + assert.match(rootYaml, /subagents:/); + assert.match(rootYaml, /kimi_cli\.tools\./); + assert.doesNotMatch(rootYaml, /mcp__/); + + const executorYaml = fs.readFileSync(executorYamlPath, 'utf8'); + assert.match(executorYaml, /system_prompt_path: \.\/gsd-executor\.md/); + assert.match(executorYaml, /kimi_cli\.tools\./); + assert.doesNotMatch(executorYaml, /mcp__/); + }); + + test('tracks Kimi agent staging dir before writing artifacts', () => { + const layout = resolveRuntimeArtifactLayout('kimi', FAKE_STAGE_DIR, 'global'); + const agentsKind = layout.kinds.find(k => k.kind === 'kimi-agents'); + assert.ok(agentsKind, 'should have a kimi-agents kind'); + + const originalWriteFileSync = fs.writeFileSync; + const before = new Set(installProfiles.STAGED_DIRS); + let added = []; + + try { + fs.writeFileSync = function writeFileSyncWithInjectedFailure(file, ...args) { + const filePath = String(file); + if (filePath.includes('gsd-kimi-agents-') && path.basename(filePath) === 'gsd.yaml') { + throw new Error('forced Kimi stage write failure'); + } + return originalWriteFileSync.call(this, file, ...args); + }; + + assert.throws( + () => agentsKind.stage(PROFILE_FULL), + /forced Kimi stage write failure/ + ); + + added = [...installProfiles.STAGED_DIRS] + .filter(dir => !before.has(dir) && path.basename(dir).startsWith('gsd-kimi-agents-')); + assert.strictEqual(added.length, 1, 'partially written Kimi stage dir must be tracked for cleanup'); + assert.ok(fs.existsSync(added[0]), 'tracked partial Kimi stage dir should exist'); + } finally { + fs.writeFileSync = originalWriteFileSync; + for (const dir of added) { + cleanup(dir); + installProfiles.STAGED_DIRS.delete(dir); + } + } + }); +}); + describe('stage — opencode commands kind', () => { test('opencode stage returns directory with .md files for selected skills', () => { const layout = resolveRuntimeArtifactLayout('opencode', FAKE_STAGE_DIR); diff --git a/tests/runtime-config-adapter-registry.test.cjs b/tests/runtime-config-adapter-registry.test.cjs index 10ed2dc68..3fb29f3cc 100644 --- a/tests/runtime-config-adapter-registry.test.cjs +++ b/tests/runtime-config-adapter-registry.test.cjs @@ -34,6 +34,7 @@ const EXPECTED_TABLE = [ { runtime: 'cursor', installSurface: 'cursor-hooks-json', writesSharedSettings: false, finishPermissionWriter: null }, { runtime: 'windsurf', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null }, { runtime: 'trae', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null }, + { runtime: 'kimi', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null }, ]; // --------------------------------------------------------------------------- @@ -97,7 +98,7 @@ describe('resolveRuntimeConfigIntent — unknown runtime throws TypeError', () = // --------------------------------------------------------------------------- describe('writesSharedSettings exclusion equivalence', () => { - const EXPECTED_FALSE_SET = new Set(['codex', 'copilot', 'kilo', 'cursor', 'windsurf', 'trae', 'cline']); + const EXPECTED_FALSE_SET = new Set(['codex', 'copilot', 'kilo', 'cursor', 'windsurf', 'trae', 'cline', 'kimi']); test('runtimes with writesSharedSettings===false are exactly the exclusion set', () => { const falseRuntimes = EXPECTED_TABLE @@ -172,6 +173,10 @@ describe('installSurface correctness', () => { assert.strictEqual(resolveRuntimeConfigIntent('trae').installSurface, 'profile-marker-only'); }); + test('kimi -> "profile-marker-only"', () => { + assert.strictEqual(resolveRuntimeConfigIntent('kimi').installSurface, 'profile-marker-only'); + }); + test('the 7 passthroughs + opencode + kilo -> "settings-json"', () => { const settingsJsonRuntimes = ['claude', 'gemini', 'antigravity', 'augment', 'qwen', 'hermes', 'codebuddy', 'opencode', 'kilo']; for (const runtime of settingsJsonRuntimes) { @@ -205,14 +210,15 @@ describe('resolveRuntimeConfigIntent — fresh object each call', () => { // --------------------------------------------------------------------------- describe('ALLOWED_CONFIG_RUNTIMES completeness', () => { - const EXPECTED_15 = new Set([ + const EXPECTED_16 = new Set([ 'claude', 'gemini', 'antigravity', 'augment', 'qwen', 'hermes', 'codebuddy', 'opencode', 'kilo', 'codex', 'copilot', 'cline', 'cursor', 'windsurf', 'trae', + 'kimi', ]); - test('ALLOWED_CONFIG_RUNTIMES contains exactly the 15 expected runtimes', () => { + test('ALLOWED_CONFIG_RUNTIMES contains exactly the 16 expected runtimes', () => { const runtimeSet = new Set(ALLOWED_CONFIG_RUNTIMES); - assert.deepStrictEqual(runtimeSet, EXPECTED_15); + assert.deepStrictEqual(runtimeSet, EXPECTED_16); }); test('every member of ALLOWED_CONFIG_RUNTIMES resolves without throwing', () => { @@ -221,8 +227,8 @@ describe('ALLOWED_CONFIG_RUNTIMES completeness', () => { } }); - test('ALLOWED_CONFIG_RUNTIMES has exactly 15 entries', () => { - assert.strictEqual([...ALLOWED_CONFIG_RUNTIMES].length, 15); + test('ALLOWED_CONFIG_RUNTIMES has exactly 16 entries', () => { + assert.strictEqual([...ALLOWED_CONFIG_RUNTIMES].length, 16); }); }); diff --git a/tests/runtime-name-policy.test.cjs b/tests/runtime-name-policy.test.cjs new file mode 100644 index 000000000..abc6ee11d --- /dev/null +++ b/tests/runtime-name-policy.test.cjs @@ -0,0 +1,20 @@ +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const { + canonicalizeRuntimeName, + resolveRuntimeNameFromCandidates, +} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-name-policy.cjs')); + +describe('runtime-name-policy canonical runtime ids', () => { + test('canonicalizes Kimi without adding extra aliases', () => { + assert.strictEqual(canonicalizeRuntimeName('kimi'), 'kimi'); + assert.strictEqual(canonicalizeRuntimeName(' KIMI '), 'kimi'); + assert.strictEqual(resolveRuntimeNameFromCandidates('', null, 'kimi'), 'kimi'); + assert.strictEqual(canonicalizeRuntimeName('kimi-cli'), null); + }); +});