feat(01-03): implement Kimi paths layout and local guard

- Resolve Kimi global skills under the generic agents path
- Add empty Kimi layout placeholder and local install no-op guard
- Keep selection/path tests aligned without Kimi skill conversion
This commit is contained in:
Viktorplus
2026-06-06 13:17:56 +02:00
parent 0d456d4bb0
commit 58626283b2
6 changed files with 122 additions and 68 deletions

View File

@@ -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 <runtime>');
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}`);

View File

@@ -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;

View File

@@ -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']);

View File

@@ -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' },

View File

@@ -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'));
});

View File

@@ -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),