Merge pull request #1517 from Kilo-Org/feat/kilo-runtime-support

Add Kilo Code support
This commit is contained in:
Tom Boucher
2026-04-03 11:20:27 -04:00
committed by GitHub
39 changed files with 1406 additions and 327 deletions

View File

@@ -36,6 +36,7 @@ describe('getDirName (Antigravity)', () => {
assert.strictEqual(getDirName('claude'), '.claude');
assert.strictEqual(getDirName('opencode'), '.opencode');
assert.strictEqual(getDirName('gemini'), '.gemini');
assert.strictEqual(getDirName('kilo'), '.kilo');
assert.strictEqual(getDirName('codex'), '.codex');
assert.strictEqual(getDirName('copilot'), '.github');
});
@@ -97,6 +98,7 @@ describe('getConfigDirFromHome (Antigravity)', () => {
test('does not change other runtimes', () => {
assert.strictEqual(getConfigDirFromHome('claude', true), "'.claude'");
assert.strictEqual(getConfigDirFromHome('gemini', true), "'.gemini'");
assert.strictEqual(getConfigDirFromHome('kilo', true), "'.config', 'kilo'");
assert.strictEqual(getConfigDirFromHome('copilot', true), "'.copilot'");
});
});

View File

@@ -44,6 +44,7 @@ describe('getDirName (Copilot)', () => {
assert.strictEqual(getDirName('claude'), '.claude');
assert.strictEqual(getDirName('opencode'), '.opencode');
assert.strictEqual(getDirName('gemini'), '.gemini');
assert.strictEqual(getDirName('kilo'), '.kilo');
assert.strictEqual(getDirName('codex'), '.codex');
});
});
@@ -109,6 +110,7 @@ describe('getConfigDirFromHome (Copilot)', () => {
assert.strictEqual(getConfigDirFromHome('opencode', true), "'.config', 'opencode'");
assert.strictEqual(getConfigDirFromHome('claude', true), "'.claude'");
assert.strictEqual(getConfigDirFromHome('gemini', true), "'.gemini'");
assert.strictEqual(getConfigDirFromHome('kilo', true), "'.config', 'kilo'");
assert.strictEqual(getConfigDirFromHome('codex', true), "'.codex'");
});
});
@@ -137,8 +139,8 @@ describe('Source code integration (Copilot)', () => {
assert.ok(src.includes('--copilot'), 'help text has --copilot option');
});
test('CLI-02: promptRuntime runtimeMap has Copilot as option 5', () => {
assert.ok(src.includes("'5': 'copilot'"), 'runtimeMap has 5 -> copilot');
test('CLI-02: promptRuntime runtimeMap has Copilot as option 6', () => {
assert.ok(src.includes("'6': 'copilot'"), 'runtimeMap has 6 -> copilot');
});
test('CLI-02: promptRuntime allRuntimes array includes copilot', () => {
@@ -146,6 +148,12 @@ describe('Source code integration (Copilot)', () => {
assert.ok(allMatch && allMatch[1].includes('copilot'), 'allRuntimes includes copilot');
});
test('CLI-02: promptRuntime keeps Kilo above OpenCode in allRuntimes', () => {
const allMatch = src.match(/const allRuntimes = \[([^\]]+)\]/);
assert.ok(allMatch, 'allRuntimes array found');
assert.ok(allMatch[1].indexOf("'kilo'") < allMatch[1].indexOf("'opencode'"), 'kilo appears before opencode');
});
test('isCopilot variable exists in install function', () => {
assert.ok(src.includes("const isCopilot = runtime === 'copilot'"), 'isCopilot defined');
});

265
tests/kilo-install.test.cjs Normal file
View File

@@ -0,0 +1,265 @@
/**
* GSD Tools Tests - Kilo Install Plumbing
*
* Tests for Kilo runtime directory resolution, config paths,
* permission config, and installer source integration.
*/
process.env.GSD_TEST_MODE = '1';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { createTempProject, cleanup } = require('./helpers.cjs');
const {
getDirName,
getGlobalDir,
getConfigDirFromHome,
resolveKiloConfigPath,
configureKiloPermissions,
} = require('../bin/install.js');
describe('getDirName (Kilo)', () => {
test('returns .kilo for kilo', () => {
assert.strictEqual(getDirName('kilo'), '.kilo');
});
});
describe('getConfigDirFromHome (Kilo)', () => {
test('returns .kilo for local installs', () => {
assert.strictEqual(getConfigDirFromHome('kilo', false), "'.kilo'");
});
test('returns .config, kilo for global installs', () => {
assert.strictEqual(getConfigDirFromHome('kilo', true), "'.config', 'kilo'");
});
});
describe('getGlobalDir (Kilo)', () => {
let savedEnv;
beforeEach(() => {
savedEnv = {
KILO_CONFIG_DIR: process.env.KILO_CONFIG_DIR,
KILO_CONFIG: process.env.KILO_CONFIG,
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
};
delete process.env.KILO_CONFIG_DIR;
delete process.env.KILO_CONFIG;
delete process.env.XDG_CONFIG_HOME;
});
afterEach(() => {
if (savedEnv.KILO_CONFIG_DIR === undefined) {
delete process.env.KILO_CONFIG_DIR;
} else {
process.env.KILO_CONFIG_DIR = savedEnv.KILO_CONFIG_DIR;
}
if (savedEnv.KILO_CONFIG === undefined) {
delete process.env.KILO_CONFIG;
} else {
process.env.KILO_CONFIG = savedEnv.KILO_CONFIG;
}
if (savedEnv.XDG_CONFIG_HOME === undefined) {
delete process.env.XDG_CONFIG_HOME;
} else {
process.env.XDG_CONFIG_HOME = savedEnv.XDG_CONFIG_HOME;
}
});
test('returns ~/.config/kilo by default', () => {
assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), '.config', 'kilo'));
});
test('respects KILO_CONFIG_DIR env var', () => {
process.env.KILO_CONFIG_DIR = '~/custom-kilo';
assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), 'custom-kilo'));
});
test('falls back to XDG_CONFIG_HOME/kilo', () => {
process.env.XDG_CONFIG_HOME = '~/xdg-config';
assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), 'xdg-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', () => {
process.env.KILO_CONFIG_DIR = '~/from-env';
process.env.XDG_CONFIG_HOME = '~/xdg-config';
assert.strictEqual(getGlobalDir('kilo', '/explicit/kilo'), '/explicit/kilo');
});
});
describe('Kilo config file helpers', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('gsd-kilo-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('resolveKiloConfigPath prefers kilo.jsonc when present', () => {
const configDir = path.join(tmpDir, '.kilo');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'kilo.jsonc'), '{\n // comment\n}\n');
assert.strictEqual(resolveKiloConfigPath(configDir), path.join(configDir, 'kilo.jsonc'));
});
test('resolveKiloConfigPath falls back to kilo.json', () => {
const configDir = path.join(tmpDir, '.kilo');
fs.mkdirSync(configDir, { recursive: true });
assert.strictEqual(resolveKiloConfigPath(configDir), path.join(configDir, 'kilo.json'));
});
});
describe('configureKiloPermissions', () => {
let tmpDir;
let configDir;
let savedEnv;
beforeEach(() => {
tmpDir = createTempProject('gsd-kilo-perms-');
configDir = path.join(tmpDir, '.config', 'kilo');
savedEnv = {
KILO_CONFIG_DIR: process.env.KILO_CONFIG_DIR,
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
};
process.env.KILO_CONFIG_DIR = configDir;
delete process.env.XDG_CONFIG_HOME;
});
afterEach(() => {
if (savedEnv.KILO_CONFIG_DIR === undefined) {
delete process.env.KILO_CONFIG_DIR;
} else {
process.env.KILO_CONFIG_DIR = savedEnv.KILO_CONFIG_DIR;
}
if (savedEnv.XDG_CONFIG_HOME === undefined) {
delete process.env.XDG_CONFIG_HOME;
} else {
process.env.XDG_CONFIG_HOME = savedEnv.XDG_CONFIG_HOME;
}
cleanup(tmpDir);
});
test('writes GSD permissions to kilo.json when config is missing', () => {
configureKiloPermissions(true);
const configPath = path.join(configDir, 'kilo.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const gsdPath = `${configDir.replace(/\\/g, '/')}/get-shit-done/*`;
assert.strictEqual(config.permission.read[gsdPath], 'allow');
assert.strictEqual(config.permission.external_directory[gsdPath], 'allow');
});
test('updates existing kilo.jsonc configs via JSONC parsing', () => {
fs.mkdirSync(configDir, { recursive: true });
const configPath = path.join(configDir, 'kilo.jsonc');
fs.writeFileSync(configPath, '{\n // existing config\n "permission": {\n "bash": "ask",\n },\n}\n');
configureKiloPermissions(true);
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const gsdPath = `${configDir.replace(/\\/g, '/')}/get-shit-done/*`;
assert.strictEqual(config.permission.bash, 'ask');
assert.strictEqual(config.permission.read[gsdPath], 'allow');
assert.strictEqual(config.permission.external_directory[gsdPath], 'allow');
});
test('writes permissions to an explicit config dir argument', () => {
const explicitDir = path.join(tmpDir, 'custom-kilo-config');
configureKiloPermissions(true, explicitDir);
const configPath = path.join(explicitDir, 'kilo.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const gsdPath = `${explicitDir.replace(/\\/g, '/')}/get-shit-done/*`;
assert.strictEqual(config.permission.read[gsdPath], 'allow');
assert.strictEqual(config.permission.external_directory[gsdPath], 'allow');
});
});
describe('Source code integration (Kilo)', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'bin', 'install.js'), 'utf8');
const updateWorkflowSrc = fs.readFileSync(path.join(__dirname, '..', 'get-shit-done', 'workflows', 'update.md'), 'utf8');
const reapplyPatchesSrc = fs.readFileSync(path.join(__dirname, '..', 'commands', 'gsd', 'reapply-patches.md'), 'utf8');
test('--kilo flag parsing exists', () => {
assert.ok(src.includes("args.includes('--kilo')"), '--kilo flag parsed');
});
test('help text includes --kilo', () => {
assert.ok(src.includes('Install for Kilo only'), 'help text includes Kilo option');
});
test('--all array includes kilo', () => {
assert.ok(src.includes("'kilo'"), '--all includes kilo runtime');
});
test('promptRuntime runtimeMap has Kilo as option 2', () => {
assert.ok(src.includes("'2': 'kilo'"), 'runtimeMap has 2 -> kilo');
});
test('prompt text shows Kilo above OpenCode with updated description', () => {
assert.ok(src.includes('2${reset}) Kilo'), 'prompt lists Kilo as option 2');
assert.ok(src.includes('the #1 AI coding platform on OpenRouter'), 'prompt includes updated Kilo description');
});
test('hooks are skipped for Kilo', () => {
assert.ok(src.includes('!isOpencode && !isKilo'), 'hooks skip check includes kilo');
});
test('settings.json write excludes Kilo', () => {
assert.ok(src.includes('!isCodex && !isCopilot && !isKilo && !isCursor && !isWindsurf'), 'settings write excludes kilo');
});
test('agent path replacement does not exclude Kilo', () => {
assert.ok(src.includes('if (!isCopilot && !isAntigravity)'), 'generic agent path replacement still applies');
});
test('finishInstall passes the actual config dir to Kilo permissions', () => {
assert.ok(src.includes('configureKiloPermissions(isGlobal, configDir);'), 'Kilo permission config uses actual install dir');
});
test('uninstall cleans Kilo permissions from the resolved target dir', () => {
assert.ok(src.includes('const configPath = resolveKiloConfigPath(targetDir);'), 'Kilo uninstall cleanup uses targetDir');
});
test('update workflow checks preferred custom config dirs before defaults', () => {
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

@@ -19,16 +19,17 @@ const installSrc = fs.readFileSync(
// Extract runtimeMap from source for validation
const runtimeMap = {
'1': 'claude',
'2': 'opencode',
'3': 'gemini',
'4': 'codex',
'5': 'copilot',
'6': 'antigravity',
'7': 'cursor',
'8': 'windsurf',
'9': 'augment'
'2': 'kilo',
'3': 'opencode',
'4': 'gemini',
'5': 'codex',
'6': 'copilot',
'7': 'antigravity',
'8': 'cursor',
'9': 'windsurf',
'10': 'augment'
};
const allRuntimes = ['claude', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment'];
const allRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment'];
/**
* Simulate the parsing logic from promptRuntime without requiring readline.
@@ -37,7 +38,7 @@ const allRuntimes = ['claude', 'opencode', 'gemini', 'codex', 'copilot', 'antigr
function parseRuntimeInput(input) {
input = input.trim() || '1';
if (input === '10') {
if (input === '11') {
return allRuntimes;
}
@@ -56,35 +57,39 @@ function parseRuntimeInput(input) {
describe('multi-runtime selection parsing', () => {
test('single choice returns single runtime', () => {
assert.deepStrictEqual(parseRuntimeInput('1'), ['claude']);
assert.deepStrictEqual(parseRuntimeInput('4'), ['codex']);
assert.deepStrictEqual(parseRuntimeInput('7'), ['cursor']);
assert.deepStrictEqual(parseRuntimeInput('2'), ['kilo']);
assert.deepStrictEqual(parseRuntimeInput('3'), ['opencode']);
assert.deepStrictEqual(parseRuntimeInput('4'), ['gemini']);
assert.deepStrictEqual(parseRuntimeInput('5'), ['codex']);
assert.deepStrictEqual(parseRuntimeInput('8'), ['cursor']);
});
test('comma-separated choices return multiple runtimes', () => {
assert.deepStrictEqual(parseRuntimeInput('1,4,6'), ['claude', 'codex', 'antigravity']);
assert.deepStrictEqual(parseRuntimeInput('2,3'), ['opencode', 'gemini']);
assert.deepStrictEqual(parseRuntimeInput('1,5,7'), ['claude', 'codex', 'antigravity']);
assert.deepStrictEqual(parseRuntimeInput('2,3'), ['kilo', 'opencode']);
assert.deepStrictEqual(parseRuntimeInput('3,4'), ['opencode', 'gemini']);
});
test('space-separated choices return multiple runtimes', () => {
assert.deepStrictEqual(parseRuntimeInput('1 4 6'), ['claude', 'codex', 'antigravity']);
assert.deepStrictEqual(parseRuntimeInput('5 7'), ['copilot', 'cursor']);
assert.deepStrictEqual(parseRuntimeInput('1 5 7'), ['claude', 'codex', 'antigravity']);
assert.deepStrictEqual(parseRuntimeInput('6 8'), ['copilot', 'cursor']);
});
test('mixed comma and space separators work', () => {
assert.deepStrictEqual(parseRuntimeInput('1, 4, 6'), ['claude', 'codex', 'antigravity']);
assert.deepStrictEqual(parseRuntimeInput('2 , 5'), ['opencode', 'copilot']);
assert.deepStrictEqual(parseRuntimeInput('1, 5, 7'), ['claude', 'codex', 'antigravity']);
assert.deepStrictEqual(parseRuntimeInput('2 , 6'), ['kilo', 'copilot']);
});
test('single choice for windsurf', () => {
assert.deepStrictEqual(parseRuntimeInput('8'), ['windsurf']);
assert.deepStrictEqual(parseRuntimeInput('9'), ['windsurf']);
});
test('single choice for augment', () => {
assert.deepStrictEqual(parseRuntimeInput('9'), ['augment']);
assert.deepStrictEqual(parseRuntimeInput('10'), ['augment']);
});
test('choice 10 returns all runtimes', () => {
assert.deepStrictEqual(parseRuntimeInput('10'), allRuntimes);
test('choice 11 returns all runtimes', () => {
assert.deepStrictEqual(parseRuntimeInput('11'), allRuntimes);
});
test('empty input defaults to claude', () => {
@@ -93,29 +98,29 @@ describe('multi-runtime selection parsing', () => {
});
test('invalid choices are ignored, falls back to claude if all invalid', () => {
assert.deepStrictEqual(parseRuntimeInput('11'), ['claude']);
assert.deepStrictEqual(parseRuntimeInput('12'), ['claude']);
assert.deepStrictEqual(parseRuntimeInput('0'), ['claude']);
assert.deepStrictEqual(parseRuntimeInput('abc'), ['claude']);
});
test('invalid choices mixed with valid are filtered out', () => {
assert.deepStrictEqual(parseRuntimeInput('1,11,4'), ['claude', 'codex']);
assert.deepStrictEqual(parseRuntimeInput('abc 3 xyz'), ['gemini']);
assert.deepStrictEqual(parseRuntimeInput('1,11,5'), ['claude', 'codex']);
assert.deepStrictEqual(parseRuntimeInput('abc 3 xyz'), ['opencode']);
});
test('duplicate choices are deduplicated', () => {
assert.deepStrictEqual(parseRuntimeInput('1,1,1'), ['claude']);
assert.deepStrictEqual(parseRuntimeInput('4,4,6,6'), ['codex', 'antigravity']);
assert.deepStrictEqual(parseRuntimeInput('5,5,7,7'), ['codex', 'antigravity']);
});
test('preserves selection order', () => {
assert.deepStrictEqual(parseRuntimeInput('6,1,4'), ['antigravity', 'claude', 'codex']);
assert.deepStrictEqual(parseRuntimeInput('7,2,5'), ['cursor', 'opencode', 'copilot']);
assert.deepStrictEqual(parseRuntimeInput('7,1,5'), ['antigravity', 'claude', 'codex']);
assert.deepStrictEqual(parseRuntimeInput('8,2,6'), ['cursor', 'kilo', 'copilot']);
});
});
describe('install.js source contains multi-select support', () => {
test('runtimeMap is defined with all 9 runtimes', () => {
test('runtimeMap is defined with all 10 runtimes', () => {
for (const [key, name] of Object.entries(runtimeMap)) {
assert.ok(
installSrc.includes(`'${key}': '${name}'`),
@@ -132,6 +137,24 @@ describe('install.js source contains multi-select support', () => {
}
});
test('all shortcut uses option 11', () => {
assert.ok(
installSrc.includes("if (input === '11')"),
'all shortcut uses option 11'
);
});
test('prompt lists Augment as option 10 and All as option 11', () => {
assert.ok(
installSrc.includes('10${reset}) Augment'),
'prompt lists Augment as option 10'
);
assert.ok(
installSrc.includes('11${reset}) All'),
'prompt lists All as option 11'
);
});
test('prompt text shows multi-select hint', () => {
assert.ok(
installSrc.includes('Select multiple'),

View File

@@ -2,20 +2,24 @@
* Regression tests for OpenCode permission config handling.
*
* Ensures the installer does not crash when opencode.json uses the valid
* top-level string form: "permission": "allow".
* top-level string form: "permission": "allow", and that path-specific
* permissions are written against the actual resolved install directory.
*/
process.env.GSD_TEST_MODE = '1';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { createTempDir, cleanup } = require('./helpers.cjs');
const fs = require('node:fs');
const path = require('node:path');
process.env.GSD_TEST_MODE = '1';
const { createTempDir, cleanup } = require('./helpers.cjs');
const { configureOpencodePermissions } = require('../bin/install.js');
const installSrc = fs.readFileSync(path.join(__dirname, '..', 'bin', 'install.js'), 'utf8');
const envKeys = ['OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG', 'XDG_CONFIG_HOME'];
const originalEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]]));
const originalEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]));
function restoreEnv(snapshot) {
for (const key of envKeys) {
@@ -27,18 +31,18 @@ function restoreEnv(snapshot) {
}
}
let configDir;
beforeEach(() => {
configDir = createTempDir('gsd-opencode-');
});
afterEach(() => {
cleanup(configDir);
restoreEnv(originalEnv);
});
describe('configureOpencodePermissions', () => {
let configDir;
beforeEach(() => {
configDir = createTempDir('gsd-opencode-');
});
afterEach(() => {
cleanup(configDir);
restoreEnv(originalEnv);
});
test('does not crash or rewrite top-level string permissions', () => {
const configPath = path.join(configDir, 'opencode.json');
const original = JSON.stringify({
@@ -50,7 +54,7 @@ describe('configureOpencodePermissions', () => {
fs.writeFileSync(configPath, original);
process.env.OPENCODE_CONFIG_DIR = configDir;
assert.doesNotThrow(() => configureOpencodePermissions(true));
assert.doesNotThrow(() => configureOpencodePermissions(true, configDir));
assert.strictEqual(fs.readFileSync(configPath, 'utf8'), original);
});
@@ -59,11 +63,19 @@ describe('configureOpencodePermissions', () => {
fs.writeFileSync(configPath, JSON.stringify({ permission: {} }, null, 2) + '\n');
process.env.OPENCODE_CONFIG_DIR = configDir;
configureOpencodePermissions(true);
configureOpencodePermissions(true, configDir);
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const gsdPath = `${configDir.replace(/\\/g, '/')}/get-shit-done/*`;
assert.strictEqual(config.permission.read[gsdPath], 'allow');
assert.strictEqual(config.permission.external_directory[gsdPath], 'allow');
});
test('finishInstall passes the actual config dir to OpenCode permissions', () => {
assert.ok(
installSrc.includes('configureOpencodePermissions(isGlobal, configDir);'),
'OpenCode permission config uses actual install dir'
);
});
});

View File

@@ -1,12 +1,12 @@
/**
* Runtime Converter Tests — OpenCode + Gemini
* Runtime Converter Tests — OpenCode + Kilo + Gemini
*
* Tests for small runtime-specific conversion functions from install.js.
* Larger runtime test suites (Copilot, Codex, Antigravity) have their own files.
*
* OpenCode: convertClaudeToOpencodeFrontmatter (agent + command modes)
* model: inherit is NOT added (OpenCode doesn't support it — see #1156)
* but mode: subagent IS added (required by OpenCode agents).
* OpenCode/Kilo: flat-runtime frontmatter converters (agent + command modes)
* model: inherit is NOT added (runtime uses its configured default model)
* but mode: subagent IS added (required by both runtimes' agents).
* Gemini: convertClaudeToGeminiAgent (frontmatter + tool mapping + body escaping)
*/
@@ -16,6 +16,7 @@ const assert = require('node:assert');
process.env.GSD_TEST_MODE = '1';
const {
convertClaudeToOpencodeFrontmatter,
convertClaudeToKiloFrontmatter,
convertClaudeToGeminiAgent,
neutralizeAgentReferences,
} = require('../bin/install.js');
@@ -52,100 +53,138 @@ allowed-tools:
Execute the phase plan.`;
describe('OpenCode agent conversion (isAgent: true)', () => {
test('keeps name: field for agents', () => {
const result = convertClaudeToOpencodeFrontmatter(SAMPLE_AGENT, { isAgent: true });
const frontmatter = result.split('---')[1];
assert.ok(frontmatter.includes('name: gsd-executor'), 'name: should be preserved for agents');
});
const flatRuntimeSuites = [
{
label: 'OpenCode',
convert: convertClaudeToOpencodeFrontmatter,
configDir: '.config/opencode',
},
{
label: 'Kilo',
convert: convertClaudeToKiloFrontmatter,
configDir: '.config/kilo',
},
];
test('does not add model: inherit (OpenCode does not support it)', () => {
const result = convertClaudeToOpencodeFrontmatter(SAMPLE_AGENT, { isAgent: true });
const frontmatter = result.split('---')[1];
assert.ok(!frontmatter.includes('model: inherit'), 'model: inherit should NOT be added — OpenCode throws ProviderModelNotFoundError');
});
for (const { label, convert, configDir } of flatRuntimeSuites) {
describe(`${label} agent conversion (isAgent: true)`, () => {
test('keeps name: field for agents', () => {
const result = convert(SAMPLE_AGENT, { isAgent: true });
const frontmatter = result.split('---')[1];
assert.ok(frontmatter.includes('name: gsd-executor'), 'name: should be preserved for agents');
});
test('adds mode: subagent', () => {
const result = convertClaudeToOpencodeFrontmatter(SAMPLE_AGENT, { isAgent: true });
const frontmatter = result.split('---')[1];
assert.ok(frontmatter.includes('mode: subagent'), 'mode: subagent should be added');
});
test('does not add model: inherit', () => {
const result = convert(SAMPLE_AGENT, { isAgent: true });
const frontmatter = result.split('---')[1];
assert.ok(!frontmatter.includes('model: inherit'), 'model: inherit should NOT be added');
});
test('strips tools: field', () => {
const result = convertClaudeToOpencodeFrontmatter(SAMPLE_AGENT, { isAgent: true });
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');
});
test('adds mode: subagent', () => {
const result = convert(SAMPLE_AGENT, { isAgent: true });
const frontmatter = result.split('---')[1];
assert.ok(frontmatter.includes('mode: subagent'), 'mode: subagent should be added');
});
test('strips skills: array', () => {
const result = convertClaudeToOpencodeFrontmatter(SAMPLE_AGENT, { isAgent: true });
const frontmatter = result.split('---')[1];
assert.ok(!frontmatter.includes('skills:'), 'skills: should be stripped');
assert.ok(!frontmatter.includes('gsd-executor-workflow'), 'skill entries should be stripped');
});
test('strips tools: field', () => {
const result = convert(SAMPLE_AGENT, { isAgent: true });
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');
test('strips color: field', () => {
const result = convertClaudeToOpencodeFrontmatter(SAMPLE_AGENT, { isAgent: true });
const frontmatter = result.split('---')[1];
assert.ok(!frontmatter.includes('color:'), 'color: should be stripped for agents');
});
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 commented hooks block', () => {
const result = convertClaudeToOpencodeFrontmatter(SAMPLE_AGENT, { isAgent: true });
const frontmatter = result.split('---')[1];
assert.ok(!frontmatter.includes('# hooks:'), 'commented hooks should be stripped');
assert.ok(!frontmatter.includes('PostToolUse'), 'hook content should be stripped');
});
test('strips skills: array', () => {
const result = convert(SAMPLE_AGENT, { isAgent: true });
const frontmatter = result.split('---')[1];
assert.ok(!frontmatter.includes('skills:'), 'skills: should be stripped');
assert.ok(!frontmatter.includes('gsd-executor-workflow'), 'skill entries should be stripped');
});
test('keeps description: field', () => {
const result = convertClaudeToOpencodeFrontmatter(SAMPLE_AGENT, { isAgent: true });
const frontmatter = result.split('---')[1];
assert.ok(frontmatter.includes('description: Executes GSD plans'), 'description should be kept');
});
test('strips color: field', () => {
const result = convert(SAMPLE_AGENT, { isAgent: true });
const frontmatter = result.split('---')[1];
assert.ok(!frontmatter.includes('color:'), 'color: should be stripped for agents');
});
test('preserves body content', () => {
const result = convertClaudeToOpencodeFrontmatter(SAMPLE_AGENT, { isAgent: true });
assert.ok(result.includes('<role>'), 'body should be preserved');
assert.ok(result.includes('You are a GSD plan executor.'), 'body content should be intact');
});
test('strips commented hooks block', () => {
const result = convert(SAMPLE_AGENT, { isAgent: true });
const frontmatter = result.split('---')[1];
assert.ok(!frontmatter.includes('# hooks:'), 'commented hooks should be stripped');
assert.ok(!frontmatter.includes('PostToolUse'), 'hook content should be stripped');
});
test('applies body text replacements', () => {
const agentWithClaudePaths = `---
test('keeps description: field', () => {
const result = convert(SAMPLE_AGENT, { isAgent: true });
const frontmatter = result.split('---')[1];
assert.ok(frontmatter.includes('description: Executes GSD plans'), 'description should be kept');
});
test('preserves body content', () => {
const result = convert(SAMPLE_AGENT, { isAgent: true });
assert.ok(result.includes('<role>'), 'body should be preserved');
assert.ok(result.includes('You are a GSD plan executor.'), 'body content should be intact');
});
test('applies body text replacements', () => {
const agentWithClaudePaths = `---
name: test-agent
description: Test
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 = convertClaudeToOpencodeFrontmatter(agentWithClaudePaths, { isAgent: true });
assert.ok(result.includes('~/.config/opencode/agent-memory/'), '~/.claude should be replaced');
assert.ok(result.includes('$HOME/.config/opencode/skills/'), '$HOME/.claude should be replaced');
});
});
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');
describe('OpenCode command conversion (isAgent: false, default)', () => {
test('strips name: field for commands', () => {
const result = convertClaudeToOpencodeFrontmatter(SAMPLE_COMMAND);
const frontmatter = result.split('---')[1];
assert.ok(!frontmatter.includes('name:'), 'name: should be stripped for commands');
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('Fallback skills live in .kilo/skills/.'), '.agents/skills should be rewritten to Kilo skills dir');
assert.ok(!result.includes('.kilo/skill/'), 'singular Kilo skill dir should not be emitted');
}
});
});
test('does not add model: or mode: for commands', () => {
const result = convertClaudeToOpencodeFrontmatter(SAMPLE_COMMAND);
const frontmatter = result.split('---')[1];
assert.ok(!frontmatter.includes('model:'), 'model: should not be added for commands');
assert.ok(!frontmatter.includes('mode:'), 'mode: should not be added for commands');
});
describe(`${label} command conversion (isAgent: false, default)`, () => {
test('strips name: field for commands', () => {
const result = convert(SAMPLE_COMMAND);
const frontmatter = result.split('---')[1];
assert.ok(!frontmatter.includes('name:'), 'name: should be stripped for commands');
});
test('keeps description: for commands', () => {
const result = convertClaudeToOpencodeFrontmatter(SAMPLE_COMMAND);
const frontmatter = result.split('---')[1];
assert.ok(frontmatter.includes('description:'), 'description should be kept');
test('does not add model: or mode: for commands', () => {
const result = convert(SAMPLE_COMMAND);
const frontmatter = result.split('---')[1];
assert.ok(!frontmatter.includes('model:'), 'model: should not be added for commands');
assert.ok(!frontmatter.includes('mode:'), 'mode: should not be added for commands');
});
test('keeps description: for commands', () => {
const result = convert(SAMPLE_COMMAND);
const frontmatter = result.split('---')[1];
assert.ok(frontmatter.includes('description:'), 'description should be kept');
});
});
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Gemini CLI agent conversion (merged from gemini-config.test.cjs)