mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
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:
co-authored by
Claude Code
parent
af78b1e512
commit
caef4b8e4c
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user