SLOPSHOPPER

mod-doctor

Names each installed plugin, of every marketplace, whose local clone already offers a newer version, with the claude plugin update command that closes the gap.

newcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-doctor
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /mod-doctor ⎿ mod-doctor: no installed plugin of every marketplace was read; the marketplace name may be another one ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

mod-doctor

You run claude plugin marketplace update, the clone gets the new versions, and the installed plugins stay where they were until you also run claude plugin update for each one. That second step is easy to forget. This mod names each installed plugin, of every marketplace, whose local clone already offers a newer version, so an update you ran for the marketplace and not for the plugin does not stay unnoticed.

What it does

  1. At each session start, at the end of each main-loop turn, and every 60 s in an interactive session, the mod reads what the host keeps on disk:
  2. ~/.claude/plugins/installed_plugins.json ($CLAUDE_CONFIG_DIR/plugins/ instead when CLAUDE_CONFIG_DIR is set, as the host reads it), which names the version installed of each <plugin>@<marketplace> per scope: the user install, and a project install for the project it names. A project install of another project is not read, and of two installs in force the older version is compared;
  3. each marketplace's own .claude-plugin/marketplace.json in its clone, which says where that plugin sits inside it (one marketplace keeps its plugins under plugins/, another is one plugin at its root);
  4. that plugin's .claude-plugin/plugin.json, the version the clone offers.
  5. It compares installed against offered by the numbers of each part, so 0.10.0 counts as newer than 0.9.0. Two versions it cannot compare as numbers count as equal, so a version of another shape never asks for an update.
  6. While the sidebar is open, the plugins that are behind are one update available section that stays for the session:

update available sidebar 0.4.1 → 0.5.0 turkish-native 1.0.0 → 1.2.0 claude plugin update sidebar@kilimcininkoroglu-mods turkish-native@turkish-native

In each row the installed version is faint and the offered version is coloured by the jump: a new major version red, a new minor version yellow, a new patch green. The rows past the eighth are counted in one faint line, and the faint update command under them names the plugins of the first eight rows. With the sidebar closed, or without that mod installed, the same finding is one transcript line.

  1. Nothing is drawn while every installed plugin is at its clone's version, and the section is taken down as soon as that is true.
  2. The measure at each turn's end and the 60 s one catch what the session start cannot: a plugin or a marketplace you updated in another window while this session was open, and a sidebar whose own plugin had not opened its pane yet when this mod first measured. The 60 s measure also catches them while this session is idle. The finding reaches the transcript once; a later measure of the same finding says nothing.
  3. /mod-doctor measures again on the spot and prints the setting, the scope, how many plugins it holds and which are behind. /mod-doctor marketplace <name> narrows it to one marketplace, and marketplace all widens it back.

The clone is only as new as the last claude plugin marketplace update, so this mod answers "I updated the marketplace, did I update the plugins?", not "is there a newer version on GitHub?".

Command

/mod-doctor the setting, the scope, and every plugin that is behind /mod-doctor on | off on by default /mod-doctor marketplace my-mods that marketplace alone /mod-doctor marketplace all every marketplace the host cloned; the default, stored across sessions

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install mod-doctor@kilimcininkoroglu-mods

Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.

After installing

  1. Restart Claude Code.
  2. Install the sidebar mod for the per-plugin rows. Without it the mod writes one transcript line instead.

What it can reach

Validated with claude plugin validate on Claude Code 2.1.284:

❯ ./register.ts hooks: session.start, command.run{command=mod-doctor}, turn.complete ❯ ./register.ts calls: $.clock.every, $.command.register, $.env.get, $.fs.read (via readText), $.sidebar.clear (via clearShown), $.sidebar.set (via toPerson), $.store.get (via readScope, readSettings), $.store.set (via setEnabled, setScope), $.ui.log (via toPerson) ❯ ./register.ts env writes: nothing ❯ ./register.ts env reads: CLAUDE_CONFIG_DIR, HOME

Reach L1, it reads files.

  1. Reads: CLAUDE_CONFIG_DIR, HOME, the host's install record, each marketplace clone's manifest, and one plugin.json per installed plugin. No project file, no prompt, no transcript.
  2. Runs: nothing; the update command is text for the person to run
  3. Sends: nothing to the model and nothing to the network; the rows are for the person only
  4. Persists: in $.store, the on/off setting and the scope
  5. Hostile input: every file is read as data, the versions are compared as numbers, and a file of another shape is skipped without a finding

Limits

  • The clone is the measure, not the upstream repository. Run claude plugin marketplace update <marketplace> first, or the mod reports nothing new.
  • A marketplace that versions its plugins by commit sha (the official one does) reports nothing, because two shas are not comparable as numbers.
  • A plugin the marketplace pulls from another repository as a git subdirectory has no version in the clone, so it is skipped.
  • It does not say whether the running session loaded the new code. A claude plugin update during a session leaves the old code loaded until /reload-plugins or a restart.
  • Nothing is updated for you. The mod names the command; running it is yours.

Development

make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test

Source 2 files
hooks/register.ts 242 lines
1import type { EngineInterface, Register } from 'claude-code'
2import { ALL, configDirOf, installedOf, isNewer, logText, marketplaceOf, marketplaceText, missingText, sidebarLines, sourcesOf, statusText, versionOf, type Installed, type Mod } from './doctor.ts'
3
4const ENABLED_KEY = 'enabled'
5const MARKETPLACE_KEY = 'marketplace'
6
7const USAGE = 'expects nothing (the status), on, off or marketplace <name | all>'
8
9/** The section this mod owns in the shared sidebar. */
10const SECTION = { consumer: 'mod-doctor', key: 'behind' }
11
12/** How often an idle session measures again, so an update run in another window shows without a turn. */
13const MEASURE_MS = 60_000
14
15/**
16 * The on/off setting, the scope read, how many plugins are installed in it, which are behind, the host's
17 * config directory, the directory the session started in, what was last said to the person, and whether
18 * a measure after the session's start is under way.
19 */
20type State = { enabled: boolean; scope: string; count: number; behind: Mod[]; config: string; cwd: string; said: string; measuring: boolean }
21
22/** Where the host keeps the record of every installed plugin. */
23function recordPath(config: string): string {
24  return `${config}/plugins/installed_plugins.json`
25}
26
27/** Where the host keeps the clone of each marketplace. */
28function clonePath(config: string, marketplace: string): string {
29  return `${config}/plugins/marketplaces/${marketplace}`
30}
31
32/** A file's text, or undefined when it is missing or unreadable. */
33async function readText($: EngineInterface, path: string): Promise<string | undefined> {
34  try {
35    return String(await $.fs.read(path))
36  } catch {
37    // The file is not there, or is larger than one read takes.
38    return undefined
39  }
40}
41
42/** The plugins installed inside the scope, or undefined when the host keeps no record of them. */
43async function readInstalled($: EngineInterface, state: State): Promise<Installed[] | undefined> {
44  const text = await readText($, recordPath(state.config))
45  if (text === undefined) return undefined
46  try {
47    const mods = installedOf(text, state.scope, state.cwd)
48    return mods.length === 0 ? undefined : mods
49  } catch {
50    // A record file of another shape.
51    return undefined
52  }
53}
54
55/**
56 * Where each plugin of one marketplace sits inside its clone. The clone's own manifest is the answer,
57 * because one marketplace holds its plugins under `plugins/` and another is one plugin at its root.
58 */
59async function readSources($: EngineInterface, state: State, marketplace: string): Promise<Map<string, string>> {
60  const text = await readText($, `${clonePath(state.config, marketplace)}/.claude-plugin/marketplace.json`)
61  if (text === undefined) return new Map()
62  try {
63    return sourcesOf(text)
64  } catch {
65    // A manifest of another shape.
66    return new Map()
67  }
68}
69
70/** The version the clone offers for one plugin, or undefined when the clone does not hold it. */
71async function readOffered($: EngineInterface, state: State, mod: Installed, source: string | undefined): Promise<string | undefined> {
72  if (source === undefined) return undefined
73  const text = await readText($, `${clonePath(state.config, mod.marketplace)}/${source}/.claude-plugin/plugin.json`)
74  if (text === undefined) return undefined
75  try {
76    return versionOf(text)
77  } catch {
78    // A manifest of another shape.
79    return undefined
80  }
81}
82
83/** Measures every installed plugin against its own clone; answers the ones an update is waiting for. */
84async function measure($: EngineInterface, state: State): Promise<Mod[] | undefined> {
85  const installed = await readInstalled($, state)
86  if (installed === undefined) return undefined
87  state.count = installed.length
88  const sources = new Map<string, Map<string, string>>()
89  const behind: Mod[] = []
90  for (const mod of installed) {
91    if (!sources.has(mod.marketplace)) sources.set(mod.marketplace, await readSources($, state, mod.marketplace))
92    const offered = await readOffered($, state, mod, sources.get(mod.marketplace)?.get(mod.name))
93    if (offered !== undefined && isNewer(offered, mod.version)) behind.push({ ...mod, installed: mod.version, offered })
94  }
95  behind.sort((a, b) => a.name.localeCompare(b.name))
96  state.behind = behind
97  return behind
98}
99
100/** What was reported last, so the same finding is not written to the transcript twice. */
101function signOf(state: State): string {
102  return state.behind.map(m => `${m.name}@${m.marketplace}:${m.offered}`).join(',')
103}
104
105/**
106 * The finding the person reads: the sidebar while it is open, else one transcript line. The section is
107 * written at every measure, because it replaces itself; the transcript line only when the finding
108 * changed, so a second measure of the same finding says nothing.
109 */
110async function toPerson($: EngineInterface, state: State): Promise<void> {
111  const lines = sidebarLines(state.behind)
112  const sign = signOf(state)
113  try {
114    if (await $.sidebar.set({ ...SECTION, title: 'update available', lines, until: 'session', order: 20 })) {
115      state.said = sign
116      return
117    }
118  } catch {
119    // The sidebar mod is not installed.
120  }
121  if (state.said === sign) return
122  state.said = sign
123  $.ui.log(logText(state.behind))
124}
125
126/** Takes the section down, because every installed plugin is at its clone's version. */
127async function clearShown($: EngineInterface, state: State): Promise<void> {
128  state.said = ''
129  try {
130    await $.sidebar.clear(SECTION)
131  } catch {
132    // The sidebar mod is not installed.
133  }
134}
135
136/** Measures, then draws the finding or takes the old one down. */
137async function check($: EngineInterface, state: State): Promise<void> {
138  const behind = await measure($, state)
139  if (behind === undefined || behind.length === 0) await clearShown($, state)
140  else await toPerson($, state)
141}
142
143/**
144 * The measure of a turn's end and of the timer: the settings read again, then a check while on. One runs
145 * at a time, so a timer that fires during a turn end's measure does not stack a second one.
146 */
147async function measureAgain($: EngineInterface, state: State): Promise<void> {
148  if (state.measuring || state.config === '') return
149  state.measuring = true
150  try {
151    await readSettings($, state)
152    if (state.enabled) await check($, state)
153  } finally {
154    state.measuring = false
155  }
156}
157
158/** Writes the scope the person named; it holds across sessions, because it lives in $.store. */
159async function setScope($: EngineInterface, state: State, arg: string): Promise<string> {
160  const name = marketplaceOf(arg)
161  if (name === undefined) return marketplaceText(undefined)
162  state.scope = name
163  await $.store.set(MARKETPLACE_KEY, name)
164  await check($, state)
165  return marketplaceText(name)
166}
167
168/** The stored scope, or every marketplace when nothing is stored and when the stored value is not one. */
169async function readScope($: EngineInterface): Promise<string> {
170  const stored = await $.store.get(MARKETPLACE_KEY)
171  return typeof stored === 'string' && marketplaceOf(stored) !== undefined ? stored : ALL
172}
173
174/**
175 * Reads the on/off setting and the scope from the store, which every window shares, so a change made in
176 * another window applies here at the next hook that acts on it. A mod turned off there takes its section
177 * down here too, as `off` does.
178 */
179async function readSettings($: EngineInterface, state: State): Promise<void> {
180  const was = state.enabled
181  state.enabled = (await $.store.get(ENABLED_KEY)) !== false
182  state.scope = await readScope($)
183  if (was && !state.enabled) await clearShown($, state)
184}
185
186async function setEnabled($: EngineInterface, state: State, on: boolean): Promise<string> {
187  state.enabled = on
188  await $.store.set(ENABLED_KEY, on)
189  if (on) await check($, state)
190  else await clearShown($, state)
191  return on ? 'on: the installed plugins are measured at each session start, at the end of each turn and every 60 s' : 'off: nothing is measured'
192}
193
194async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
195  const arg = args.trim()
196  await readSettings($, state)
197  if (arg === 'on' || arg === 'off') return setEnabled($, state, arg === 'on')
198  if (arg.startsWith('marketplace')) return setScope($, state, arg.slice(11).trim())
199  if (arg !== '' && arg !== 'status') return USAGE
200  // The person asked, so the answer is measured now rather than read from the session's start.
201  const behind = await measure($, state)
202  if (behind === undefined) return missingText(state.scope)
203  if (behind.length === 0) await clearShown($, state)
204  else await toPerson($, state)
205  return statusText(state.enabled, state.scope, state.count, behind)
206}
207
208export const register: Register = on => {
209  const state: State = { enabled: true, scope: ALL, count: 0, behind: [], config: '', cwd: '', said: '', measuring: false }
210
211  on('session.start', async ($, e, next) => {
212    const r = await next(e)
213    await readSettings($, state)
214    state.cwd = e.cwd
215    state.config = configDirOf(await $.env.get('CLAUDE_CONFIG_DIR'), await $.env.get('HOME'))
216    await $.command.register({
217      name: 'mod-doctor',
218      description: 'Which installed plugins are behind their marketplace clone: status, on, off, marketplace <name | all> (mod-doctor)',
219      argumentHint: '[on | off | marketplace <name | all>]',
220      immediate: true,
221    })
222    // The session's first measure; each turn's end measures again, and an interactive session every 60 s.
223    if (state.enabled && state.config !== '') await check($, state)
224    if (e.isInteractive) $.clock.every(MEASURE_MS, () => void measureAgain($, state))
225    return r
226  })
227
228  // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
229  on('command.run', { command: 'mod-doctor' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
230
231  /*
232   * A measure at the end of each main-loop turn. It catches a plugin updated, or a marketplace updated,
233   * while this session runs, and a sidebar whose own plugin had not opened its pane yet when this mod
234   * measured at the session's start, where the first `set` answered false.
235   */
236  on('turn.complete', async ($, e, next) => {
237    const r = await next(e)
238    if (e.agentId === undefined) await measureAgain($, state)
239    return r
240  })
241}
242
hooks/doctor.ts 193 lines
1/** What is installed of each marketplace, what its clone offers, and how the two texts read. */
2
3/** The scope the mod reads until the person names one marketplace: every marketplace the host cloned. */
4export const ALL = 'all'
5
6/** A marketplace name as the install records spell it; anything else is refused. */
7const NAME = /^[A-Za-z0-9._-]+$/
8
9/** The rows the pane draws; the rest are counted. */
10export const ROWS = 8
11
12/**
13 * The directory the host keeps its plugins under: `CLAUDE_CONFIG_DIR` when it is set, as the host reads it,
14 * else `~/.claude`. Empty when neither is known, and then nothing is measured.
15 */
16export function configDirOf(configDir: string | undefined, home: string | undefined): string {
17  if (configDir !== undefined && configDir !== '') return configDir
18  return home !== undefined && home !== '' ? `${home}/.claude` : ''
19}
20
21/** One plugin: where it comes from, the version installed, and the version its clone offers. */
22export type Mod = { name: string; marketplace: string; installed: string; offered: string }
23
24/** One plugin as the install record names it, before its clone was read. */
25export type Installed = { name: string; marketplace: string; version: string }
26
27/**
28 * One record `installed_plugins.json` keeps per `<plugin>@<marketplace>` and scope. A project or local
29 * install names its project in `projectPath`; a user install names none.
30 */
31type Entry = { version?: unknown; projectPath?: unknown }
32
33/** Whether a record is in force for a session started in `cwd`: a user install, or one of its project. */
34function appliesTo(entry: Entry, cwd: string): boolean {
35  if (typeof entry.projectPath !== 'string') return true
36  return cwd === entry.projectPath || cwd.startsWith(`${entry.projectPath}/`)
37}
38
39/**
40 * The oldest version among the records in force for this session, so an old install is reported even when
41 * another scope of the same plugin is up to date. A record of another project is not read.
42 */
43function versionIn(records: readonly Entry[], cwd: string): string | undefined {
44  let oldest: string | undefined
45  for (const entry of records) {
46    if (!appliesTo(entry, cwd) || typeof entry.version !== 'string') continue
47    if (oldest === undefined || isNewer(oldest, entry.version)) oldest = entry.version
48  }
49  return oldest
50}
51
52/**
53 * The plugins installed, read from the install record of the host. `scope` is one marketplace's name, or
54 * `all` for every one of them. `cwd` is the directory the session started in.
55 */
56export function installedOf(text: string, scope: string, cwd: string): Installed[] {
57  const all = (JSON.parse(text) as { plugins?: Record<string, Entry[]> }).plugins ?? {}
58  const out: Installed[] = []
59  for (const [id, records] of Object.entries(all)) {
60    const [name, marketplace] = id.split('@')
61    const version = versionIn(records, cwd)
62    if (name === undefined || marketplace === undefined || version === undefined) continue
63    if (scope !== ALL && marketplace !== scope) continue
64    out.push({ name, marketplace, version })
65  }
66  return out
67}
68
69/**
70 * Where each plugin of one marketplace sits inside its clone, from that marketplace's own manifest. A
71 * plugin whose source is not a path in the clone (a git subdirectory of another repository) is left out,
72 * because its version is not on disk here.
73 */
74export function sourcesOf(text: string): Map<string, string> {
75  const read = (JSON.parse(text) as { plugins?: unknown }).plugins
76  const out = new Map<string, string>()
77  if (!Array.isArray(read)) return out
78  for (const one of read) {
79    const { name, source } = (one ?? {}) as { name?: unknown; source?: unknown }
80    if (typeof name === 'string' && typeof source === 'string') out.set(name, source)
81  }
82  return out
83}
84
85/** The version one `plugin.json` names, or undefined when the file does not name one. */
86export function versionOf(text: string): string | undefined {
87  const version = (JSON.parse(text) as { version?: unknown }).version
88  return typeof version === 'string' ? version : undefined
89}
90
91/** The numbers of a version, so `0.10.0` sorts after `0.9.0` and a suffix does not decide. */
92function parts(version: string): number[] {
93  return version.split(/[.\-+]/).map(p => (/^\d+$/.test(p) ? Number(p) : -1))
94}
95
96/**
97 * Whether `offered` is newer than `installed`, by the numbers of each part. Two versions of another
98 * shape read as equal, so a version this mod cannot compare never asks for an update. A marketplace that
99 * versions its plugins by commit sha therefore reports nothing.
100 */
101export function isNewer(offered: string, installed: string): boolean {
102  const a = parts(offered)
103  const b = parts(installed)
104  for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
105    const one = a[i] ?? 0
106    const two = b[i] ?? 0
107    if (one !== two) return one > two
108  }
109  return false
110}
111
112/** The scope a `/mod-doctor marketplace <word>` argument names, or undefined when it is not one. */
113export function marketplaceOf(arg: string): string | undefined {
114  return arg.length > 0 && arg.length <= 64 && NAME.test(arg) ? arg : undefined
115}
116
117/** The answer of `/mod-doctor marketplace <name>`, or of an argument it cannot read. */
118export function marketplaceText(name: string | undefined): string {
119  if (name === undefined) return 'marketplace expects a name of 1-64 characters of letters, digits, . _ or -, or all'
120  if (name === ALL) return 'marketplace all: every marketplace the host cloned is checked from now on'
121  return `marketplace ${name}: that marketplace alone is checked from now on`
122}
123
124/** One row: the plugin, the version installed, and the version waiting for it. */
125export function rowText(mod: Mod): string {
126  return `${mod.name} ${mod.installed} → ${mod.offered}`
127}
128
129/** The commands that bring the outdated plugins up to date, as one line. */
130export function fixText(mods: readonly Mod[]): string {
131  return `claude plugin update ${mods.map(m => `${m.name}@${m.marketplace}`).join(' ')}`
132}
133
134/** How the sidebar colours a line or a part of one. */
135type Tone = 'ok' | 'warn' | 'error' | 'dim'
136export type Part = { text: string; kind?: Tone }
137/** A line; `parts` colour pieces of it, and `text` holds the whole line for a sidebar that draws no parts. */
138export type Line = { text: string; kind?: Tone; parts?: Part[] }
139
140const part = (text: string, kind: Tone | undefined): Part => (kind === undefined ? { text } : { text, kind })
141
142/** A line made of parts, its `text` their texts joined. */
143const partsLine = (parts: Part[]): Line => ({ text: parts.map(p => p.text).join(''), parts })
144
145/** The colour of a jump by the first part that differs: a major red, a minor yellow, a patch or later green. */
146export function jumpTone(installed: string, offered: string): Tone {
147  const a = parts(offered)
148  const b = parts(installed)
149  const at = Array.from({ length: Math.max(a.length, b.length) }, (_, i) => i).find(i => (a[i] ?? 0) !== (b[i] ?? 0))
150  if (at === 0) return 'error'
151  return at === 1 ? 'warn' : 'ok'
152}
153
154/** One row in parts: the name, the version installed faint, and the version offered coloured by the jump. */
155function rowLine(mod: Mod): Line {
156  return partsLine([part(`${mod.name} `, undefined), part(mod.installed, 'dim'), part(' → ', undefined), part(mod.offered, jumpTone(mod.installed, mod.offered))])
157}
158
159/**
160 * The pane's lines: one row per plugin that is behind, the update command faint under them. The rows
161 * past the eighth are one faint line, so forty installed plugins still hold nine rows.
162 */
163export function sidebarLines(mods: readonly Mod[]): Line[] {
164  const lines: Line[] = mods.slice(0, ROWS).map(rowLine)
165  const rest = mods.length - ROWS
166  if (rest > 0) lines.push({ text: `${rest} more plugin(s) behind`, kind: 'dim' })
167  lines.push({ text: fixText(mods.slice(0, ROWS)), kind: 'dim' })
168  return lines
169}
170
171/** The transcript line the person reads while the sidebar is closed. */
172export function logText(mods: readonly Mod[]): string {
173  return `${mods.length} plugin(s) are behind their clone: ${mods.map(rowText).join(', ')}`
174}
175
176/** How the status text names what was read. */
177function scopeText(scope: string): string {
178  return scope === ALL ? 'every marketplace' : scope
179}
180
181/** The `/mod-doctor` answer: the setting, the scope, and every plugin that is behind. */
182export function statusText(enabled: boolean, scope: string, count: number, mods: readonly Mod[]): string {
183  const seen = mods.length === 0 ? `${count} plugin(s) installed, each at its clone's version` : `${mods.length} of ${count} plugin(s) behind`
184  const head = `${enabled ? 'on' : 'off'} · ${scopeText(scope)} · ${seen}`
185  if (mods.length === 0) return head
186  return `${head}\n${mods.map(rowText).join('\n')}\n${fixText(mods)}`
187}
188
189/** The answer when the host keeps no install record of what was asked for. */
190export function missingText(scope: string): string {
191  return `no installed plugin of ${scopeText(scope)} was read; the marketplace name may be another one`
192}
193