diff --git a/cli/bin/commands/skills.mjs b/cli/bin/commands/skills.mjs index 07fd91e44..952a0a560 100644 --- a/cli/bin/commands/skills.mjs +++ b/cli/bin/commands/skills.mjs @@ -9,7 +9,7 @@ */ import { execSync } from 'node:child_process'; -import { existsSync, readFileSync, readdirSync, statSync, lstatSync, unlinkSync, mkdirSync, writeFileSync, rmSync, renameSync, createWriteStream, realpathSync, symlinkSync, readlinkSync, cpSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync, statSync, lstatSync, unlinkSync, mkdirSync, writeFileSync, rmSync, rmdirSync, renameSync, createWriteStream, realpathSync, symlinkSync, readlinkSync, cpSync } from 'node:fs'; import { join, resolve, dirname, relative, isAbsolute, sep } from 'node:path'; import { createInterface, emitKeypressEvents } from 'node:readline'; import { fileURLToPath } from 'node:url'; @@ -65,11 +65,23 @@ const PROVIDER_DISPLAY = { }; const PROVIDER_INPUT_ORDER = ['claude', 'codex', 'cursor', 'gemini', 'github', 'grok', 'kiro', 'opencode', 'pi', 'qoder', 'trae', 'trae-cn', 'rovo-dev', 'vibe']; -// Providers whose GLOBAL (home) skills dir is not `/skills`. -// Pi discovers global skills from ~/.pi/agent/skills/; project scope -// stays .pi/skills/. See issue #327. +// OpenCode reads global skills from its config directory, not ~/.opencode: +// $OPENCODE_CONFIG_DIR, else $XDG_CONFIG_HOME/opencode, else +// ~/.config/opencode. Writing to ~/.opencode/skills produced an install +// `opencode debug skill` never listed. See issue #406. +function opencodeGlobalConfigDir(home) { + if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR; + if (process.env.XDG_CONFIG_HOME) return join(process.env.XDG_CONFIG_HOME, 'opencode'); + return join(home, '.config', 'opencode'); +} + +// Providers whose GLOBAL (home) skills dir is not `/skills`, +// as a function of the home dir. Pi discovers global skills from +// ~/.pi/agent/skills/ (issue #327); OpenCode from its config dir (issue +// #406). Project scope stays `/skills` for both. const HOME_SKILLS_DIR_OVERRIDES = { - '.pi': join('.pi', 'agent', 'skills'), + '.pi': (home) => join(home, '.pi', 'agent', 'skills'), + '.opencode': (home) => join(opencodeGlobalConfigDir(home), 'skills'), }; // When a project has no harness folder yet, infer the target from globally @@ -83,6 +95,9 @@ const GLOBAL_HARNESS_HINTS = [ { home: '.grok', provider: '.grok' }, { home: '.kiro', provider: '.kiro' }, { home: '.opencode', provider: '.opencode' }, + // OpenCode's real global config dir (issue #406); the ~/.opencode entry + // above keeps recognizing machines that only have the legacy dir. + { resolve: opencodeGlobalConfigDir, provider: '.opencode' }, { home: '.pi', provider: '.pi' }, { home: '.qoder', provider: '.qoder' }, { home: '.rovodev', provider: '.rovodev' }, @@ -134,7 +149,8 @@ const PROVIDER_HOOK_ARTIFACTS = { }; function userProviderSkillsDir(home, provider) { - if (HOME_SKILLS_DIR_OVERRIDES[provider]) return join(home, HOME_SKILLS_DIR_OVERRIDES[provider]); + const override = HOME_SKILLS_DIR_OVERRIDES[provider]; + if (override) return override(home); return join(home, provider, 'skills'); } @@ -861,10 +877,15 @@ function collectInstallDetections(root, home = homedir()) { }); } - for (const { home: h, provider } of GLOBAL_HARNESS_HINTS) { - const foundPath = join(home, h); + for (const hint of GLOBAL_HARNESS_HINTS) { + const { provider } = hint; + // A hint is either a fixed dir under home or a resolver for harnesses + // whose location depends on the environment (OpenCode's config dir). + const foundPath = hint.resolve ? hint.resolve(home) : join(home, hint.home); if (!existsSync(foundPath)) continue; - const skillProbePaths = userSkillProbePaths(home, h, provider); + const skillProbePaths = hint.resolve + ? uniquePaths([userProviderSkillsDir(home, provider), join(foundPath, 'skills')]) + : userSkillProbePaths(home, hint.home, provider); detections.push({ provider, scope: 'user', @@ -1137,6 +1158,20 @@ function copyProviderSkills(bundleDir, root, targets, { scope } = {}) { copyDirSync(src, dest); written++; } + // A pre-#406 global OpenCode install lived at ~/.opencode/skills, a + // location OpenCode never reads. Now that the real copy sits in the + // config dir, drop exactly the skills just written from the stranded + // location; sibling skills and everything else in ~/.opencode stay. + if (scope === 'user' && provider === '.opencode') { + const legacyDir = join(root, '.opencode', 'skills'); + if (legacyDir !== localSkillsDir && existsSync(legacyDir)) { + for (const skill of readdirSync(srcDir, { withFileTypes: true })) { + if (!skill.isDirectory()) continue; + rmSync(join(legacyDir, skill.name), { recursive: true, force: true }); + } + try { rmdirSync(legacyDir); } catch { /* not empty: siblings stay */ } + } + } } } return written; diff --git a/tests/skills-cli.test.js b/tests/skills-cli.test.js index 556afe8d5..8b8826cbc 100644 --- a/tests/skills-cli.test.js +++ b/tests/skills-cli.test.js @@ -884,6 +884,98 @@ describe('skills install/update: local universal bundle e2e', () => { rmSync(home, { recursive: true, force: true }); }, 15000); + // OpenCode reads global skills from its config directory, not ~/.opencode: + // $OPENCODE_CONFIG_DIR/skills, else $XDG_CONFIG_HOME/opencode/skills, else + // ~/.config/opencode/skills. Writing to ~/.opencode/skills produced an + // install `opencode debug skill` never saw (#406). + test('global install writes OpenCode skills to ~/.config/opencode/skills (#406)', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-scope-user-oc-')); + const home = mkdtempSync(join(tmpdir(), 'imp-home-scope-user-oc-')); + execSync('git init', { cwd: tmp }); + mkdirSync(join(home, '.opencode'), { recursive: true }); + const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']); + const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot }; + delete env.OPENCODE_CONFIG_DIR; + delete env.XDG_CONFIG_HOME; + + const output = run('skills install -y --scope=global --no-hooks', { cwd: tmp, env }); + + expect(output).toContain('Installed impeccable into: .opencode (global)'); + expect(existsSync(join(home, '.config', 'opencode', 'skills', 'impeccable', 'SKILL.md'))).toBe(true); + expect(existsSync(join(home, '.opencode', 'skills', 'impeccable'))).toBe(false); + + rmSync(tmp, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + }, 15000); + + test('OpenCode global dir honors OPENCODE_CONFIG_DIR and XDG_CONFIG_HOME (#406)', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-oc-env-')); + const home = mkdtempSync(join(tmpdir(), 'imp-home-oc-env-')); + execSync('git init', { cwd: tmp }); + const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']); + const baseEnv = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot }; + delete baseEnv.OPENCODE_CONFIG_DIR; + delete baseEnv.XDG_CONFIG_HOME; + + run('skills install -y --providers=opencode --scope=global --no-hooks', { + cwd: tmp, + env: { ...baseEnv, OPENCODE_CONFIG_DIR: join(home, 'occfg') }, + }); + expect(existsSync(join(home, 'occfg', 'skills', 'impeccable', 'SKILL.md'))).toBe(true); + + run('skills install -y --providers=opencode --scope=global --no-hooks', { + cwd: tmp, + env: { ...baseEnv, XDG_CONFIG_HOME: join(home, 'xdg') }, + }); + expect(existsSync(join(home, 'xdg', 'opencode', 'skills', 'impeccable', 'SKILL.md'))).toBe(true); + expect(existsSync(join(home, '.opencode', 'skills', 'impeccable'))).toBe(false); + + rmSync(tmp, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + }, 20000); + + test('global OpenCode install migrates a legacy ~/.opencode/skills copy, sparing siblings (#406)', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-oc-migrate-')); + const home = mkdtempSync(join(tmpdir(), 'imp-home-oc-migrate-')); + execSync('git init', { cwd: tmp }); + writeSkill(home, '.opencode', 'impeccable'); + writeSkill(home, '.opencode', 'unrelated-skill'); + const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']); + const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot }; + delete env.OPENCODE_CONFIG_DIR; + delete env.XDG_CONFIG_HOME; + + run('skills install -y --providers=opencode --scope=global --no-hooks', { cwd: tmp, env }); + + expect(existsSync(join(home, '.config', 'opencode', 'skills', 'impeccable', 'SKILL.md'))).toBe(true); + // The stranded legacy copy is gone; the sibling skill is untouched. + expect(existsSync(join(home, '.opencode', 'skills', 'impeccable'))).toBe(false); + expect(existsSync(join(home, '.opencode', 'skills', 'unrelated-skill', 'SKILL.md'))).toBe(true); + + rmSync(tmp, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + }, 15000); + + test('global install detects OpenCode from ~/.config/opencode alone (#406)', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-oc-detect-')); + const home = mkdtempSync(join(tmpdir(), 'imp-home-oc-detect-')); + execSync('git init', { cwd: tmp }); + // No ~/.opencode at all; only the config dir marks OpenCode as present. + mkdirSync(join(home, '.config', 'opencode'), { recursive: true }); + const bundleRoot = createFakeUniversalBundle(tmp, ['.opencode']); + const env = { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot }; + delete env.OPENCODE_CONFIG_DIR; + delete env.XDG_CONFIG_HOME; + + const output = run('skills install -y --scope=global --no-hooks', { cwd: tmp, env }); + + expect(output).toContain('Installed impeccable into: .opencode (global)'); + expect(existsSync(join(home, '.config', 'opencode', 'skills', 'impeccable', 'SKILL.md'))).toBe(true); + + rmSync(tmp, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + }, 15000); + // Project scope must stay at .pi/skills/ even when the git root IS the home // dir (dotfiles repos), where scope can't be inferred from the path alone. // An existing global install at ~/.pi/agent/skills must not swallow the