Available, on disk, and live are three different states

Plugin Sync · No. 106

Shipped

pluginsync 0.1.0 reconciles the Claude Code plugins installed on a machine with what the marketplace actually offers. It reports one row per plugin, installs what is missing, updates what drifted, and then re-reads the installed list and compares.

That last step is the entire reason it exists rather than being a shell alias for four commands. claude plugin update prints no version and returns success when it no-ops. So “✓ updated skillfactory” followed by a /skillfactory that still runs the old code is not a rare edge case; it is the default failure, and nothing in the output distinguishes it from success.

The pattern generalizes to anything that mutates state through a CLI you do not control. Below is a runnable version, small enough to lift into whatever your equivalent is.

The three states a manager collapses into one

Most package and plugin managers report one bit: the command worked. There are actually three states, and conflating any two of them produces a confidently wrong report.

State Means How it is known
available the source offers this version the catalog or manifest at the source
on disk this version is installed here the manager’s list output, re-read after every write
live this version is loaded in the running process only true after a restart, so never assert it

Other tools already draw part of this line. npm outdated prints three columns, where Current is what is installed in node_modules, Wanted is the most recent version your semver range allows, and Latest is what the registry tags. Ansible goes further and makes the distinction a return value: every module reports changed, a boolean for whether the task actually had to modify the target, separately from whether it failed. Terraform calls the same idea refresh, reading the current settings from real remote objects and updating state to match rather than trusting what it last wrote.

The rule that falls out: never report a thing as updated because a command succeeded; report it because the version on disk changed.

Prerequisites: a manager that lies to you

To watch the failure, you need a manager that no-ops silently. Save this as fake-pm.mjs. It stands in for the real CLI and behaves the way most of them do.

#!/usr/bin/env node
/**
 * A stand-in for the package/plugin manager you actually use.
 *
 * It behaves the way most of them do: `update` prints a cheerful line and
 * exits 0 whether or not anything moved. `STUCK` is the real-world case being
 * simulated, a plugin the manager believes is fine and refuses to touch.
 */
import { readFileSync, writeFileSync } from 'node:fs';

const STATE = new URL('./state/installed.json', import.meta.url);
const STUCK = new Set(['reporter']);   // pretend this one silently no-ops

const read = () => JSON.parse(readFileSync(STATE, 'utf8'));
const [cmd, name] = process.argv.slice(2);

if (cmd === 'list') {
  console.log(JSON.stringify(read(), null, 2));
} else if (cmd === 'update' || cmd === 'install') {
  const installed = read();
  const available = JSON.parse(
    readFileSync(new URL('./catalog/catalog.json', import.meta.url), 'utf8'));
  if (!STUCK.has(name)) {
    const row = installed.find((p) => p.name === name);
    const target = available[name];
    if (row) row.version = target;
    else installed.push({ name, version: target, enabled: true });
    writeFileSync(STATE, `${JSON.stringify(installed, null, 2)}\n`);
  }
  console.log(`✓ ${cmd}ed ${name}`);   // exits 0 either way. This is the bug.
} else {
  console.error('usage: fake-pm <list|install|update> [name]');
  process.exit(2);
}

Then the fixture it reads:

mkdir -p state catalog overrides/formatter && touch overrides/formatter/OVERRIDE.md

cat > catalog/catalog.json <<'JSON'
{ "formatter": "2.1.0", "linter": "0.9.4", "reporter": "1.4.0", "bundler": "3.0.1" }
JSON

cat > state/installed.json <<'JSON'
[
  { "name": "formatter", "version": "2.1.0", "enabled": true },
  { "name": "linter", "version": "0.9.1", "enabled": true },
  { "name": "reporter", "version": "1.2.0", "enabled": true }
]
JSON

Read the facts in one place, decide in another

Every fact comes from exactly one reader, and no reader classifies anything. A function that reads and also decides is a function you cannot point at a fixture. Save as state.mjs.

/**
 * Every fact this tool reports, read from exactly one place.
 * Nothing here classifies or renders: a reader that also decides is a reader
 * you cannot test against a fixture.
 */
import { existsSync, readFileSync } from 'node:fs';
import { join, relative } from 'node:path';

/** Read JSON, returning null rather than throwing. A missing file is data. */
export function readJson(path) {
  try {
    return JSON.parse(readFileSync(path, 'utf8'));
  } catch {
    return null;
  }
}

/**
 * AVAILABLE: what the source offers, read from the catalog rather than from
 * the manager. An entry whose source cannot be read becomes a row with
 * `error` set, never a dropped row: omitting it reads as "nothing to do",
 * which is the exact silent success this tool exists to prevent.
 */
export function readCatalog(catalogPath) {
  const raw = readJson(catalogPath);
  if (!raw) return { ok: false, error: `cannot read catalog at ${catalogPath}`, plugins: [] };
  const plugins = Object.entries(raw).map(([name, version]) => (
    version
      ? { name, available: String(version), error: null }
      : { name, available: null, error: 'catalog declares no version' }
  ));
  return { ok: true, error: null, plugins };
}

/**
 * ON DISK: what is installed right now.
 *
 * An unrecognised shape THROWS rather than reading as an empty list. "Nothing
 * is installed" and "I could not parse the list" render identically otherwise,
 * and the first one tells you to reinstall everything.
 */
export function readInstalled(raw) {
  const list = Array.isArray(raw) ? raw : raw?.installed;
  if (!Array.isArray(list)) {
    throw new Error('unrecognised list output — expected an array or {installed: [...]}');
  }
  const out = new Map();
  for (const p of list) {
    if (!p?.name) continue;
    out.set(p.name, {
      name: p.name,
      version: String(p.version ?? ''),
      enabled: p.enabled !== false,
    });
  }
  return out;
}

/**
 * A local override at <overrideDir>/<name> wins over the managed copy, and no
 * version number anywhere reveals it: the managed copy updates cleanly and the
 * stale one keeps being the code that runs.
 */
export function findShadows(overrideDir, names) {
  return names
    .filter((n) => existsSync(join(overrideDir, n)))
    // Relative, so the report is the same on every machine and can be diffed.
    .map((n) => ({ name: n, path: relative(process.cwd(), join(overrideDir, n)) }));
}

/** The diff. Pure, so it is testable without a filesystem or a subprocess. */
export function classify({ catalog, installed }) {
  return catalog.plugins.map((entry) => {
    const have = installed.get(entry.name);
    if (entry.error) return { ...entry, installed: have?.version ?? null, action: 'error' };
    if (!have) return { ...entry, installed: null, action: 'install' };
    if (have.version !== entry.available) {
      return { ...entry, installed: have.version, action: 'update' };
    }
    return { ...entry, installed: have.version, action: have.enabled ? 'ok' : 'disabled' };
  });
}

export const changeable = (rows) => rows.filter((r) => r.action === 'install' || r.action === 'update');

Write, then read the version back

apply runs the manager’s commands and then does the thing the manager will not do for you: it re-reads the installed list and compares each row against what the catalog offered. Anything that did not move is stalled, and stalled exits non-zero exactly like a failure. Save as sync.mjs.

#!/usr/bin/env node
/**
 * check  — diff available against on-disk and print one row per plugin
 * apply  — write, then READ THE VERSION BACK and compare
 *
 * Exit 1 when anything failed or stalled, so a script calling this can tell.
 */
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';

import { changeable, classify, findShadows, readCatalog, readInstalled } from './state.mjs';

const MANAGER = fileURLToPath(new URL('./fake-pm.mjs', import.meta.url));
const CATALOG = fileURLToPath(new URL('./catalog/catalog.json', import.meta.url));
const OVERRIDES = fileURLToPath(new URL('./overrides', import.meta.url));

/** The one call that reaches the manager for state. Isolated so tests can stub it. */
function listInstalled() {
  const out = execFileSync('node', [MANAGER, 'list'], { encoding: 'utf8' });
  return readInstalled(JSON.parse(out));
}

function table(rows, cols) {
  const head = cols.map((c) => c.label);
  const body = rows.map((r) => cols.map((c) => String(c.get(r) ?? '-')));
  const width = head.map((h, i) => Math.max(h.length, ...body.map((r) => r[i].length)));
  const line = (cells) => `| ${cells.map((c, i) => c.padEnd(width[i])).join(' | ')} |`;
  return [line(head), `|${width.map((w) => '-'.repeat(w + 2)).join('|')}|`,
    ...body.map(line)].join('\n');
}

function gather() {
  const catalog = readCatalog(CATALOG);
  if (!catalog.ok) throw new Error(catalog.error);
  const installed = listInstalled();
  const rows = classify({ catalog, installed });
  return { rows, shadows: findShadows(OVERRIDES, rows.map((r) => r.name)) };
}

function reportShadows(shadows) {
  for (const s of shadows) {
    console.log(`\n! ${s.name} is shadowed by ${s.path} - that copy wins regardless of version`);
  }
}

function cmdCheck() {
  const { rows, shadows } = gather();
  console.log(table(rows, [
    { label: 'Plugin', get: (r) => r.name },
    { label: 'On disk', get: (r) => r.installed },
    { label: 'Available', get: (r) => r.available },
    { label: 'Action', get: (r) => r.action },
  ]));
  reportShadows(shadows);
  console.log(`\n${changeable(rows).length} to change · restart before calling any of it live`);
  if (rows.some((r) => r.action === 'error')) process.exitCode = 1;
}

function cmdApply() {
  const before = gather();
  const attempts = [];
  for (const row of changeable(before.rows)) {
    const verb = row.action;
    try {
      execFileSync('node', [MANAGER, verb, row.name], { encoding: 'utf8', stdio: 'pipe' });
      attempts.push({ row, verb, failure: null });
    } catch (err) {
      attempts.push({ row, verb, failure: (err.stderr || err.message).toString().trim() });
    }
  }

  // The whole point. Trusting the exit code here is the bug being guarded.
  const after = listInstalled();
  const rows = attempts.map(({ row, verb, failure }) => {
    const now = after.get(row.name)?.version ?? null;
    if (failure) return { name: row.name, was: row.installed, now, outcome: 'failed', note: failure };
    if (now === row.available) {
      const done = verb === 'install' ? 'installed' : 'updated';
      return { name: row.name, was: row.installed, now, outcome: done, note: null };
    }
    return {
      name: row.name, was: row.installed, now, outcome: 'stalled',
      note: `${verb} exited 0 but the version on disk is still ${now ?? 'absent'}`,
    };
  });

  console.log(table(rows, [
    { label: 'Plugin', get: (r) => r.name },
    { label: 'Was', get: (r) => r.was },
    { label: 'Now', get: (r) => r.now },
    { label: 'Outcome', get: (r) => r.outcome },
    { label: 'Note', get: (r) => r.note },
  ]));
  reportShadows(before.shadows);
  console.log('\nNothing above is live until you restart.');
  if (rows.some((r) => r.outcome === 'failed' || r.outcome === 'stalled')) process.exitCode = 1;
}

try {
  const cmd = process.argv[2];
  if (cmd === 'check') cmdCheck();
  else if (cmd === 'apply') cmdApply();
  else { console.error('usage: sync <check|apply>'); process.exitCode = 2; }
} catch (err) {
  console.error(`sync: ${err.message}`);
  process.exitCode = 1;
}

Run it and watch the stall get caught

node sync.mjs check; echo "EXIT=$?"
| Plugin    | On disk | Available | Action  |
|-----------|---------|-----------|---------|
| formatter | 2.1.0   | 2.1.0     | ok      |
| linter    | 0.9.1   | 0.9.4     | update  |
| reporter  | 1.2.0   | 1.4.0     | update  |
| bundler   | -       | 3.0.1     | install |

! formatter is shadowed by overrides/formatter - that copy wins regardless of version

3 to change · restart before calling any of it live
EXIT=0

Now apply. The manager will print three success lines and exit 0 three times.

node sync.mjs apply; echo "EXIT=$?"
| Plugin   | Was   | Now   | Outcome   | Note                                                   |
|----------|-------|-------|-----------|--------------------------------------------------------|
| linter   | 0.9.1 | 0.9.4 | updated   | -                                                      |
| reporter | 1.2.0 | 1.2.0 | stalled   | update exited 0 but the version on disk is still 1.2.0 |
| bundler  | -     | 3.0.1 | installed | -                                                      |

! formatter is shadowed by overrides/formatter - that copy wins regardless of version

Nothing above is live until you restart.
EXIT=1

reporter is the row that matters. The manager said ✓ updated reporter and returned 0; the version on disk never moved, and the only reason you know is that something read it back. Without the read-back this run is three green checks and a wrong mental model of what your machine is running.

To confirm the check is reading reality rather than replaying its own diff, edit state/installed.json by hand, set linter back to 0.9.1, and run check again. The row returns to update.

Gotchas

Diffing against the wrong list reports everything as clean. Claude Code’s claude plugin list --available --json looks like the obvious source for available versions. It lists only plugins that are not installed, so every plugin you already have is absent from it, and a diff against it compares nothing and finds no drift. It is a source that is wrong in the direction of silence, which is the worst direction. Take available versions from the catalog at the source instead, which for Claude Code plugins means the version field in .claude-plugin/plugin.json. That field is also what decides whether users get an update at all: set it, and they only receive one when you bump it.

An unparsable list and an empty list must not render the same. The first version treated any unexpected shape as “no plugins installed”, which produced a table telling you to reinstall every plugin you already had. readInstalled throws on an unrecognised shape for exactly this reason. The real CLI has two legitimate output shapes, a bare array and an object with an installed key, so the parser accepts both and rejects a third rather than guessing.

A local override is invisible to every version number in the system. A personal copy of a skill at ~/.claude/skills/<name>/SKILL.md wins over the plugin of the same name. The plugin updates cleanly, every version reads correct, and the stale copy is still the code that runs. There is nothing to compare, so it has to be a checked existence fact, which is why findShadows is in the reader layer and not a note in the docs.

A fixture-driven read plus a real write will damage the machine. The tool takes a --home flag so tests can point the readers at a fixture. apply refuses that flag outright, because the reads would come from the fixture while every write still goes to the real install, which means issuing genuine install commands for plugins that only exist in the test. Reading from one machine and writing to another is not a mode worth supporting.

Ending on the restart is part of the contract, not politeness. On-disk and live are different states, and the gap between them is a whole session long. Every report this tool prints closes by naming the restart, because the moment it stops doing that, someone runs /skillfactory, gets the old behavior, and concludes the sync did not work.

Sources

  • npm-outdated — the Current/Wanted/Latest split, where Current means what is actually installed on disk
  • Ansible common return valueschanged as a first-class boolean, reported separately from success
  • Terraform refresh — reading current settings from real remote objects instead of trusting last-written state
  • Claude Code plugins — the .claude-plugin/plugin.json manifest and what its version field controls

Changelog

  • feat(pluginsync): refresh the installed marketplace, and prove the version moved (#155) (948c2d3)