Install global OpenCode skills into the config dir OpenCode actually reads

npx impeccable install --providers=opencode --scope=global wrote to
~/.opencode/skills, but OpenCode discovers global skills from its config
directory: $OPENCODE_CONFIG_DIR/skills, else $XDG_CONFIG_HOME/opencode/
skills, else ~/.config/opencode/skills. The install succeeded and
`opencode debug skill` never listed it (issue #406, diagnosed by
@dergachoff).

HOME_SKILLS_DIR_OVERRIDES entries become functions of the home dir (the
Pi override from #327 was the only entry and is unchanged in behavior),
with OpenCode resolving through the env chain above. Detection gains a
resolver-based GLOBAL_HARNESS_HINTS entry so a machine with only
~/.config/opencode (no legacy ~/.opencode) still routes global installs
to OpenCode. After a global install, the skills just written are removed
from the stranded ~/.opencode/skills location; sibling skills and the
rest of ~/.opencode stay untouched, and the empty skills dir is pruned.

Four new CLI tests (failing-first): default config-dir install,
OPENCODE_CONFIG_DIR and XDG_CONFIG_HOME precedence, legacy-copy
migration with sibling preservation, and config-dir-only detection.

Prepared with AI assistance (Claude Code), directed by @pbakaus.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-25 18:54:59 -07:00
co-authored by Claude Code
parent af78b1e512
commit caef4b8e4c
2 changed files with 136 additions and 9 deletions
+44 -9
View File
@@ -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 `<provider>/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 `<provider>/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 `<provider>/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;
+92
View File
@@ -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