diff --git a/README.md b/README.md index 0f9830859..a4eaacb9c 100644 --- a/README.md +++ b/README.md @@ -105,11 +105,31 @@ This auto-detects your harness and writes the build compiled for it to the right Claude Code users can alternatively install the plugin with `/plugin marketplace add pbakaus/impeccable`. The general-purpose `npx skills add pbakaus/impeccable` also works, though it installs one shared build for all harnesses rather than the one compiled for yours. -### Option 2: Download from Website +### Option 2: Git Submodule + +For teams that want to keep Impeccable vendored and updated through Git, add this repo as a submodule and link the compiled provider build into your harness folders: + +```bash +git submodule add https://github.com/pbakaus/impeccable .impeccable +npx impeccable skills link --source=.impeccable --providers=claude,cursor +git add .gitmodules .impeccable .claude .cursor +git commit -m "Add Impeccable skills" +``` + +Use the providers your project needs, for example `claude`, `cursor`, `gemini`, `codex`, `github`, `opencode`, `pi`, `qoder`, `trae`, `trae-cn`, or `rovo-dev`. The command links individual skill folders from `.impeccable/dist/universal/` and leaves existing real skill directories untouched unless you pass `--force`. + +To update later: + +```bash +git submodule update --remote .impeccable +npx impeccable skills link --source=.impeccable --providers=claude,cursor +``` + +### Option 3: Download from Website Visit [impeccable.style](https://impeccable.style), download the ZIP for your tool, and extract to your project. -### Option 3: Copy from Repository +### Option 4: Copy from Repository **Cursor:** ```bash diff --git a/README.npm.md b/README.npm.md index 254e63913..7e3cfaa32 100644 --- a/README.npm.md +++ b/README.npm.md @@ -11,6 +11,9 @@ npx impeccable skills install # Update skills to the latest version npx impeccable skills update +# Link skills from a Git submodule checkout +npx impeccable skills link --source=.impeccable --providers=claude,cursor + # List all available commands npx impeccable skills help diff --git a/cli/bin/cli.js b/cli/bin/cli.js index 328420f0e..54c1bd6fd 100755 --- a/cli/bin/cli.js +++ b/cli/bin/cli.js @@ -24,6 +24,7 @@ Commands: detect [file-or-dir-or-url...] Scan for UI anti-patterns and design quality issues skills help List all available skills and commands skills install Install impeccable skills into your project + skills link Symlink skills from a local checkout or submodule skills update Update skills to the latest version skills check Check if skill updates are available diff --git a/cli/bin/commands/skills.mjs b/cli/bin/commands/skills.mjs index 29d153ae6..8b727940f 100644 --- a/cli/bin/commands/skills.mjs +++ b/cli/bin/commands/skills.mjs @@ -4,12 +4,13 @@ * Usage: * impeccable skills help Show all available skills and commands * impeccable skills install Install compiled skills from the universal bundle + * impeccable skills link Symlink compiled skills from a local checkout * impeccable skills update Update skills to latest version */ import { execSync } from 'node:child_process'; -import { existsSync, readFileSync, readdirSync, statSync, lstatSync, unlinkSync, mkdirSync, writeFileSync, rmSync, renameSync, createWriteStream, realpathSync } from 'node:fs'; -import { join, resolve, dirname } from 'node:path'; +import { existsSync, readFileSync, readdirSync, statSync, lstatSync, unlinkSync, mkdirSync, writeFileSync, rmSync, renameSync, createWriteStream, realpathSync, symlinkSync, readlinkSync } from 'node:fs'; +import { join, resolve, dirname, relative, isAbsolute } from 'node:path'; import { createInterface } from 'node:readline'; import { fileURLToPath } from 'node:url'; import { get } from 'node:https'; @@ -21,7 +22,25 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const API_BASE = 'https://impeccable.style'; // Provider folder names in project roots -const PROVIDER_DIRS = ['.claude', '.cursor', '.gemini', '.agents', '.github', '.kiro', '.opencode', '.pi', '.qoder', '.trae', '.trae-cn']; +const PROVIDER_DIRS = ['.claude', '.cursor', '.gemini', '.agents', '.github', '.kiro', '.opencode', '.pi', '.qoder', '.trae', '.trae-cn', '.rovodev']; +const PROVIDER_ALIASES = { + agents: '.agents', + claude: '.claude', + 'claude-code': '.claude', + codex: '.agents', + copilot: '.github', + cursor: '.cursor', + gemini: '.gemini', + github: '.github', + kiro: '.kiro', + opencode: '.opencode', + pi: '.pi', + qoder: '.qoder', + 'rovo-dev': '.rovodev', + rovodev: '.rovodev', + trae: '.trae', + 'trae-cn': '.trae-cn', +}; // When a project has no harness folder yet, infer the target from globally // installed harnesses (~/.claude, ~/.codex, ...). Codex reads skills from @@ -34,6 +53,7 @@ const GLOBAL_HARNESS_HINTS = [ { home: '.kiro', provider: '.kiro' }, { home: '.opencode', provider: '.opencode' }, { home: '.qoder', provider: '.qoder' }, + { home: '.rovodev', provider: '.rovodev' }, ]; // Last-resort default when nothing is detected: Claude Code + the universal @@ -61,6 +81,7 @@ async function showHelp() { console.log('\n Impeccable Skills & Commands\n'); console.log(' Install: npx impeccable skills install'); + console.log(' Link: npx impeccable skills link --source=.impeccable'); console.log(' Update: npx impeccable skills update'); console.log(' Docs: https://impeccable.style/cheatsheet\n'); console.log(` ${pad('Command', 22)} Description`); @@ -291,6 +312,25 @@ function migrateUnprefixImpeccable(root) { return migrated; } +function getFlagValue(flags, name) { + const prefix = `${name}=`; + const inline = flags.find(f => f.startsWith(prefix)); + if (inline) return inline.slice(prefix.length); + const index = flags.indexOf(name); + if (index !== -1 && flags[index + 1] && !flags[index + 1].startsWith('-')) { + return flags[index + 1]; + } + return null; +} + +function normalizeProviderName(value) { + const raw = String(value || '').trim(); + if (!raw) return null; + if (PROVIDER_DIRS.includes(raw)) return raw; + const key = raw.replace(/^\./, '').toLowerCase(); + return PROVIDER_ALIASES[key] || null; +} + /** * Decide which provider folders to install into. * 1. An explicit --providers=.claude,.cursor list wins. @@ -304,8 +344,8 @@ function resolveInstallTargets(root, providersValue) { .split(',') .map(s => s.trim()) .filter(Boolean) - .map(s => (s.startsWith('.') ? s : `.${s}`)) - .filter(p => PROVIDER_DIRS.includes(p)); + .map(normalizeProviderName) + .filter(Boolean); return [...new Set(wanted)]; } @@ -351,10 +391,144 @@ function copyProviderSkills(bundleDir, root, targets) { return written; } +function resolveLinkSource(sourceValue, root) { + const sourcePath = sourceValue || '.impeccable'; + const checkoutRoot = isAbsolute(sourcePath) ? sourcePath : resolve(root, sourcePath); + const universalRoot = join(checkoutRoot, 'dist', 'universal'); + if (existsSync(universalRoot)) { + return { checkoutRoot, bundleRoot: universalRoot }; + } + if (PROVIDER_DIRS.some(provider => existsSync(join(checkoutRoot, provider, 'skills')))) { + return { checkoutRoot, bundleRoot: checkoutRoot }; + } + throw new Error(`Could not find compiled skills in ${sourcePath}. Expected dist/universal/ or provider skill folders.`); +} + +function pathExistsOrLink(path) { + try { + lstatSync(path); + return true; + } catch { + return false; + } +} + +function isSymlinkTo(dest, expectedSource) { + try { + if (!lstatSync(dest).isSymbolicLink()) return false; + const target = readlinkSync(dest); + const resolvedTarget = resolve(dirname(dest), target); + return realpathSync(resolvedTarget) === realpathSync(expectedSource); + } catch { + return false; + } +} + +function resolveUniqueLinkTargets(root, targets) { + const seen = new Set(); + const unique = []; + for (const provider of targets) { + const localSkillsDir = join(root, provider, 'skills'); + mkdirSync(localSkillsDir, { recursive: true }); + const real = realpathSync(localSkillsDir); + if (seen.has(real)) continue; + seen.add(real); + unique.push({ provider, localSkillsDir }); + } + return unique; +} + +function linkProviderSkills(bundleRoot, root, targets, { force = false } = {}) { + let linked = 0; + let already = 0; + let skipped = 0; + + for (const { provider, localSkillsDir } of resolveUniqueLinkTargets(root, targets)) { + const srcDir = join(bundleRoot, provider, 'skills'); + if (!existsSync(srcDir)) continue; + + for (const skill of readdirSync(srcDir, { withFileTypes: true })) { + if (!skill.isDirectory()) continue; + const src = join(srcDir, skill.name); + const dest = join(localSkillsDir, skill.name); + + if (pathExistsOrLink(dest)) { + if (isSymlinkTo(dest, src)) { + already++; + continue; + } + if (!force) { + console.warn(`Skipped existing ${provider}/skills/${skill.name}. Use --force to replace it with a link.`); + skipped++; + continue; + } + rmSync(dest, { recursive: true, force: true }); + } + + const target = relative(dirname(dest), src) || '.'; + symlinkSync(target, dest, 'dir'); + linked++; + } + } + + return { linked, already, skipped }; +} + +async function link(flags) { + const force = flags.includes('--force'); + const yes = flags.includes('-y') || flags.includes('--yes'); + const sourceValue = getFlagValue(flags, '--source'); + const providersValue = getFlagValue(flags, '--providers'); + const root = findProjectRoot(); + + let source; + try { + source = resolveLinkSource(sourceValue, root); + } catch (e) { + console.error(e.message); + process.exit(1); + } + + const targets = resolveInstallTargets(root, providersValue); + if (targets.length === 0) { + console.error('Could not determine a target harness folder.'); + console.error('Pass one explicitly, e.g. --providers=claude,cursor'); + process.exit(1); + } + + if (!yes) { + console.log(`Source checkout: ${source.checkoutRoot}`); + console.log(`Target harness folder(s): ${targets.join(', ')}`); + const ans = await ask(`Link impeccable skills into ${targets.length} folder(s)? (Y/n) `); + if (ans === 'n' || ans === 'no') { + console.log('Aborted. Re-run with --providers= to choose explicitly (e.g. --providers=claude,cursor).'); + process.exit(0); + } + } + + const result = linkProviderSkills(source.bundleRoot, root, targets, { force }); + if (result.linked === 0 && result.already === 0) { + if (result.skipped > 0) { + console.error('Nothing was linked because matching skill folders already exist.'); + console.error('Existing skills were left untouched. Re-run with --force to replace them with links.'); + } else { + console.error(`Nothing was linked: ${source.bundleRoot} had no variants for ${targets.join(', ')}.`); + } + process.exit(1); + } + + const parts = []; + if (result.linked > 0) parts.push(`${result.linked} linked`); + if (result.already > 0) parts.push(`${result.already} already linked`); + if (result.skipped > 0) parts.push(`${result.skipped} skipped`); + console.log(`Linked impeccable into: ${targets.join(', ')} (${parts.join(', ')}).`); + console.log('Update with `git submodule update --remote` from your project root, then rerun this command if new skills are added.\n'); +} + async function install(flags) { const force = flags.includes('--force'); const yes = flags.includes('-y') || flags.includes('--yes'); - const providersFlag = flags.find(f => f.startsWith('--providers=')); + const providersValue = getFlagValue(flags, '--providers'); const root = findProjectRoot(); const existing = isAlreadyInstalled(root); @@ -369,7 +543,7 @@ async function install(flags) { // to `npx skills add`: its name-based discovery can install the uncompiled // source, and its symlink default points every harness at one shared variant. // Copying per-provider variants is the only correct install for this skill. - const targets = resolveInstallTargets(root, providersFlag ? providersFlag.split('=')[1] : null); + const targets = resolveInstallTargets(root, providersValue); if (targets.length === 0) { console.error('Could not determine a target harness folder.'); console.error('Pass one explicitly, e.g. --providers=.claude,.cursor'); @@ -453,6 +627,17 @@ function findInstalledProviders(root) { return found; } +function findLinkedProviders(root, providers) { + return providers.filter(provider => { + const skillDir = join(root, provider, 'skills', 'impeccable'); + try { + return lstatSync(skillDir).isSymbolicLink(); + } catch { + return false; + } + }); +} + function getModifiedSkillFiles(root, providerDirs) { // Use git to check if any skill files have local modifications const modified = []; @@ -517,6 +702,8 @@ async function update(flags = []) { // (vercel-labs/skills#775) where it can't find the lock file. const root = findProjectRoot(); const providers = findInstalledProviders(root); + const linkedProviders = findLinkedProviders(root, providers); + const copyProviders = providers.filter(provider => !linkedProviders.includes(provider)); if (providers.length === 0) { console.log('No impeccable skill folders found in this project.'); @@ -524,6 +711,13 @@ async function update(flags = []) { process.exit(1); } + if (linkedProviders.length > 0) { + console.log(`Linked skills found in: ${linkedProviders.join(', ')}`); + console.log('Update the source checkout with `git submodule update --remote`, then rerun `npx impeccable skills link --source=.impeccable` if new skills are added.'); + if (copyProviders.length === 0) process.exit(0); + console.log(`Continuing with copied installs in: ${copyProviders.join(', ')}\n`); + } + console.log('Checking for updates...'); let tmpDir; @@ -535,17 +729,17 @@ async function update(flags = []) { } // Compare local vs remote -- skip if already up to date - if (isUpToDate(root, providers, tmpDir)) { + if (isUpToDate(root, copyProviders, tmpDir)) { rmSync(tmpDir, { recursive: true, force: true }); const v = getSkillsVersion(root); console.log(`Skills are up to date${v ? ` (v${v})` : ''}. Nothing to do.`); process.exit(0); } - console.log(`Found skills in: ${providers.join(', ')}`); + console.log(`Found skills in: ${copyProviders.join(', ')}`); if (!yes) { - const ans = await ask(`Update skills in ${providers.length} provider folder(s)? (Y/n) `); + const ans = await ask(`Update skills in ${copyProviders.length} provider folder(s)? (Y/n) `); if (ans === 'n' || ans === 'no') { rmSync(tmpDir, { recursive: true, force: true }); console.log('Aborted.'); @@ -563,7 +757,7 @@ async function update(flags = []) { // Copy from the bundle to each unique provider folder. // Deduplicate so symlinked dirs (e.g. .claude/skills -> .agents/skills) // are only written once with the correct provider's content. - const unique = deduplicateProviders(root, providers); + const unique = deduplicateProviders(root, copyProviders); let updated = 0; for (const { provider, localSkillsDir } of unique) { const srcDir = join(tmpDir, provider, 'skills'); @@ -616,7 +810,7 @@ function copyDirSync(src, dest) { // ─── Test surface ─────────────────────────────────────────────────────────── // Exported so the test suite exercises the real implementation rather than a // reimplementation in a helper script (which is how bugs slip through). -export { migrateUnprefixImpeccable }; +export { migrateUnprefixImpeccable, linkProviderSkills, resolveLinkSource }; // ─── Router ─────────────────────────────────────────────────────────────────── @@ -627,6 +821,8 @@ export async function run(args) { await showHelp(); } else if (sub === 'install') { await install(args.slice(1)); + } else if (sub === 'link') { + await link(args.slice(1)); } else if (sub === 'update') { await update(args.slice(1)); } else if (sub === 'check') { diff --git a/tests/skills-cli.test.js b/tests/skills-cli.test.js index 41309813e..0dd55a37f 100644 --- a/tests/skills-cli.test.js +++ b/tests/skills-cli.test.js @@ -10,7 +10,7 @@ */ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { execSync } from 'child_process'; -import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, rmSync } from 'fs'; +import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, rmSync, lstatSync, realpathSync, readlinkSync, symlinkSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { migrateUnprefixImpeccable } from '../cli/bin/commands/skills.mjs'; @@ -51,6 +51,12 @@ function writeSkill(root, provider, name) { writeFileSync(join(dir, 'SKILL.md'), `---\nname: ${name}\n---\nRun /${name}.\n`); } +function createFakeLinkSource(root, providers = ['.claude']) { + for (const provider of providers) { + writeSkill(join(root, '.impeccable', 'dist', 'universal'), provider, 'impeccable'); + } +} + /** * Simulate an install from the era when the CLI offered a command prefix: the * skill lives at `impeccable`. Optionally drop in a third-party skill @@ -106,6 +112,109 @@ describe('skills install: already-installed detection', () => { }, 15000); }); +// ─── Submodule/link installs ──────────────────────────────────────────────── + +describe('skills link: submodule installs', () => { + test('creates relative skill symlinks from dist/universal', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-')); + execSync('git init', { cwd: tmp }); + createFakeLinkSource(tmp, ['.claude', '.cursor']); + + const output = run('skills link --source=.impeccable --providers=claude,cursor -y', { cwd: tmp }); + expect(output).toContain('Linked impeccable into: .claude, .cursor'); + + for (const provider of ['.claude', '.cursor']) { + const dest = join(tmp, provider, 'skills', 'impeccable'); + const src = join(tmp, '.impeccable', 'dist', 'universal', provider, 'skills', 'impeccable'); + expect(lstatSync(dest).isSymbolicLink()).toBe(true); + expect(readlinkSync(dest).startsWith('/')).toBe(false); + expect(realpathSync(dest)).toBe(realpathSync(src)); + } + + rmSync(tmp, { recursive: true, force: true }); + }, 15000); + + test('is idempotent when links already point at the same source', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-again-')); + execSync('git init', { cwd: tmp }); + createFakeLinkSource(tmp); + + run('skills link --source=.impeccable --providers=claude -y', { cwd: tmp }); + const before = readlinkSync(join(tmp, '.claude', 'skills', 'impeccable')); + const output = run('skills link --source=.impeccable --providers=claude -y', { cwd: tmp }); + + expect(output).toContain('already linked'); + expect(readlinkSync(join(tmp, '.claude', 'skills', 'impeccable'))).toBe(before); + + rmSync(tmp, { recursive: true, force: true }); + }, 15000); + + test('does not overwrite an existing real skill unless forced', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-existing-')); + execSync('git init', { cwd: tmp }); + createFakeLinkSource(tmp); + writeSkill(tmp, '.claude', 'impeccable'); + + expect(() => run('skills link --source=.impeccable --providers=claude -y', { cwd: tmp })).toThrow(); + const dest = join(tmp, '.claude', 'skills', 'impeccable'); + expect(lstatSync(dest).isSymbolicLink()).toBe(false); + + const output = run('skills link --source=.impeccable --providers=claude -y --force', { cwd: tmp }); + expect(output).toContain('1 linked'); + expect(lstatSync(dest).isSymbolicLink()).toBe(true); + + rmSync(tmp, { recursive: true, force: true }); + }, 15000); + + test('maps codex and rovo-dev provider aliases to their install folders', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-alias-')); + execSync('git init', { cwd: tmp }); + createFakeLinkSource(tmp, ['.agents', '.rovodev']); + + run('skills link --source=.impeccable --providers=codex,rovo-dev -y', { cwd: tmp }); + + expect(lstatSync(join(tmp, '.agents', 'skills', 'impeccable')).isSymbolicLink()).toBe(true); + expect(lstatSync(join(tmp, '.rovodev', 'skills', 'impeccable')).isSymbolicLink()).toBe(true); + + rmSync(tmp, { recursive: true, force: true }); + }, 15000); + + test('skills update leaves linked installs on the submodule path', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-update-')); + execSync('git init', { cwd: tmp }); + createFakeLinkSource(tmp); + run('skills link --source=.impeccable --providers=claude -y', { cwd: tmp }); + + const dest = join(tmp, '.claude', 'skills', 'impeccable'); + const before = readlinkSync(dest); + const output = run('skills update -y', { cwd: tmp }); + + expect(output).toContain('Linked skills found in: .claude'); + expect(readlinkSync(dest)).toBe(before); + expect(lstatSync(dest).isSymbolicLink()).toBe(true); + + rmSync(tmp, { recursive: true, force: true }); + }, 15000); + + test('deduplicates providers that share one skills directory', () => { + const tmp = mkdtempSync(join(tmpdir(), 'imp-test-link-shared-')); + execSync('git init', { cwd: tmp }); + createFakeLinkSource(tmp, ['.claude', '.agents']); + mkdirSync(join(tmp, '.agents', 'skills'), { recursive: true }); + mkdirSync(join(tmp, '.claude'), { recursive: true }); + symlinkSync('../.agents/skills', join(tmp, '.claude', 'skills'), 'dir'); + + run('skills link --source=.impeccable --providers=claude,codex -y', { cwd: tmp }); + + const dest = join(tmp, '.agents', 'skills', 'impeccable'); + const src = join(tmp, '.impeccable', 'dist', 'universal', '.claude', 'skills', 'impeccable'); + expect(lstatSync(dest).isSymbolicLink()).toBe(true); + expect(realpathSync(dest)).toBe(realpathSync(src)); + + rmSync(tmp, { recursive: true, force: true }); + }, 15000); +}); + // ─── Unprefix migration (real implementation, real filesystem) ─────────────── // // The CLI no longer offers a command prefix (the `i-` rename only made sense