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.

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.
~/.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;.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);.claude-plugin/plugin.json, the version the clone offers.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.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.
/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?".
/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
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.
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.
claude plugin marketplace update <marketplace> first, or the mod reports nothing new.claude plugin update during a session leaves the old code loaded until /reload-plugins or a restart.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
hooks/register.ts 242 lines1import 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}
242hooks/doctor.ts 193 lines1/** 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