From 9fbd13114e18dba622e6b6158c2b25f1d2f8ba7c Mon Sep 17 00:00:00 2001 From: ayushsingh82 Date: Wed, 12 Aug 2026 02:18:22 +0530 Subject: [PATCH 1/2] fix: detect skill/CLI version drift between npm and Claude Code plugin installs webcmd ships skills/ through two channels: an npm symlink (always in sync with the running package) and a Claude Code plugin-cache copy that's version-pinned and only refreshed by `claude plugin update`. Since only the CLI's own version nags on update-check, a user can end up running a newer CLI against older skills with no signal, producing confidently wrong commands. Add findSkillVersionDrift(): when both channels are present on disk and the plugin-cache version differs from the running CLI, webcmd doctor now reports it with the fix command. Fixes #274 (item 2, the doctor drift check) --- src/doctor.ts | 12 ++++ src/skill-version-drift.test.ts | 101 +++++++++++++++++++++++++++++++ src/skill-version-drift.ts | 103 ++++++++++++++++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 src/skill-version-drift.test.ts create mode 100644 src/skill-version-drift.ts diff --git a/src/doctor.ts b/src/doctor.ts index 96a1ec09..be38b422 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -14,6 +14,7 @@ import type { BrowserProfileStatus } from './browser/daemon-transport.js'; import { aliasForContextId, loadProfileConfig } from './browser/profile.js'; import { formatDaemonVersion, isDaemonStale, staleDaemonIssue } from './browser/daemon-version.js'; import { findShadowedUserAdapters, formatAdapterShadowIssue, type AdapterShadow } from './adapter-shadow.js'; +import { findSkillVersionDrift, formatSkillVersionDriftIssue, type SkillVersionDrift } from './skill-version-drift.js'; const DOCTOR_LIVE_TIMEOUT_SECONDS = 8; const DOCTOR_SESSION = '__doctor__'; @@ -43,6 +44,7 @@ export type DoctorReport = { connectivity?: ConnectivityResult; profiles?: BrowserProfileStatus[]; adapterShadows?: AdapterShadow[]; + skillVersionDrift?: SkillVersionDrift | null; issues: string[]; }; @@ -101,6 +103,12 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise 0) { issues.push(formatAdapterShadowIssue(adapterShadows)); } + if (skillVersionDrift) { + issues.push(formatSkillVersionDriftIssue(skillVersionDrift)); + } return { cliVersion: opts.cliVersion, @@ -170,6 +181,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise void) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-skill-drift-')); + try { + const homeDir = path.join(root, 'home'); + const cwd = path.join(root, 'project'); + fs.mkdirSync(homeDir, { recursive: true }); + fs.mkdirSync(cwd, { recursive: true }); + fn({ root, homeDir, cwd }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +function seedPluginCache(homeDir: string, version: string): void { + fs.mkdirSync(path.join(homeDir, '.claude', 'plugins', 'cache', 'webcmd', 'webcmd', version), { recursive: true }); +} + +function seedSymlinkedSkills(homeDir: string): void { + const skillsDir = path.join(homeDir, '.webcmd', 'skills'); + fs.mkdirSync(skillsDir, { recursive: true }); + const target = path.join(homeDir, '.webcmd', 'skills-target'); + fs.mkdirSync(target, { recursive: true }); + fs.symlinkSync(target, path.join(skillsDir, 'webcmd-usage'), 'dir'); +} + +describe('skill version drift detection', () => { + it('reports drift when the plugin cache is pinned behind the running CLI and symlinked skills exist too', () => { + withTempDirs(({ homeDir, cwd }) => { + seedPluginCache(homeDir, '0.6.0'); + seedSymlinkedSkills(homeDir); + + const drift = findSkillVersionDrift('0.6.1', { homeDir, cwd }); + expect(drift).toEqual({ + cliVersion: '0.6.1', + pluginCacheVersion: '0.6.0', + pluginCachePath: path.join(homeDir, '.claude', 'plugins', 'cache', 'webcmd', 'webcmd', '0.6.0'), + }); + }); + }); + + it('picks the highest installed plugin-cache version when several are present', () => { + withTempDirs(({ homeDir, cwd }) => { + seedPluginCache(homeDir, '0.5.3'); + seedPluginCache(homeDir, '0.6.0'); + seedPluginCache(homeDir, '0.10.0'); + seedSymlinkedSkills(homeDir); + + const drift = findSkillVersionDrift('0.10.1', { homeDir, cwd }); + expect(drift?.pluginCacheVersion).toBe('0.10.0'); + }); + }); + + it('reports no drift when the plugin cache version matches the CLI', () => { + withTempDirs(({ homeDir, cwd }) => { + seedPluginCache(homeDir, '0.6.1'); + seedSymlinkedSkills(homeDir); + + expect(findSkillVersionDrift('0.6.1', { homeDir, cwd })).toBeNull(); + }); + }); + + it('reports no drift when only the Claude Code plugin channel is installed', () => { + withTempDirs(({ homeDir, cwd }) => { + seedPluginCache(homeDir, '0.6.0'); + + expect(findSkillVersionDrift('0.6.1', { homeDir, cwd })).toBeNull(); + }); + }); + + it('reports no drift when only the npm symlink channel is installed', () => { + withTempDirs(({ homeDir, cwd }) => { + seedSymlinkedSkills(homeDir); + + expect(findSkillVersionDrift('0.6.1', { homeDir, cwd })).toBeNull(); + }); + }); + + it('reports no drift when neither channel is installed', () => { + withTempDirs(({ homeDir, cwd }) => { + expect(findSkillVersionDrift('0.6.1', { homeDir, cwd })).toBeNull(); + }); + }); + + it('formats a doctor issue naming both versions and the fix command', () => { + const issue = formatSkillVersionDriftIssue({ + cliVersion: '0.6.1', + pluginCacheVersion: '0.6.0', + pluginCachePath: '/home/me/.claude/plugins/cache/webcmd/webcmd/0.6.0', + }); + + expect(issue).toContain('0.6.0'); + expect(issue).toContain('0.6.1'); + expect(issue).toContain('claude plugin update webcmd@webcmd'); + }); +}); diff --git a/src/skill-version-drift.ts b/src/skill-version-drift.ts new file mode 100644 index 00000000..1999880b --- /dev/null +++ b/src/skill-version-drift.ts @@ -0,0 +1,103 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +export type SkillVersionDrift = { + cliVersion: string; + pluginCacheVersion: string; + pluginCachePath: string; +}; + +export type SkillVersionDriftOptions = { + homeDir?: string; + cwd?: string; + /** Marketplace/plugin id pair under `/.claude/plugins/cache///`. */ + marketplace?: string; + plugin?: string; +}; + +/** + * Webcmd ships the same skills/ through two channels: an npm-managed symlink (which can + * never drift — it always points at the running package) and a Claude Code plugin cache + * copy that's version-pinned and only refreshed by `claude plugin update`. Drift is only + * possible, and only worth reporting, when both channels are actually in use on this + * machine and the plugin-cache copy is pinned to a different version than the running CLI. + */ +export function findSkillVersionDrift(cliVersion: string | undefined, options: SkillVersionDriftOptions = {}): SkillVersionDrift | null { + if (!cliVersion) return null; + const homeDir = options.homeDir ?? os.homedir(); + const cwd = options.cwd ?? process.cwd(); + const marketplace = options.marketplace ?? 'webcmd'; + const plugin = options.plugin ?? 'webcmd'; + + const pluginCacheRoot = path.join(homeDir, '.claude', 'plugins', 'cache', marketplace, plugin); + const pluginCacheVersion = latestPluginCacheVersion(pluginCacheRoot); + if (!pluginCacheVersion) return null; + + if (!hasSymlinkedSkills(homeDir, cwd)) return null; + if (pluginCacheVersion === cliVersion) return null; + + return { cliVersion, pluginCacheVersion, pluginCachePath: path.join(pluginCacheRoot, pluginCacheVersion) }; +} + +export function formatSkillVersionDriftIssue(drift: SkillVersionDrift): string { + return ( + `Claude Code plugin skills are pinned at v${drift.pluginCacheVersion}, but the webcmd CLI is v${drift.cliVersion}.\n` + + ' Skills emit webcmd commands and flags for their own version, so a mismatch can produce confidently wrong commands.\n' + + ' Run: claude plugin update webcmd@webcmd' + ); +} + +function latestPluginCacheVersion(pluginCacheRoot: string): string | undefined { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(pluginCacheRoot, { withFileTypes: true }); + } catch { + return undefined; + } + const versions = entries.filter((entry) => entry.isDirectory() && /^\d+\.\d+\.\d+/.test(entry.name)).map((entry) => entry.name); + return versions.sort(compareVersions).at(-1); +} + +function compareVersions(a: string, b: string): number { + const partsOf = (v: string) => v.split(/[.-]/).map((part) => Number.parseInt(part, 10)); + const [aParts, bParts] = [partsOf(a), partsOf(b)]; + for (let i = 0; i < Math.max(aParts.length, bParts.length); i += 1) { + const diff = (aParts[i] ?? 0) - (bParts[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; +} + +function hasSymlinkedSkills(homeDir: string, cwd: string): boolean { + const stableRoot = path.join(homeDir, '.webcmd', 'skills'); + if (directoryHasEntries(stableRoot)) return true; + + return ['.agents', '.codex', '.claude'].some((agentDir) => + [path.join(homeDir, agentDir, 'skills'), path.join(cwd, agentDir, 'skills')].some((root) => directoryHasSymlinkEntry(root)), + ); +} + +function directoryHasEntries(dir: string): boolean { + try { + return fs.readdirSync(dir).length > 0; + } catch { + return false; + } +} + +function directoryHasSymlinkEntry(dir: string): boolean { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return false; + } + return entries.some((entry) => { + try { + return fs.lstatSync(path.join(dir, entry.name)).isSymbolicLink(); + } catch { + return false; + } + }); +} From 3b97942acca860e739b2d6516a4774bf0cdc3a65 Mon Sep 17 00:00:00 2001 From: ayushsingh82 Date: Wed, 12 Aug 2026 02:18:52 +0530 Subject: [PATCH 2/2] docs: note that skill changes need fix:/feat: to reach plugin users Related to #274 item 3: docs:/chore: commits don't cut a release-please version, so a skill edit under those prefixes never reaches the Claude Code plugin install path even though npm users get it for free via the symlink. --- CONTRIBUTING.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ffb5bda3..e858ee4a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,14 @@ import { CommandExecutionError, EmptyResultError } from '@agentrhq/webcmd/errors User adapters, plugins, cache, traces, and site memory live under `~/.webcmd`. +## Skill Changes and Release Triggers + +The Claude Code plugin install path copies `skills/` into a version-pinned cache directory +and only refreshes it when release-please cuts a new version. `docs:`/`chore:` commits do not +cut a version, so a skill fix landed under those prefixes stays invisible to plugin users +indefinitely even though npm installs (which symlink `skills/`) pick it up immediately. Use +`fix:`/`feat:` for any commit that changes a file under `skills/`. + ## Documentation The published docs at [webcmd.dev/docs](https://webcmd.dev/docs) are built by Mintlify from the `docs/` directory in this repo. To change the published docs, edit the `.mdx` pages under `docs/` (and `docs/docs.json` for navigation) — do not edit the site directly.