From 239d69d0d8843dd1b7d726e17b6a59a955701223 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Fri, 10 Apr 2026 10:39:16 -0700 Subject: [PATCH] Add skills check command, skip-if-up-to-date in update, decouple versioning CLI changes (bumped to v2.1.2, skills stay at v2.1.1): - `npx impeccable skills check` compares local skill files against the latest bundle and reports whether updates are available - `npx impeccable skills update` now downloads the bundle first, compares hashes, and skips with "up to date" if nothing changed - Removed the local-modifications warning (was confusing for users who installed via npx skills add) Versioning: - CLI (package.json), skills (plugin.json/marketplace.json), and Chrome extension (manifest.json) are now versioned independently - CLAUDE.md updated to document when to bump each Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 17 +++- bin/cli.js | 1 + bin/commands/skills.mjs | 179 +++++++++++++++++++++++++++++----------- package.json | 2 +- 4 files changed, 149 insertions(+), 50 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 291b8991f..e95cff647 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,12 +72,25 @@ bun run build:browser ## Versioning -When bumping the version, update **all** of these locations to keep them in sync: +There are three independently versioned components. Only bump the one(s) that actually changed: +**CLI** (npm package): - `package.json` → `version` +- Bump when: CLI code changes (`bin/`, `src/detect-antipatterns.mjs`, etc.) + +**Skills** (Claude Code plugin / skill definitions): - `.claude-plugin/plugin.json` → `version` - `.claude-plugin/marketplace.json` → `plugins[0].version` -- `public/index.html` → hero version link text + new changelog entry (user-facing changes only, not internal build/tooling details) +- Bump when: skill content changes (`source/skills/`, skill count changes, etc.) + +**Chrome extension**: +- `extension/manifest.json` → `version` +- Bump when: extension code changes (`extension/`) + +**Website changelog** (`public/index.html`): +- Hero version link text + new changelog entry +- Update for user-facing changes only, not internal build/tooling details +- Use the most prominent version that changed (e.g. skills version for skill consolidation) ## Adding New Skills diff --git a/bin/cli.js b/bin/cli.js index 7ea88ccc5..7f467639e 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -29,6 +29,7 @@ Commands: skills help List all available skills and 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 Options: --help Show this help message diff --git a/bin/commands/skills.mjs b/bin/commands/skills.mjs index 1598b80d4..4a66220e1 100644 --- a/bin/commands/skills.mjs +++ b/bin/commands/skills.mjs @@ -13,6 +13,7 @@ import { join, resolve, dirname } from 'node:path'; 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'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -57,6 +58,106 @@ async function showHelp() { console.log(`\n ${commands.length} commands available. Run / in your AI harness.\n`); } +// ─── version helpers ───────────────────────────────────────────────────────── + +function getLocalVersion() { + try { + const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')); + return pkg.version; + } catch { + return null; + } +} + +function fetchRemoteVersion() { + return new Promise((resolve) => { + get('https://registry.npmjs.org/impeccable/latest', (res) => { + if (res.statusCode !== 200) { resolve(null); res.resume(); return; } + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => { + try { resolve(JSON.parse(data).version); } catch { resolve(null); } + }); + }).on('error', () => resolve(null)); + }); +} + +/** + * Hash all SKILL.md files in a directory tree for comparison. + * Returns a sorted string of "name:hash" pairs. + */ +function hashSkillsDir(skillsDir) { + if (!existsSync(skillsDir)) return ''; + const entries = []; + for (const name of readdirSync(skillsDir).sort()) { + const skillMd = join(skillsDir, name, 'SKILL.md'); + if (!existsSync(skillMd)) continue; + const hash = createHash('sha256').update(readFileSync(skillMd)).digest('hex').slice(0, 12); + entries.push(`${name}:${hash}`); + } + return entries.join(','); +} + +/** + * Download the universal bundle to a temp dir and return its path. + * Caller is responsible for cleanup. + */ +async function downloadAndExtractBundle() { + const tmpZip = join(tmpdir(), `impeccable-update-${Date.now()}.zip`); + const tmpDir = join(tmpdir(), `impeccable-update-${Date.now()}`); + await downloadFile(`${API_BASE}/api/download/bundle/universal`, tmpZip); + mkdirSync(tmpDir, { recursive: true }); + execSync(`unzip -qo "${tmpZip}" -d "${tmpDir}"`, { encoding: 'utf8' }); + rmSync(tmpZip, { force: true }); + return tmpDir; +} + +/** + * Compare local skills against a downloaded bundle. + * Returns true if all local SKILL.md files match the bundle. + */ +function isUpToDate(root, providers, bundleDir) { + for (const provider of providers) { + const localHash = hashSkillsDir(join(root, provider, 'skills')); + const remoteHash = hashSkillsDir(join(bundleDir, provider, 'skills')); + if (localHash !== remoteHash) return false; + } + return true; +} + +// ─── skills check ──────────────────────────────────────────────────────────── + +async function check() { + const root = findProjectRoot(); + const installed = isAlreadyInstalled(root); + + if (!installed) { + console.log('Impeccable is not installed in this project.'); + console.log('Run `npx impeccable skills install` to install.'); + process.exit(0); + } + + const providers = findInstalledProviders(root); + const version = getLocalVersion(); + + console.log('Checking for updates...\n'); + try { + const bundleDir = await downloadAndExtractBundle(); + const upToDate = isUpToDate(root, providers, bundleDir); + rmSync(bundleDir, { recursive: true, force: true }); + + if (upToDate) { + console.log(`Skills are up to date${version ? ` (v${version})` : ''}.`); + } else { + console.log(`Updates available${version ? ` (currently v${version})` : ''}.`); + console.log('Run `npx impeccable skills update` to update.'); + } + } catch (e) { + console.error(`Could not check for updates: ${e.message}`); + process.exit(1); + } +} + // ─── skills install ─────────────────────────────────────────────────────────── // Check if impeccable skills are already present in any provider folder @@ -385,8 +486,6 @@ async function update(flags = []) { // Download the latest skills directly from impeccable.style. // We skip `npx skills update` because it has a known upstream bug // (vercel-labs/skills#775) where it can't find the lock file. - console.log('Checking for updates...\n'); - const root = findProjectRoot(); const providers = findInstalledProviders(root); @@ -396,73 +495,56 @@ async function update(flags = []) { process.exit(1); } - console.log(`Found impeccable skills in: ${providers.join(', ')}`); + console.log('Checking for updates...'); - // Check for local modifications - const modified = getModifiedSkillFiles(root, providers); - if (modified.length > 0) { - console.log(`\n Warning: ${modified.length} skill file(s) have local changes:\n`); - for (const m of modified.slice(0, 10)) { - console.log(` ${m.flag} ${m.file}`); - } - if (modified.length > 10) console.log(` ... and ${modified.length - 10} more`); - console.log(); - if (!yes) { - const ans = await ask(' Overwrite local changes? (y/N) '); - if (ans !== 'y' && ans !== 'yes') { - console.log('Aborted.'); - process.exit(0); - } - } - } else if (!yes) { - const ans = await ask(`Update skills in ${providers.length} provider folder(s)? (Y/n) `); - if (ans === 'n' || ans === 'no') { - console.log('Aborted.'); - process.exit(0); - } - } - - // Download universal bundle - const tmpZip = join(tmpdir(), `impeccable-update-${Date.now()}.zip`); - console.log('\nDownloading latest skills...'); + let tmpDir; try { - await downloadFile(`${API_BASE}/api/download/bundle/universal`, tmpZip); + tmpDir = await downloadAndExtractBundle(); } catch (e) { console.error(`Download failed: ${e.message}`); process.exit(1); } - // Extract and copy to each provider folder - let unzip; - try { - // Use built-in decompress if available (Node 22+), otherwise shell unzip - const tmpDir = join(tmpdir(), `impeccable-update-${Date.now()}`); - mkdirSync(tmpDir, { recursive: true }); - execSync(`unzip -qo "${tmpZip}" -d "${tmpDir}"`, { encoding: 'utf8' }); + // Compare local vs remote -- skip if already up to date + if (!yes && isUpToDate(root, providers, tmpDir)) { + rmSync(tmpDir, { recursive: true, force: true }); + const version = getLocalVersion(); + console.log(`Skills are up to date${version ? ` (v${version})` : ''}. Nothing to do.`); + process.exit(0); + } - // The universal bundle has provider folders at the top level + console.log(`Found skills in: ${providers.join(', ')}`); + + if (!yes) { + const ans = await ask(`Update skills in ${providers.length} provider folder(s)? (Y/n) `); + if (ans === 'n' || ans === 'no') { + rmSync(tmpDir, { recursive: true, force: true }); + console.log('Aborted.'); + process.exit(0); + } + } + + try { + + // Copy from the already-downloaded bundle to each provider folder let updated = 0; for (const provider of providers) { const srcDir = join(tmpDir, provider, 'skills'); const destDir = join(root, provider, 'skills'); if (!existsSync(srcDir)) continue; - // Copy each skill folder const skills = readdirSync(srcDir, { withFileTypes: true }); for (const skill of skills) { if (!skill.isDirectory()) continue; const src = join(srcDir, skill.name); const dest = join(destDir, skill.name); - // Remove old and copy new if (existsSync(dest)) rmSync(dest, { recursive: true }); copyDirSync(src, dest); updated++; } } - // Cleanup rmSync(tmpDir, { recursive: true, force: true }); - rmSync(tmpZip, { force: true }); // Re-apply prefix if detected const prefix = detectPrefix(root); @@ -471,7 +553,7 @@ async function update(flags = []) { if (count > 0) console.log(`Re-applied "${prefix}" prefix to ${count} skills.`); } - // Run cleanup again to remove deprecated stubs from the fresh download + // Run cleanup to remove deprecated stubs from the fresh download try { const { cleanup: postCleanup } = await import('../../source/skills/impeccable/scripts/cleanup-deprecated.mjs'); postCleanup(root); @@ -479,11 +561,12 @@ async function update(flags = []) { // Not available -- skip } - console.log(`Updated ${updated} skills across ${providers.length} provider(s).`); + const version = await fetchRemoteVersion(); + console.log(`Updated ${updated} skills across ${providers.length} provider(s)${version ? ` to v${version}` : ''}.`); console.log('Done!\n'); } catch (e) { - console.error(`Extract failed: ${e.message}`); - rmSync(tmpZip, { force: true }); + console.error(`Update failed: ${e.message}`); + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); process.exit(1); } } @@ -512,6 +595,8 @@ export async function run(args) { await install(args.slice(1)); } else if (sub === 'update') { await update(args.slice(1)); + } else if (sub === 'check') { + await check(); } else { console.error(`Unknown skills command: ${sub}`); console.error(`Run 'impeccable skills --help' for available commands.`); diff --git a/package.json b/package.json index 8f90de465..e066f93b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impeccable", - "version": "2.1.1", + "version": "2.1.2", "author": "Paul Bakaus", "description": "Design skills, commands, and anti-pattern detection for AI coding agents", "keywords": [