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
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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__';
Expand Down Expand Up @@ -43,6 +44,7 @@ export type DoctorReport = {
connectivity?: ConnectivityResult;
profiles?: BrowserProfileStatus[];
adapterShadows?: AdapterShadow[];
skillVersionDrift?: SkillVersionDrift | null;
issues: string[];
};

Expand Down Expand Up @@ -101,6 +103,12 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
} catch (err) {
issues.push(`Could not check adapter overrides: ${getErrorMessage(err)}`);
}
let skillVersionDrift: SkillVersionDrift | null = null;
try {
skillVersionDrift = findSkillVersionDrift(opts.cliVersion);
} catch (err) {
issues.push(`Could not check skill version drift: ${getErrorMessage(err)}`);
}
if (daemonFlaky) {
issues.push(
'Daemon connectivity is unstable. The live browser test succeeded, but the daemon was no longer running immediately afterward.\n' +
Expand Down Expand Up @@ -156,6 +164,9 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
if (adapterShadows.length > 0) {
issues.push(formatAdapterShadowIssue(adapterShadows));
}
if (skillVersionDrift) {
issues.push(formatSkillVersionDriftIssue(skillVersionDrift));
}

return {
cliVersion: opts.cliVersion,
Expand All @@ -170,6 +181,7 @@ export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise<Doctor
connectivity,
profiles,
adapterShadows,
skillVersionDrift,
issues,
};
}
Expand Down
101 changes: 101 additions & 0 deletions src/skill-version-drift.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { findSkillVersionDrift, formatSkillVersionDriftIssue } from './skill-version-drift.js';

function withTempDirs(fn: (dirs: { root: string; homeDir: string; cwd: string }) => 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');
});
});
103 changes: 103 additions & 0 deletions src/skill-version-drift.ts
Original file line number Diff line number Diff line change
@@ -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 `<homeDir>/.claude/plugins/cache/<marketplace>/<plugin>/`. */
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;
}
});
}