diff --git a/bin/install.js b/bin/install.js index ec1cf32c1..0d631a728 100755 --- a/bin/install.js +++ b/bin/install.js @@ -196,7 +196,7 @@ function getOpencodeGlobalDir() { /** * Get the global config directory for Kilo * Kilo follows XDG Base Directory spec and uses ~/.config/kilo/ - * Priority: KILO_CONFIG_DIR > XDG_CONFIG_HOME/kilo > ~/.config/kilo + * Priority: KILO_CONFIG_DIR > dirname(KILO_CONFIG) > XDG_CONFIG_HOME/kilo > ~/.config/kilo */ function getKiloGlobalDir() { // 1. Explicit KILO_CONFIG_DIR env var @@ -204,12 +204,17 @@ function getKiloGlobalDir() { return expandTilde(process.env.KILO_CONFIG_DIR); } - // 2. XDG_CONFIG_HOME/kilo + // 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'); } - // 3. Default: ~/.config/kilo (XDG default) + // 4. Default: ~/.config/kilo (XDG default) return path.join(os.homedir(), '.config', 'kilo'); } @@ -602,6 +607,72 @@ function convertGeminiToolName(claudeTool) { return claudeTool.toLowerCase(); } +const claudeToKiloAgentPermissions = { + Read: 'read', + Write: 'edit', + Edit: 'edit', + Bash: 'bash', + Grep: 'grep', + Glob: 'glob', + Task: 'task', + WebFetch: 'webfetch', + WebSearch: 'websearch', + TodoWrite: 'todowrite', + AskUserQuestion: 'question', + SlashCommand: 'skill', +}; + +const kiloAgentPermissionOrder = [ + 'read', + 'edit', + 'bash', + 'grep', + 'glob', + 'task', + 'webfetch', + 'websearch', + 'skill', + 'question', + 'todowrite', + 'list', + 'codesearch', + 'lsp', +]; + +function convertClaudeToKiloPermissionTool(claudeTool) { + return claudeToKiloAgentPermissions[claudeTool] || null; +} + +function buildKiloAgentPermissionBlock(claudeTools) { + const allowedPermissions = new Set(); + + for (const tool of claudeTools) { + const mapped = convertClaudeToKiloPermissionTool(tool); + if (mapped) { + allowedPermissions.add(mapped); + } + } + + const lines = ['permission:']; + for (const permission of kiloAgentPermissionOrder) { + lines.push(` ${permission}: ${allowedPermissions.has(permission) ? 'allow' : 'deny'}`); + } + + return lines; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function replaceRelativePathReference(content, fromPath, toPath) { + const escapedPath = escapeRegExp(fromPath); + return content.replace( + new RegExp(`(^|[^A-Za-z0-9_./-])${escapedPath}`, 'g'), + (_, prefix) => `${prefix}${toPath}`, + ); +} + /** * Convert a Claude Code tool name to GitHub Copilot format. * - Applies explicit mapping from claudeToCopilotTools @@ -2721,6 +2792,10 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) { // Replace ~/.claude and $HOME/.claude with Kilo's config location convertedContent = convertedContent.replace(/~\/\.claude\b/g, '~/.config/kilo'); convertedContent = convertedContent.replace(/\$HOME\/\.claude\b/g, '$HOME/.config/kilo'); + convertedContent = convertedContent.replace(/\.\/\.claude\//g, './.kilo/'); + convertedContent = replaceRelativePathReference(convertedContent, '.claude/skills/', '.kilo/skills/'); + convertedContent = replaceRelativePathReference(convertedContent, '.agents/skills/', '.kilo/skill/'); + convertedContent = replaceRelativePathReference(convertedContent, '.claude/agents/', '.kilo/agents/'); // Replace general-purpose subagent type with Kilo's equivalent "general" convertedContent = convertedContent.replace(/subagent_type="general-purpose"/g, 'subagent_type="general"'); // Runtime-neutral agent name replacement (#766) @@ -2744,8 +2819,10 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) { const lines = frontmatter.split('\n'); const newLines = []; let inAllowedTools = false; + let inAgentTools = false; let inSkippedArray = false; const allowedTools = []; + const agentTools = []; for (const line of lines) { const trimmed = line.trim(); @@ -2761,11 +2838,26 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) { continue; } + if (isAgent && inAgentTools) { + if (trimmed.startsWith('- ')) { + agentTools.push(trimmed.substring(2).trim()); + continue; + } + if (trimmed && !trimmed.startsWith('-')) { + inAgentTools = false; + } + } + // Detect inline tools: field (comma-separated string) if (trimmed.startsWith('tools:')) { if (isAgent) { - // Agents: strip tools entirely (not supported in Kilo agent frontmatter) - inSkippedArray = true; + const toolsValue = trimmed.substring(6).trim(); + if (toolsValue) { + const tools = toolsValue.split(',').map(t => t.trim()).filter(t => t); + agentTools.push(...tools); + } else { + inAgentTools = true; + } continue; } const toolsValue = trimmed.substring(6).trim(); @@ -2825,7 +2917,12 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) { // Collect allowed-tools items if (inAllowedTools) { if (trimmed.startsWith('- ')) { - allowedTools.push(trimmed.substring(2).trim()); + const tool = trimmed.substring(2).trim(); + if (isAgent) { + agentTools.push(tool); + } else { + allowedTools.push(tool); + } continue; } else if (trimmed && !trimmed.startsWith('-')) { // End of array, new field started @@ -2842,6 +2939,7 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) { // For agents: add required Kilo agent fields if (isAgent) { newLines.push('mode: subagent'); + newLines.push(...buildKiloAgentPermissionBlock(agentTools)); } // For commands: add tools object if we had allowed-tools or tools diff --git a/commands/gsd/reapply-patches.md b/commands/gsd/reapply-patches.md index 728b3a7ab..e8b678258 100644 --- a/commands/gsd/reapply-patches.md +++ b/commands/gsd/reapply-patches.md @@ -29,6 +29,11 @@ if [ -n "$KILO_CONFIG_DIR" ]; then if [ -d "$candidate" ]; then PATCHES_DIR="$candidate" fi +elif [ -n "$KILO_CONFIG" ]; then + candidate="$(dirname "$(expand_home "$KILO_CONFIG")")/gsd-local-patches" + if [ -d "$candidate" ]; then + PATCHES_DIR="$candidate" + fi elif [ -n "$XDG_CONFIG_HOME" ]; then candidate="$(expand_home "$XDG_CONFIG_HOME")/kilo/gsd-local-patches" if [ -d "$candidate" ]; then diff --git a/get-shit-done/workflows/update.md b/get-shit-done/workflows/update.md index cb7a2553f..4d5a8f775 100644 --- a/get-shit-done/workflows/update.md +++ b/get-shit-done/workflows/update.md @@ -22,6 +22,8 @@ First, derive `PREFERRED_CONFIG_DIR` and `PREFERRED_RUNTIME` from the invoking p Use `PREFERRED_CONFIG_DIR` when available so custom `--config-dir` installs are checked before default locations. Use `PREFERRED_RUNTIME` as the first runtime checked so `/gsd:update` targets the runtime that invoked it. +Kilo config precedence must match the installer: `KILO_CONFIG_DIR` -> `dirname(KILO_CONFIG)` -> `XDG_CONFIG_HOME/kilo` -> `~/.config/kilo`. + ```bash expand_home() { case "$1" in @@ -60,6 +62,8 @@ if [ -z "$PREFERRED_RUNTIME" ]; then PREFERRED_RUNTIME="gemini" elif [ -n "$KILO_CONFIG_DIR" ]; then PREFERRED_RUNTIME="kilo" + elif [ -n "$KILO_CONFIG" ]; then + PREFERRED_RUNTIME="kilo" elif [ -n "$OPENCODE_CONFIG_DIR" ] || [ -n "$OPENCODE_CONFIG" ]; then PREFERRED_RUNTIME="opencode" elif [ -n "$CLAUDE_CONFIG_DIR" ]; then @@ -103,6 +107,8 @@ if [ -n "$GEMINI_CONFIG_DIR" ]; then fi if [ -n "$KILO_CONFIG_DIR" ]; then ENV_RUNTIME_DIRS+=( "kilo:$(expand_home "$KILO_CONFIG_DIR")" ) +elif [ -n "$KILO_CONFIG" ]; then + ENV_RUNTIME_DIRS+=( "kilo:$(dirname "$(expand_home "$KILO_CONFIG")")" ) elif [ -n "$XDG_CONFIG_HOME" ]; then ENV_RUNTIME_DIRS+=( "kilo:$(expand_home "$XDG_CONFIG_HOME")/kilo" ) fi @@ -392,6 +398,8 @@ if [ -n "$GEMINI_CONFIG_DIR" ]; then fi if [ -n "$KILO_CONFIG_DIR" ]; then CACHE_DIRS+=( "$(expand_home "$KILO_CONFIG_DIR")" ) +elif [ -n "$KILO_CONFIG" ]; then + CACHE_DIRS+=( "$(dirname "$(expand_home "$KILO_CONFIG")")" ) elif [ -n "$XDG_CONFIG_HOME" ]; then CACHE_DIRS+=( "$(expand_home "$XDG_CONFIG_HOME")/kilo" ) fi diff --git a/tests/kilo-install.test.cjs b/tests/kilo-install.test.cjs index 87d4896c1..0d6de47ea 100644 --- a/tests/kilo-install.test.cjs +++ b/tests/kilo-install.test.cjs @@ -87,9 +87,15 @@ describe('getGlobalDir (Kilo)', () => { assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), 'xdg-config', 'kilo')); }); - test('ignores KILO_CONFIG file env var and uses XDG/default resolution only', () => { - process.env.KILO_CONFIG = '/tmp/ignored-kilo.json'; - assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), '.config', 'kilo')); + test('uses dirname(KILO_CONFIG) when KILO_CONFIG_DIR is unset', () => { + process.env.KILO_CONFIG = '~/profiles/work/kilo.jsonc'; + assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), 'profiles', 'work')); + }); + + test('KILO_CONFIG_DIR takes precedence over KILO_CONFIG', () => { + process.env.KILO_CONFIG_DIR = '~/custom-kilo'; + process.env.KILO_CONFIG = '~/profiles/work/kilo.jsonc'; + assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), 'custom-kilo')); }); test('explicit config-dir overrides env vars', () => { @@ -248,10 +254,12 @@ describe('Source code integration (Kilo)', () => { assert.ok(updateWorkflowSrc.includes('PREFERRED_CONFIG_DIR'), 'workflow tracks preferred config dir'); assert.ok(updateWorkflowSrc.includes('kilo.jsonc'), 'workflow infers Kilo from config files'); assert.ok(updateWorkflowSrc.includes('ENV_RUNTIME_DIRS'), 'workflow checks env-derived config dirs'); + assert.ok(updateWorkflowSrc.includes('KILO_CONFIG'), 'workflow checks KILO_CONFIG'); }); test('reapply-patches checks Kilo custom config env vars first', () => { assert.ok(reapplyPatchesSrc.includes('KILO_CONFIG_DIR'), 'reapply-patches checks KILO_CONFIG_DIR'); + assert.ok(reapplyPatchesSrc.includes('KILO_CONFIG'), 'reapply-patches checks KILO_CONFIG'); assert.ok(reapplyPatchesSrc.includes('XDG_CONFIG_HOME'), 'reapply-patches checks XDG_CONFIG_HOME'); }); }); diff --git a/tests/runtime-converters.test.cjs b/tests/runtime-converters.test.cjs index b47a4ab1c..ec137efff 100644 --- a/tests/runtime-converters.test.cjs +++ b/tests/runtime-converters.test.cjs @@ -91,6 +91,18 @@ for (const { label, convert, configDir } of flatRuntimeSuites) { const frontmatter = result.split('---')[1]; assert.ok(!frontmatter.includes('tools:'), 'tools: should be stripped for agents'); assert.ok(!frontmatter.includes('read: true'), 'tools object should not be generated'); + + if (label === 'Kilo') { + assert.ok(frontmatter.includes('permission:'), 'Kilo agents should emit permission block'); + assert.ok(frontmatter.includes('read: allow'), 'Read should map to read: allow'); + assert.ok(frontmatter.includes('edit: allow'), 'Write/Edit should map to edit: allow'); + assert.ok(frontmatter.includes('bash: allow'), 'Bash should map to bash: allow'); + assert.ok(frontmatter.includes('grep: allow'), 'Grep should map to grep: allow'); + assert.ok(frontmatter.includes('glob: allow'), 'Glob should map to glob: allow'); + assert.ok(frontmatter.includes('task: deny'), 'unspecified permissions should be denied'); + } else { + assert.ok(!frontmatter.includes('permission:'), 'OpenCode agents should not emit permission block'); + } }); test('strips skills: array', () => { @@ -133,11 +145,21 @@ tools: Read --- Read ~/.claude/agent-memory/ for context. -Use $HOME/.claude/skills/ for reference.`; +Use $HOME/.claude/skills/ for reference. +Check .claude/skills/ and .claude/agents/ locally. +Use ./.claude/hooks/gsd-statusline.js during local testing. +Fallback skills live in .agents/skills/.`; const result = convert(agentWithClaudePaths, { isAgent: true }); assert.ok(result.includes(`~/${configDir}/agent-memory/`), '~/.claude should be replaced'); assert.ok(result.includes(`$HOME/${configDir}/skills/`), '$HOME/.claude should be replaced'); + + if (label === 'Kilo') { + assert.ok(result.includes('.kilo/skills/'), '.claude/skills should be replaced for Kilo'); + assert.ok(result.includes('.kilo/agents/'), '.claude/agents should be replaced for Kilo'); + assert.ok(result.includes('./.kilo/hooks/'), './.claude should be replaced for Kilo'); + assert.ok(result.includes('.kilo/skill/'), '.agents/skills should be rewritten to Kilo skill dir'); + } }); });