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:
145
bin/install.js
145
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 <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}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user