Merge remote-tracking branch 'upstream/next' into kimi-runtime-support

# Conflicts:
#	bin/install.js
This commit is contained in:
Viktorplus
2026-06-07 19:21:53 +02:00
32 changed files with 1741 additions and 380 deletions

View File

@@ -37,6 +37,7 @@ const {
applyWorktreeBaseRef,
readBaseRefFromSettings,
} = require('../gsd-core/bin/lib/worktree-base-ref.cjs');
const { resolveRuntimeConfigIntent } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs');
/**
* Runtimes that register hyphen-form `name:` per #2808 AND copy agent bodies
@@ -402,224 +403,12 @@ function getConfigDirFromHome(runtime, isGlobal) {
}
/**
* Get the global config directory for OpenCode
* OpenCode follows XDG Base Directory spec and uses ~/.config/opencode/
* Priority: OPENCODE_CONFIG_DIR > dirname(OPENCODE_CONFIG) > XDG_CONFIG_HOME/opencode > ~/.config/opencode
*/
function getOpencodeGlobalDir() {
// 1. Explicit OPENCODE_CONFIG_DIR env var
if (process.env.OPENCODE_CONFIG_DIR) {
return expandTilde(process.env.OPENCODE_CONFIG_DIR);
}
// 2. OPENCODE_CONFIG env var (use its directory)
if (process.env.OPENCODE_CONFIG) {
return path.dirname(expandTilde(process.env.OPENCODE_CONFIG));
}
// 3. XDG_CONFIG_HOME/opencode
if (process.env.XDG_CONFIG_HOME) {
return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'opencode');
}
// 4. Default: ~/.config/opencode (XDG default)
return path.join(os.homedir(), '.config', 'opencode');
}
/**
* Get the global config directory for Kilo
* Kilo follows XDG Base Directory spec and uses ~/.config/kilo/
* Priority: KILO_CONFIG_DIR > dirname(KILO_CONFIG) > XDG_CONFIG_HOME/kilo > ~/.config/kilo
*/
function getKiloGlobalDir() {
// 1. Explicit KILO_CONFIG_DIR env var
if (process.env.KILO_CONFIG_DIR) {
return expandTilde(process.env.KILO_CONFIG_DIR);
}
// 2. KILO_CONFIG env var (use its directory)
if (process.env.KILO_CONFIG) {
return path.dirname(expandTilde(process.env.KILO_CONFIG));
}
// 3. XDG_CONFIG_HOME/kilo
if (process.env.XDG_CONFIG_HOME) {
return path.join(expandTilde(process.env.XDG_CONFIG_HOME), 'kilo');
}
// 4. Default: ~/.config/kilo (XDG default)
return path.join(os.homedir(), '.config', 'kilo');
}
/**
* Get the global config directory for a runtime
* @param {string} runtime - 'claude', 'opencode', 'gemini', 'codex', or 'copilot'
* @param {string|null} explicitDir - Explicit directory from --config-dir flag
* Compatibility seam for tests and older installer consumers.
* Runtime home resolution now lives in runtime-homes.cjs.
*/
function getGlobalDir(runtime, explicitDir = null) {
if (runtime === 'opencode') {
// For OpenCode, --config-dir overrides env vars
if (explicitDir) {
return expandTilde(explicitDir);
}
return getOpencodeGlobalDir();
}
if (runtime === 'kilo') {
// For Kilo, --config-dir overrides env vars
if (explicitDir) {
return expandTilde(explicitDir);
}
return getKiloGlobalDir();
}
if (runtime === 'gemini') {
// Gemini: --config-dir > GEMINI_CONFIG_DIR > ~/.gemini
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.GEMINI_CONFIG_DIR) {
return expandTilde(process.env.GEMINI_CONFIG_DIR);
}
return path.join(os.homedir(), '.gemini');
}
if (runtime === 'codex') {
// Codex: --config-dir > CODEX_HOME > ~/.codex
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.CODEX_HOME) {
return expandTilde(process.env.CODEX_HOME);
}
return path.join(os.homedir(), '.codex');
}
if (runtime === 'copilot') {
// Copilot: --config-dir > COPILOT_CONFIG_DIR > ~/.copilot
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.COPILOT_CONFIG_DIR) {
return expandTilde(process.env.COPILOT_CONFIG_DIR);
}
return path.join(os.homedir(), '.copilot');
}
if (runtime === 'antigravity') {
// Antigravity: --config-dir > ANTIGRAVITY_CONFIG_DIR > auto-detected
// ~/.gemini/{antigravity,antigravity-ide,antigravity-cli}
if (explicitDir) {
return expandTilde(explicitDir);
}
return resolveAntigravityGlobalDir();
}
if (runtime === 'cursor') {
// Cursor: --config-dir > CURSOR_CONFIG_DIR > ~/.cursor
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.CURSOR_CONFIG_DIR) {
return expandTilde(process.env.CURSOR_CONFIG_DIR);
}
return path.join(os.homedir(), '.cursor');
}
if (runtime === 'windsurf') {
// Windsurf: --config-dir > WINDSURF_CONFIG_DIR > ~/.codeium/windsurf
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.WINDSURF_CONFIG_DIR) {
return expandTilde(process.env.WINDSURF_CONFIG_DIR);
}
return path.join(os.homedir(), '.codeium', 'windsurf');
}
if (runtime === 'augment') {
// Augment: --config-dir > AUGMENT_CONFIG_DIR > ~/.augment
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.AUGMENT_CONFIG_DIR) {
return expandTilde(process.env.AUGMENT_CONFIG_DIR);
}
return path.join(os.homedir(), '.augment');
}
if (runtime === 'trae') {
// Trae: --config-dir > TRAE_CONFIG_DIR > ~/.trae
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.TRAE_CONFIG_DIR) {
return expandTilde(process.env.TRAE_CONFIG_DIR);
}
return path.join(os.homedir(), '.trae');
}
if (runtime === 'qwen') {
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.QWEN_CONFIG_DIR) {
return expandTilde(process.env.QWEN_CONFIG_DIR);
}
return path.join(os.homedir(), '.qwen');
}
if (runtime === 'hermes') {
// Hermes Agent: --config-dir > HERMES_HOME > ~/.hermes
// Honors HERMES_HOME which Hermes users set for profile mode / Docker
// deploys (docs: https://hermes-agent.nousresearch.com/docs).
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.HERMES_HOME) {
return expandTilde(process.env.HERMES_HOME);
}
return path.join(os.homedir(), '.hermes');
}
if (runtime === 'codebuddy') {
// CodeBuddy: --config-dir > CODEBUDDY_CONFIG_DIR > ~/.codebuddy
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.CODEBUDDY_CONFIG_DIR) {
return expandTilde(process.env.CODEBUDDY_CONFIG_DIR);
}
return path.join(os.homedir(), '.codebuddy');
}
if (runtime === 'cline') {
// Cline: --config-dir > CLINE_CONFIG_DIR > ~/.cline
if (explicitDir) {
return expandTilde(explicitDir);
}
if (process.env.CLINE_CONFIG_DIR) {
return expandTilde(process.env.CLINE_CONFIG_DIR);
}
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);
}
if (process.env.CLAUDE_CONFIG_DIR) {
return expandTilde(process.env.CLAUDE_CONFIG_DIR);
}
return path.join(os.homedir(), '.claude');
return getGlobalConfigDir(runtime, explicitDir);
}
const banner = '\n' +
cyan + ' ██████╗ ███████╗██████╗\n' +
' ██╔════╝ ██╔════╝██╔══██╗\n' +
@@ -692,16 +481,6 @@ if (hasHelp) {
process.exit(0);
}
/**
* Expand ~ to home directory (shell doesn't expand in env vars passed to node)
*/
function expandTilde(filePath) {
if (filePath && filePath.startsWith('~/')) {
return path.join(os.homedir(), filePath.slice(2));
}
return filePath;
}
/**
* Compute the path prefix used for `@file` references in installed command/skill
* markdown. For global installs into a runtime config dir under $HOME, we
@@ -1774,11 +1553,11 @@ function getCommitAttribution(runtime) {
const resolveConfigPath = runtime === 'opencode'
? resolveOpencodeConfigPath
: resolveKiloConfigPath;
const config = readSettings(resolveConfigPath(getGlobalDir(runtime, null)));
const config = readSettings(resolveConfigPath(getGlobalConfigDir(runtime, null)));
result = (config && config.disable_ai_attribution === true) ? null : undefined;
} else if (runtime === 'gemini') {
// Gemini: check gemini settings.json for attribution config
const settings = readSettings(path.join(getGlobalDir('gemini', explicitConfigDir), 'settings.json'));
const settings = readSettings(path.join(getGlobalConfigDir('gemini', explicitConfigDir), 'settings.json'));
if (!settings || !settings.attribution || settings.attribution.commit === undefined) {
result = undefined;
} else if (settings.attribution.commit === '') {
@@ -1788,7 +1567,7 @@ function getCommitAttribution(runtime) {
}
} else if (runtime === 'claude') {
// Claude Code
const settings = readSettings(path.join(getGlobalDir('claude', explicitConfigDir), 'settings.json'));
const settings = readSettings(path.join(getGlobalConfigDir('claude', explicitConfigDir), 'settings.json'));
if (!settings || !settings.attribution || settings.attribution.commit === undefined) {
result = undefined;
} else if (settings.attribution.commit === '') {
@@ -7420,7 +7199,7 @@ function uninstall(isGlobal, runtime = 'claude') {
// Get the target directory based on runtime and install type
const targetDir = isGlobal
? getGlobalDir(runtime, explicitConfigDir)
? getGlobalConfigDir(runtime, explicitConfigDir)
: path.join(process.cwd(), dirName);
const locationLabel = isGlobal
@@ -7934,7 +7713,7 @@ function configureOpencodePermissions(isGlobal = true, configDir = null) {
// For local installs, use ./.opencode/
// For global installs, use ~/.config/opencode/
const opencodeConfigDir = configDir || (isGlobal
? getGlobalDir('opencode', explicitConfigDir)
? getGlobalConfigDir('opencode', explicitConfigDir)
: path.join(process.cwd(), '.opencode'));
// Ensure config directory exists
fs.mkdirSync(opencodeConfigDir, { recursive: true });
@@ -8014,7 +7793,7 @@ function configureKiloPermissions(isGlobal = true, configDir = null) {
// For local installs, use ./.kilo/
// For global installs, use ~/.config/kilo/
const kiloConfigDir = configDir || (isGlobal
? getGlobalDir('kilo', explicitConfigDir)
? getGlobalConfigDir('kilo', explicitConfigDir)
: path.join(process.cwd(), '.kilo'));
// Ensure config directory exists
fs.mkdirSync(kiloConfigDir, { recursive: true });
@@ -8668,6 +8447,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
const isHermes = runtime === 'hermes';
const isCodebuddy = runtime === 'codebuddy';
const isCline = runtime === 'cline';
const configIntent = resolveRuntimeConfigIntent(runtime);
const dirName = getDirName(runtime);
const src = path.join(__dirname, '..');
@@ -8722,7 +8502,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
// Cline local installs write to the project root (like Claude Code) — .clinerules
// lives at the root, not inside a .cline/ subdirectory.
const targetDir = isGlobal
? getGlobalDir(runtime, explicitConfigDir)
? getGlobalConfigDir(runtime, explicitConfigDir)
: isCline
? process.cwd()
: path.join(process.cwd(), dirName);
@@ -9668,7 +9448,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
throw _earlyInstallErr;
}
if (isCodex && !isMinimalMode(_effectiveInstallMode)) {
if (configIntent.installSurface === 'codex-toml' && !isMinimalMode(_effectiveInstallMode)) {
// Capture pre-install snapshots before ANY GSD mutation
// (#2760 fix 3). On post-write schema-validation failure OR any throw
// during the mutation sequence (write failure, merge throw, etc.) we
@@ -10014,7 +9794,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
if (isCopilot) {
if (configIntent.installSurface === 'copilot-instructions') {
// Generate copilot-instructions.md
const templatePath = path.join(targetDir, 'gsd-core', 'templates', 'copilot-instructions.md');
const instructionsPath = path.join(targetDir, 'copilot-instructions.md');
@@ -10028,32 +9808,13 @@ function install(isGlobal, runtime = 'claude', options = {}) {
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
if (isCursor) {
// Cursor uses skills — no config.toml, no settings.json hooks needed
if (configIntent.installSurface === 'profile-marker-only') {
// Cursor/Windsurf/Trae/Kimi use skills/agents — no config.toml, no settings.json hooks needed
persistActiveProfileMarker();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
if (isWindsurf) {
// Windsurf uses skills — no config.toml, no settings.json hooks needed
persistActiveProfileMarker();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
if (isTrae) {
// Trae uses skills — no settings.json hooks needed
persistActiveProfileMarker();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
if (isKimi) {
// Kimi uses Agent Skills plus explicit custom agent YAML files. It does
// not own settings.json, hooks, rules, or update-banner/statusline config.
persistActiveProfileMarker();
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
}
if (isCline) {
if (configIntent.installSurface === 'cline-rules') {
// Cline uses .clinerules — generate a rules file with GSD system instructions
const clinerulesDest = path.join(targetDir, '.clinerules');
const clinerules = [
@@ -10671,9 +10432,9 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
const isWindsurf = runtime === 'windsurf';
const isTrae = runtime === 'trae';
const isCline = runtime === 'cline';
const isKimi = runtime === 'kimi';
const configIntent = resolveRuntimeConfigIntent(runtime);
if (shouldInstallStatusline && !isOpencode && !isKilo && !isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae && !isKimi) {
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
@@ -10699,7 +10460,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 {
@@ -10731,17 +10492,17 @@ 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 (settingsPath && settings && !isCodex && !isCopilot && !isKilo && !isCursor && !isWindsurf && !isTrae && !isCline && !isKimi) {
if (settingsPath && settings && configIntent.writesSharedSettings) {
writeSettings(settingsPath, validateHookFields(settings));
}
// Configure OpenCode permissions
if (isOpencode && !process.env.GSD_TEST_MODE) {
if (configIntent.finishPermissionWriter === 'opencode' && !process.env.GSD_TEST_MODE) {
configureOpencodePermissions(isGlobal, configDir);
}
// Configure Kilo permissions
if (isKilo) {
if (configIntent.finishPermissionWriter === 'kilo') {
configureKiloPermissions(isGlobal, configDir);
}
@@ -11099,7 +10860,7 @@ function promptLocation(runtimes) {
});
const pathExamples = runtimes.map(r => {
const globalPath = getGlobalDir(r, explicitConfigDir);
const globalPath = getGlobalConfigDir(r, explicitConfigDir);
return globalPath.replace(os.homedir(), '~');
}).join(', ');