diff --git a/bin/install.js b/bin/install.js index c143d8533..6664bab22 100755 --- a/bin/install.js +++ b/bin/install.js @@ -30,6 +30,8 @@ const { } = require(path.join(__dirname, '..', 'scripts', 'fix-slash-commands.cjs')); const { resolveAntigravityGlobalDir, + getGlobalConfigDir, + getGlobalSkillsBase, } = require('../gsd-core/bin/lib/runtime-homes.cjs'); /** @@ -239,23 +241,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'; @@ -282,30 +267,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. @@ -354,6 +346,7 @@ function getDirName(runtime) { if (runtime === 'trae') return '.trae'; if (runtime === 'qwen') return '.qwen'; if (runtime === 'hermes') return '.hermes'; + if (runtime === 'kimi') return '.kimi'; if (runtime === 'codebuddy') return '.codebuddy'; if (runtime === 'cline') return '.cline'; return '.claude'; @@ -400,6 +393,7 @@ 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'"; } @@ -605,6 +599,13 @@ function getGlobalDir(runtime, explicitDir = null) { return path.join(os.homedir(), '.cline'); } + if (runtime === 'kimi') { + if (explicitDir) { + return expandTilde(explicitDir); + } + return getGlobalConfigDir('kimi'); + } + // Claude Code: --config-dir > CLAUDE_CONFIG_DIR > ~/.claude if (explicitDir) { return expandTilde(explicitDir); @@ -8234,6 +8235,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'; @@ -8248,6 +8250,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 1.`); + console.log(` No .kimi/skills or .agents/skills project artifacts were written.`); + console.log(` Use ${cyan}--kimi --global${reset} for the Phase 1 runtime skeleton.`); + 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, @@ -8374,6 +8393,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'; @@ -8657,7 +8677,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { // handles per-runtime path + branding rewrites, including Qwen/Hermes. const _isSkillsRuntime = isCodex || isCopilot || isAntigravity || isCursor || isWindsurf || isAugment || isTrae || isCodebuddy || isQwen || isHermes || - (runtime === 'claude' && isGlobal); + isKimi || (runtime === 'claude' && isGlobal); if (_isSkillsRuntime) { // Layout-driven install for skills-based runtimes (full and minimal modes) @@ -8684,6 +8704,8 @@ function install(isGlobal, runtime = 'claude', options = {}) { } else { failures.push('skills/gsd/*'); } + } else if (isKimi) { + console.log(` ${yellow}⚠${reset} Kimi SKILL.md conversion is deferred for Phase 2; no skills were written.`); } else { const skillsDir = path.join(targetDir, 'skills'); if (fs.existsSync(skillsDir)) { @@ -10348,14 +10370,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. @@ -10373,12 +10396,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)${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} `; @@ -10389,7 +10413,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) { @@ -10841,6 +10865,7 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) { try { const printSummaries = () => { for (const result of results) { + if (result && result.skipped) continue; const useStatusline = statuslineRuntimes.includes(result.runtime) && shouldInstallStatusline; finishInstall( result.settingsPath, @@ -11002,6 +11027,7 @@ module.exports = { maybeSuggestPathExport, runtimeMap, allRuntimes, + selectRuntimesFromArgs, GSD_UNINSTALL_HOOKS, parseRuntimeInput, buildRuntimePromptText, @@ -11047,12 +11073,11 @@ if (require.main === module && !process.env.GSD_TEST_MODE) { console.error('Usage: node install.js --skills-root '); process.exit(1); } - const globalDir = getGlobalDir(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/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index 0a0d836ac..b85544ea2 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -167,7 +167,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', ]); // --------------------------------------------------------------------------- @@ -304,6 +304,12 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope: kinds = []; break; + case 'kimi': + // Phase 1 skeleton only: Kimi is recognized by the layout seam, but + // SKILL.md conversion and local project semantics are deferred. + kinds = []; + break; + case 'opencode': kinds = [commandsKind('command', 'gsd-', configDir)]; break; diff --git a/src/runtime-homes.cts b/src/runtime-homes.cts index be373e44f..a775726cf 100644 --- a/src/runtime-homes.cts +++ b/src/runtime-homes.cts @@ -14,6 +14,9 @@ * cline — Rules-based; commands are embedded in .clinerules. Cline does * not use a skills/ directory. getGlobalSkillDir() returns null * for cline so the caller can emit an appropriate warning. + * kimi — Agent Skills are discovered from the generic agents root: + * ~/.config/agents/skills. ~/.kimi/skills is compatible with + * Kimi CLI, but is not GSD's canonical Phase 1 install target. */ import os from 'node:os'; @@ -125,6 +128,13 @@ export function getGlobalConfigDir(runtime: string): string { case 'cline': return env['CLINE_CONFIG_DIR'] ? expandTilde(env['CLINE_CONFIG_DIR']) : path.join(home, '.cline'); + // ── Kimi CLI (generic agents XDG root) ───────────────────────────────── + case 'kimi': { + if (env['KIMI_CONFIG_DIR']) return expandTilde(env['KIMI_CONFIG_DIR']); + if (env['XDG_CONFIG_HOME']) return path.join(expandTilde(env['XDG_CONFIG_HOME']), 'agents'); + return path.join(home, '.config', 'agents'); + } + // ── OpenCode (XDG) ─────────────────────────────────────────────────────── case 'opencode': { if (env['OPENCODE_CONFIG_DIR']) return expandTilde(env['OPENCODE_CONFIG_DIR']); diff --git a/tests/helpers/install-shared.cjs b/tests/helpers/install-shared.cjs index 14f131fbd..12c580d91 100644 --- a/tests/helpers/install-shared.cjs +++ b/tests/helpers/install-shared.cjs @@ -48,6 +48,7 @@ const RUNTIME_META = { cursor: { localDir: '.cursor', globalSuffix: '.cursor' }, gemini: { localDir: '.gemini', globalSuffix: '.gemini' }, hermes: { localDir: '.hermes', globalSuffix: '.hermes' }, + kimi: { localDir: '.kimi', 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.test.cjs b/tests/install.test.cjs index 3ac6e72c8..4b4fb2d39 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -649,17 +649,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/multi-runtime-select.test.cjs b/tests/multi-runtime-select.test.cjs index 6ac4a58ed..8b2efa530 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', () => { @@ -122,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']); }); }); @@ -169,6 +170,17 @@ describe('install.js exports multi-select runtime metadata', () => { assert.deepStrictEqual(parseRuntimeInput('17'), allRuntimes); }); + 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),