Merge pull request #1517 from Kilo-Org/feat/kilo-runtime-support
Add Kilo Code support
This commit is contained in:
@@ -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'");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
265
tests/kilo-install.test.cjs
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user