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) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-04-10 10:39:16 -07:00
co-authored by Claude Opus 4.6
parent 0d37f31ef3
commit 239d69d0d8
4 changed files with 149 additions and 50 deletions
+15 -2
View File
@@ -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
+1
View File
@@ -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
+132 -47
View File
@@ -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 /<command> 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.`);
+1 -1
View File
@@ -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": [