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
+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