From b8b01fca640a55f683981896833115fb977216af Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 3 Apr 2026 10:55:34 +0300 Subject: [PATCH] fix: normalize Kilo skill paths and guard OpenCode permissions Keep Kilo skill path rewrites consistent and avoid rewriting valid string-valued OpenCode permission configs while preserving resolved config-dir handling. --- bin/install.js | 12 +++++- tests/opencode-permissions.test.cjs | 61 +++++++++++++++++++++++++++++ tests/runtime-converters.test.cjs | 3 +- 3 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 tests/opencode-permissions.test.cjs diff --git a/bin/install.js b/bin/install.js index dd8bccbbc..4b550a8d3 100755 --- a/bin/install.js +++ b/bin/install.js @@ -2895,8 +2895,9 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) { convertedContent = convertedContent.replace(/~\/\.claude\b/g, '~/.config/kilo'); convertedContent = convertedContent.replace(/\$HOME\/\.claude\b/g, '$HOME/.config/kilo'); convertedContent = convertedContent.replace(/\.\/\.claude\//g, './.kilo/'); + // Normalize both Claude skill directory variants to Kilo's canonical skills dir. convertedContent = replaceRelativePathReference(convertedContent, '.claude/skills/', '.kilo/skills/'); - convertedContent = replaceRelativePathReference(convertedContent, '.agents/skills/', '.kilo/skill/'); + convertedContent = replaceRelativePathReference(convertedContent, '.agents/skills/', '.kilo/skills/'); 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"'); @@ -4337,8 +4338,14 @@ function configureOpencodePermissions(isGlobal = true, configDir = null) { } } + // OpenCode also supports a top-level string permission like "allow". + // In that case, path-specific permission entries are unnecessary. + if (typeof config.permission === 'string') { + return; + } + // Ensure permission structure exists - if (!config.permission) { + if (!config.permission || typeof config.permission !== 'object') { config.permission = {}; } @@ -5619,6 +5626,7 @@ if (process.env.GSD_TEST_MODE) { convertClaudeCommandToCodexSkill, convertClaudeToOpencodeFrontmatter, convertClaudeToKiloFrontmatter, + configureOpencodePermissions, neutralizeAgentReferences, GSD_CODEX_MARKER, CODEX_AGENT_SANDBOX, diff --git a/tests/opencode-permissions.test.cjs b/tests/opencode-permissions.test.cjs new file mode 100644 index 000000000..6d1e6ff73 --- /dev/null +++ b/tests/opencode-permissions.test.cjs @@ -0,0 +1,61 @@ +/** + * GSD Tools Tests - OpenCode permission config + * + * Regression tests for OpenCode permission handling, including the valid + * top-level string form: "permission": "allow". + */ + +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 { createTempDir, cleanup } = require('./helpers.cjs'); +const { configureOpencodePermissions } = require('../bin/install.js'); + +const installSrc = fs.readFileSync(path.join(__dirname, '..', 'bin', 'install.js'), 'utf8'); + +describe('configureOpencodePermissions', () => { + let configDir; + + beforeEach(() => { + configDir = createTempDir('gsd-opencode-'); + }); + + afterEach(() => { + cleanup(configDir); + }); + + test('does not rewrite top-level string permissions', () => { + const configPath = path.join(configDir, 'opencode.json'); + const original = JSON.stringify({ + $schema: 'https://opencode.ai/config.json', + permission: 'allow', + skills: { paths: ['/tmp/skills'] }, + }, null, 2) + '\n'; + + fs.writeFileSync(configPath, original); + + assert.doesNotThrow(() => configureOpencodePermissions(true, configDir)); + assert.strictEqual(fs.readFileSync(configPath, 'utf8'), original); + }); + + test('adds path-specific read and external_directory permissions for object configs', () => { + const configPath = path.join(configDir, 'opencode.json'); + fs.writeFileSync(configPath, JSON.stringify({ permission: {} }, null, 2) + '\n'); + + 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'); + }); +}); diff --git a/tests/runtime-converters.test.cjs b/tests/runtime-converters.test.cjs index ec137efff..a6d8209b4 100644 --- a/tests/runtime-converters.test.cjs +++ b/tests/runtime-converters.test.cjs @@ -158,7 +158,8 @@ Fallback skills live in .agents/skills/.`; 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'); + 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'); } }); });