Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions cli/bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
206 changes: 205 additions & 1 deletion cli/bin/commands/skills.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)}`);
Expand Down Expand Up @@ -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`);
}

Expand Down Expand Up @@ -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/<name> 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: `<name>` 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/<name> 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) {
Expand All @@ -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.`);
Expand Down
117 changes: 117 additions & 0 deletions tests/skills-doctor.test.js
Original file line number Diff line number Diff line change
@@ -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 });
});
});