fix: align Kilo config and agent conversion

Honor KILO_CONFIG across installer and workflow resolution, preserve Claude agent tool intent as explicit Kilo permissions, and rewrite relative .claude references during Kilo conversion.
This commit is contained in:
Alex Alecu
2026-04-02 15:44:46 +03:00
parent 52585de4ab
commit f8edfe7f15
5 changed files with 151 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View File

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