diff --git a/README.md b/README.md index 5d2ae02d..cfb5189a 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,19 @@ cp -r dist/qoder/.qoder your-project/ cp -r dist/qoder/.qoder/skills/* ~/.qoder/skills/ ``` +### Troubleshooting global installs + +If you used `npx skills add pbakaus/impeccable` with a global install and Claude Code does not see the impeccable commands, check whether the skill landed in `~/.agents/skills/impeccable/` instead of `~/.claude/skills/impeccable/`. The upstream `npx skills` package can route Claude Code installs to the canonical `.agents/skills/` location without creating the expected `~/.claude/skills/` symlink (vercel-labs/skills#851). + +Run the diagnostic to detect and repair the mismatch: + +```bash +npx impeccable skills doctor # report only +npx impeccable skills doctor --fix # symlink ~/.claude/skills/impeccable -> ~/.agents/skills/impeccable +``` + +`impeccable skills install` runs the same check after install and offers to repair on the spot. + ## Usage Once installed, use commands in your AI harness: diff --git a/cli/bin/cli.js b/cli/bin/cli.js index 328420f0..cf2d0cd4 100755 --- a/cli/bin/cli.js +++ b/cli/bin/cli.js @@ -26,6 +26,7 @@ Commands: skills install Install impeccable skills into your project skills update Update skills to the latest version skills check Check if skill updates are available + skills doctor [--fix] Diagnose & repair misrouted global installs Options: --help Show this help message diff --git a/cli/bin/commands/skills.mjs b/cli/bin/commands/skills.mjs index e1e5782b..4d5bbfb0 100644 --- a/cli/bin/commands/skills.mjs +++ b/cli/bin/commands/skills.mjs @@ -14,7 +14,7 @@ import { createInterface } from 'node:readline'; import { fileURLToPath } from 'node:url'; import { get } from 'node:https'; import { createHash } from 'node:crypto'; -import { tmpdir } from 'node:os'; +import { tmpdir, homedir } from 'node:os'; const __dirname = dirname(fileURLToPath(import.meta.url)); const API_BASE = 'https://impeccable.style'; @@ -44,6 +44,7 @@ async function showHelp() { console.log('\n Impeccable Skills & Commands\n'); console.log(' Install: npx impeccable skills install'); console.log(' Update: npx impeccable skills update'); + console.log(' Doctor: npx impeccable skills doctor [--fix]'); console.log(' Docs: https://impeccable.style/cheatsheet\n'); console.log(` ${pad('Command', 22)} Description`); console.log(` ${'-'.repeat(22)} ${'-'.repeat(52)}`); @@ -374,6 +375,11 @@ async function install(flags) { // Cleanup script not available -- skip } + // Detect and offer to repair the upstream `npx skills` routing bug where + // global installs land in ~/.agents/skills/ but Claude Code reads from + // ~/.claude/skills/. See vercel-labs/skills#851. + await verifyGlobalRoutingAfterInstall({ yes }); + console.log(`\nDone! Run /${prefix}impeccable teach in your AI harness to set up design context.\n`); } @@ -627,6 +633,202 @@ function copyDirSync(src, dest) { } } +// ─── skills doctor ─────────────────────────────────────────────────────────── + +/** + * The upstream `npx skills` package routes "Universal" agents (Cursor, Codex, + * Cline, Copilot, ...) to ~/.agents/skills/, while Claude Code reads from + * ~/.claude/skills/. When the interactive prompt installs only universal + * agents (vercel-labs/skills#851), the impeccable skill ends up in + * ~/.agents/skills/impeccable/ and Claude Code never sees it. Doctor detects + * that mismatch and offers to symlink ~/.claude/skills/ back to the + * canonical copy. + */ + +function isImpeccableSkillName(name) { + return ( + name === 'impeccable' || + name === 'teach-impeccable' || + name.endsWith('-impeccable') || + name.endsWith('-teach-impeccable') + ); +} + +function findGlobalImpeccableInstalls(home = homedir()) { + const found = []; + for (const provider of PROVIDER_DIRS) { + const skillsDir = join(home, provider, 'skills'); + if (!existsSync(skillsDir)) continue; + let entries; + try { + entries = readdirSync(skillsDir); + } catch { + continue; + } + for (const name of entries) { + if (!isImpeccableSkillName(name)) continue; + const path = join(skillsDir, name); + let isLink = false; + let target = null; + try { + const ls = lstatSync(path); + isLink = ls.isSymbolicLink(); + if (isLink) target = readlinkSync(path); + } catch {} + found.push({ provider, name, path, isSymlink: isLink, target }); + } + } + return found; +} + +/** + * Detect routing issues for global installs. Currently checks for the + * canonical bug: `` exists under ~/.agents/skills but not under + * ~/.claude/skills, which makes Claude Code blind to it. + * + * Returns { issues: Array<{ severity, message, source, target }> }. + */ +function diagnoseGlobalRouting(installs, home = homedir()) { + const issues = []; + const byName = new Map(); + for (const inst of installs) { + if (!byName.has(inst.name)) byName.set(inst.name, []); + byName.get(inst.name).push(inst); + } + for (const [name, list] of byName) { + const inAgents = list.find((i) => i.provider === '.agents'); + const inClaude = list.find((i) => i.provider === '.claude'); + if (inAgents && !inClaude) { + issues.push({ + severity: 'critical', + name, + message: `${join('~', '.agents', 'skills', name)} exists, but ${join('~', '.claude', 'skills', name)} is missing — Claude Code reads from ~/.claude/skills/, so it cannot see this skill.`, + source: inAgents.path, + target: join(home, '.claude', 'skills', name), + }); + } + } + return { issues }; +} + +function applyRepairs(issues) { + const results = []; + for (const issue of issues) { + try { + mkdirSync(dirname(issue.target), { recursive: true }); + // If target already exists (rare race), leave it alone. + if (existsSync(issue.target)) { + results.push({ issue, ok: true, skipped: true }); + continue; + } + symlinkSync(issue.source, issue.target); + results.push({ issue, ok: true }); + } catch (e) { + results.push({ issue, ok: false, error: e.message }); + } + } + return results; +} + +async function doctor(flags) { + const fix = flags.includes('--fix'); + const yes = flags.includes('-y') || flags.includes('--yes'); + const home = homedir(); + + const installs = findGlobalImpeccableInstalls(home); + if (installs.length === 0) { + console.log('No global impeccable installs found in ~/. Nothing to check.'); + return; + } + + console.log('Global impeccable installs:'); + for (const inst of installs) { + const kind = inst.isSymlink ? `symlink → ${inst.target}` : 'real dir'; + console.log(` ${inst.path} (${kind})`); + } + + const { issues } = diagnoseGlobalRouting(installs, home); + if (issues.length === 0) { + console.log('\nAll routing looks good.'); + return; + } + + console.log(`\nFound ${issues.length} routing issue(s):`); + for (const issue of issues) { + console.log(` [${issue.severity}] ${issue.message}`); + console.log(` suggested: symlink ${issue.target} -> ${issue.source}`); + } + + if (!fix) { + console.log('\nRun `impeccable skills doctor --fix` to apply repairs.'); + return; + } + + // In non-interactive environments (CI, scripts, tests) treat --fix as + // explicit consent. Only prompt when we have a real TTY and the user + // didn't pass -y. + const interactive = !!process.stdin.isTTY; + let proceed = yes || !interactive; + if (!proceed) { + const ans = await ask('\nApply repairs? (Y/n) '); + proceed = ans !== 'n' && ans !== 'no'; + } + if (!proceed) return; + + const results = applyRepairs(issues); + for (const r of results) { + if (r.ok && r.skipped) { + console.log(` Skipped (already present): ${r.issue.target}`); + } else if (r.ok) { + console.log(` Linked: ${r.issue.target} -> ${r.issue.source}`); + } else { + console.error(` Failed: ${r.issue.target}: ${r.error}`); + } + } +} + +/** + * Best-effort post-install check. Called from install() after the upstream + * `npx skills add` finishes. If we detect the bug, warn or auto-repair + * depending on -y mode. Errors here never break the install flow. + */ +async function verifyGlobalRoutingAfterInstall({ yes }) { + try { + const home = homedir(); + const installs = findGlobalImpeccableInstalls(home); + if (installs.length === 0) return; + const { issues } = diagnoseGlobalRouting(installs, home); + if (issues.length === 0) return; + + console.log('\nWarning: detected a known routing issue from `npx skills` (vercel-labs/skills#851):'); + for (const issue of issues) { + console.log(` ${issue.message}`); + } + + const interactive = !!process.stdin.isTTY; + let proceed = yes || !interactive; + if (!proceed) { + const ans = await ask('Symlink ~/.claude/skills/ to the canonical copy? (Y/n) '); + proceed = ans !== 'n' && ans !== 'no'; + } + if (!proceed) { + console.log('Skipped. Run `npx impeccable skills doctor --fix` later if Claude Code does not see the skill.'); + return; + } + + const results = applyRepairs(issues); + for (const r of results) { + if (r.ok && !r.skipped) { + console.log(` Linked: ${r.issue.target} -> ${r.issue.source}`); + } else if (!r.ok) { + console.error(` Failed: ${r.issue.target}: ${r.error}`); + } + } + } catch { + // Diagnostic step — never fail the install on its account. + } +} + // ─── Router ─────────────────────────────────────────────────────────────────── export async function run(args) { @@ -640,6 +842,8 @@ export async function run(args) { await update(args.slice(1)); } else if (sub === 'check') { await check(); + } else if (sub === 'doctor') { + await doctor(args.slice(1)); } else { console.error(`Unknown skills command: ${sub}`); console.error(`Run 'impeccable skills --help' for available commands.`); diff --git a/tests/skills-doctor.test.js b/tests/skills-doctor.test.js new file mode 100644 index 00000000..1b598c3f --- /dev/null +++ b/tests/skills-doctor.test.js @@ -0,0 +1,117 @@ +/** + * Tests for `impeccable skills doctor` subcommand. + * + * Diagnoses misrouted global installs: when the upstream `npx skills` + * lands the impeccable skill in `~/.agents/skills/` but Claude Code + * needs `~/.claude/skills/`, doctor detects the mismatch and (with + * `--fix`) creates the missing symlink. See vercel-labs/skills#851. + */ +import { describe, test, expect } from 'bun:test'; +import { execSync } from 'child_process'; +import { + mkdtempSync, + existsSync, + mkdirSync, + writeFileSync, + rmSync, + lstatSync, + readlinkSync, +} from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +const CLI = join(import.meta.dir, '..', 'cli', 'bin', 'cli.js'); + +function run(args, opts = {}) { + return execSync(`node ${CLI} ${args}`, { + encoding: 'utf8', + timeout: 30000, + ...opts, + }); +} + +function makeSkill(home, providerDir, name = 'impeccable') { + const dir = join(home, providerDir, 'skills', name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'SKILL.md'), '---\nname: impeccable\n---\n'); + return dir; +} + +describe('skills doctor — diagnose mode', () => { + test('reports clean state when no global impeccable installs exist', () => { + const home = mkdtempSync(join(tmpdir(), 'imp-doctor-clean-')); + const out = run('skills doctor', { env: { ...process.env, HOME: home } }); + expect(out).toMatch(/no.*impeccable.*found|nothing to check/i); + rmSync(home, { recursive: true, force: true }); + }); + + test('flags missing ~/.claude/skills/impeccable when ~/.agents/skills/impeccable exists', () => { + const home = mkdtempSync(join(tmpdir(), 'imp-doctor-flag-')); + makeSkill(home, '.agents'); + + const out = run('skills doctor', { env: { ...process.env, HOME: home } }); + + expect(out).toContain('.agents/skills/impeccable'); + expect(out).toMatch(/claude code|\.claude\/skills/i); + expect(out).toMatch(/--fix/i); + + // Without --fix, no repair. + expect(existsSync(join(home, '.claude', 'skills', 'impeccable'))).toBe(false); + + rmSync(home, { recursive: true, force: true }); + }); + + test('reports clean when both ~/.agents and ~/.claude already have impeccable', () => { + const home = mkdtempSync(join(tmpdir(), 'imp-doctor-both-')); + makeSkill(home, '.agents'); + makeSkill(home, '.claude'); + + const out = run('skills doctor', { env: { ...process.env, HOME: home } }); + expect(out).not.toMatch(/cannot see|missing/i); + + rmSync(home, { recursive: true, force: true }); + }); +}); + +describe('skills doctor --fix', () => { + test('creates symlink from ~/.claude/skills/impeccable to ~/.agents/skills/impeccable', () => { + const home = mkdtempSync(join(tmpdir(), 'imp-doctor-fix-')); + const source = makeSkill(home, '.agents'); + + run('skills doctor --fix', { env: { ...process.env, HOME: home } }); + + const target = join(home, '.claude', 'skills', 'impeccable'); + expect(existsSync(target)).toBe(true); + expect(lstatSync(target).isSymbolicLink()).toBe(true); + expect(readlinkSync(target)).toBe(source); + + rmSync(home, { recursive: true, force: true }); + }); + + test('handles prefixed skill names (e.g. i-impeccable)', () => { + const home = mkdtempSync(join(tmpdir(), 'imp-doctor-prefix-')); + const source = makeSkill(home, '.agents', 'i-impeccable'); + + run('skills doctor --fix', { env: { ...process.env, HOME: home } }); + + const target = join(home, '.claude', 'skills', 'i-impeccable'); + expect(existsSync(target)).toBe(true); + expect(lstatSync(target).isSymbolicLink()).toBe(true); + expect(readlinkSync(target)).toBe(source); + + rmSync(home, { recursive: true, force: true }); + }); + + test('is idempotent: running --fix twice does not error', () => { + const home = mkdtempSync(join(tmpdir(), 'imp-doctor-idem-')); + makeSkill(home, '.agents'); + + run('skills doctor --fix', { env: { ...process.env, HOME: home } }); + run('skills doctor --fix', { env: { ...process.env, HOME: home } }); + + const target = join(home, '.claude', 'skills', 'impeccable'); + expect(existsSync(target)).toBe(true); + + rmSync(home, { recursive: true, force: true }); + }); +});