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