Tells you when installed plugins or copied skills are behind upstream, without updating them: a status line, one toast per news, and a /plugin-updates list of…

Claude Code updates plugins on its own only for marketplaces with auto-update on, which by default are Anthropic's official ones. A plugin from any other marketplace stays at the version you installed until you update it, and nothing tells you it fell behind.
This plugin tells you, without updating anything.
Once a day, 30 seconds after a session starts, the plugin checks every installed plugin against its marketplace:
~/.cache/plugin-updates, and fetches it;marketplace.json and counts the commits between the installed commit and the upstream one, limited to the plugin's own folder, so a commit that changes only the marketplace's README does not count;sha in the listing is the upstream commit; without one, the branch in ref or the default branch is.The mirrors are the plugin's own. Claude Code's marketplace clones and installed plugins are never touched.
When something is behind:
updates: claude-hud 537, cloudflare 13;/plugin-updates runs the check right away and lists, for each plugin that is behind, the latest missing commits and the command that updates it, for example claude plugin update claude-hud@claude-hud. It also lists what it could not check and why.
A plugin with new commits under an unchanged version is held, not behind. Claude Code replaces an installed copy only when the version changes, so claude plugin update would do nothing until the author bumps it. A held plugin gets no status line entry and no toast; /plugin-updates lists it under held with its missing commits.
A skill copied by hand from a repository can be checked too. Put a SOURCE.txt in the skill's folder:
Source: https://github.com/owner/repo (commit 683bc88), copied after review.
Path: skills/my-skill
Path: lists the files or folders that were copied, separated by spaces; without it every commit in the repository counts. The list then shows a compare link for the missing commits, so you can review them before copying the newer version.
Plugins from npm, archive and command sources, marketplaces that are not git repositories (a local folder or a hosted marketplace.json file), and any source that is not a plain https URL. These show under "Not checked" with the reason.
No model calls. One git fetch per repository per day; the first check clones the mirrors (about 24 MB for seven plugins from five repositories).
claude plugin marketplace add lbzepoqo/claude-plugins
claude plugin install plugin-updates@lbzepoqo
Restart Claude Code.
claude plugin validate .
claude plugin test .hooks/register.ts 122 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4 Mirrors,
5 checkPlugin,
6 checkSkill,
7 marketplaceRepos,
8 newsKeys,
9 parseInstalls,
10 parseSourceFile,
11 reportText,
12 statusText,
13 toastText,
14 type CopiedSkill,
15 type Report,
16} from './checks'
17
18const DAY = 24 * 60 * 60 * 1000
19const RETRY_AFTER = 60 * 60 * 1000
20const START_DELAY = 30_000
21const GIT_TIMEOUT = 5 * 60 * 1000
22
23type Stored = Report & { inputs: string }
24
25async function paths($: EngineInterface) {
26 // The order of Node's os.homedir(): USERPROFILE exists only on Windows, where HOME is usually unset.
27 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
28 if (!home) throw new Error('Neither USERPROFILE nor HOME is set')
29 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${home}/.claude`
30 return { configDir, cacheRoot: `${home}/.cache/plugin-updates` }
31}
32
33// The files a check reads; when they change (a plugin updated, a skill copied again) the stored report is stale.
34async function readInputs($: EngineInterface, configDir: string) {
35 const installed = await $.fs.read(`${configDir}/plugins/installed_plugins.json`)
36 const skillsDir = `${configDir}/skills`
37 const sources: { name: string; text: string }[] = []
38 if (await $.fs.exists(skillsDir)) {
39 for (const entry of await $.fs.list(skillsDir)) {
40 const file = `${skillsDir}/${entry.name}/SOURCE.txt`
41 if (await $.fs.exists(file)) sources.push({ name: entry.name, text: await $.fs.read(file) })
42 }
43 }
44 return { installed, sources, fingerprint: [installed, ...sources.map(source => source.text)].join('\n') }
45}
46
47async function check($: EngineInterface): Promise<Stored> {
48 const { configDir, cacheRoot } = await paths($)
49 // A git that cannot start or runs past the timeout fails its own plugin, not the whole check.
50 const git = (args: readonly string[]) =>
51 $.process
52 .run(['git', ...args], { timeoutMs: GIT_TIMEOUT, env: { GIT_TERMINAL_PROMPT: '0' } })
53 .catch((error: unknown) => ({ exitCode: 1, stdout: '', stderr: error instanceof Error ? error.message : String(error) }))
54 const mirrors = new Mirrors(git, cacheRoot)
55 const inputs = await readInputs($, configDir)
56 const repos = marketplaceRepos(await $.fs.read(`${configDir}/plugins/known_marketplaces.json`))
57 const skills = inputs.sources
58 .map(source => parseSourceFile(source.name, source.text))
59 .filter((skill): skill is CopiedSkill => skill !== undefined)
60 const findings = await Promise.all([
61 ...parseInstalls(inputs.installed).map(install => checkPlugin(git, mirrors, install, repos.get(install.marketplace))),
62 ...skills.map(skill => checkSkill(git, mirrors, skill)),
63 ])
64 return { checkedAt: await $.clock.now(), findings, inputs: inputs.fingerprint }
65}
66
67let running: Promise<Stored | undefined> | undefined
68
69function runCheck($: EngineInterface): Promise<Stored | undefined> {
70 running ??= (async () => {
71 try {
72 await $.store.set('lastAttempt', await $.clock.now())
73 const report = await check($)
74 await $.store.set('report', report)
75 $.ui.status(statusText(report))
76 await toastNews($, report)
77 return report
78 } catch (error) {
79 $.ui.log(`plugin-updates: check failed: ${error instanceof Error ? error.message : String(error)}`)
80 return undefined
81 } finally {
82 running = undefined
83 }
84 })()
85 return running
86}
87
88async function toastNews($: EngineInterface, report: Report) {
89 const keys = newsKeys(report)
90 const seen = new Set(((await $.store.get('toasted')) as string[] | undefined) ?? [])
91 await $.store.set('toasted', keys)
92 if (keys.some(key => !seen.has(key))) $.ui.toast(toastText(report), { timeoutMs: 12_000 })
93}
94
95// Sessions started together share the store: the attempt time written here keeps all but one from fetching the same mirrors.
96async function claimAttempt($: EngineInterface, now: number): Promise<boolean> {
97 const lastAttempt = ((await $.store.get('lastAttempt')) as number | undefined) ?? 0
98 if (now - lastAttempt < RETRY_AFTER) return false
99 await $.store.set('lastAttempt', now)
100 return true
101}
102
103export const register: Register = on => {
104 on('session.start', async ($, e, next) => {
105 await $.command.register({ name: 'plugin-updates', description: 'Check installed plugins and copied skills for upstream updates' })
106 const stored = (await $.store.get('report')) as Stored | undefined
107 const { configDir } = await paths($)
108 const changed = stored !== undefined && stored.inputs !== (await readInputs($, configDir)).fingerprint
109 const now = await $.clock.now()
110 $.ui.status(changed ? undefined : statusText(stored))
111 const due = !stored || changed || now - stored.checkedAt >= DAY
112 if (due && (changed || (await claimAttempt($, now)))) $.clock.after(START_DELAY, () => void runCheck($))
113 return next(e)
114 })
115
116 on('command.run', { command: 'plugin-updates' }, async $ => {
117 const report = await runCheck($)
118 if (!report) return { text: 'The check failed; the debug log has the reason.' }
119 return { text: reportText(report) }
120 })
121}
122hooks/checks.ts 388 lines1export type RunResult = { exitCode: number; stdout: string; stderr: string }
2export type Git = (args: readonly string[]) => Promise<RunResult>
3
4export type Finding = {
5 name: string
6 kind: 'plugin' | 'skill'
7 // 'behind': `behind` commits (or a version change) are missing; 'held': commits are missing but the plugin's
8 // version string is unchanged, so `claude plugin update` keeps the installed copy; 'unchecked': `reason` says why.
9 status: 'current' | 'behind' | 'held' | 'unchecked'
10 behind: number
11 commits: string[]
12 action?: string
13 reason?: string
14 upstream?: string
15 // The upstream commit compared against; a new one is news for the toast.
16 tip?: string
17}
18
19export type Report = { checkedAt: number; findings: Finding[] }
20
21type InstallEntry = { scope?: string; projectPath?: string; version?: string; gitCommitSha?: string }
22type SourceObject = { source?: string; repo?: string; url?: string; ref?: string; sha?: string; path?: string }
23type ListingEntry = { name?: string; version?: string; source?: string | SourceObject }
24type Listing = { metadata?: { pluginRoot?: string }; plugins?: ListingEntry[] }
25
26export type Install = { key: string; plugin: string; marketplace: string; version?: string; sha?: string; where: string }
27export type CopiedSkill = { name: string; url: string; commit: string; paths: string[] }
28
29const SHA = /^[0-9a-f]{7,40}$/
30const MAX_COMMITS = 10
31
32// Marketplace and listing JSON is third-party input that ends up in git argv, so only plain HTTPS URLs,
33// ref names and relative paths get through; no value may start with "-" and be read as an option.
34export function httpsUrl(source: { source?: string; repo?: string; url?: string } | undefined): string | undefined {
35 if (!source) return undefined
36 if (source.source === 'github' && typeof source.repo === 'string' && /^[\w.-]+\/[\w.-]+$/.test(source.repo)) {
37 return `https://github.com/${source.repo.replace(/\.git$/, '')}.git`
38 }
39 const url = source.url
40 if (typeof url !== 'string') return undefined
41 if (source.source === 'git-subdir' && /^[\w.-]+\/[\w.-]+$/.test(url)) return `https://github.com/${url.replace(/\.git$/, '')}.git`
42 return /^https:\/\/[\w.-]+(:\d+)?\/[\w.~%\/-]+$/.test(url) ? url : undefined
43}
44
45export function safeRef(ref: unknown): string | undefined {
46 if (typeof ref !== 'string' || !/^[\w./-]+$/.test(ref) || ref.startsWith('-') || ref.includes('..')) return undefined
47 return ref
48}
49
50// '' means the whole repository.
51export function safePath(path: unknown): string | undefined {
52 if (typeof path !== 'string') return undefined
53 const trimmed = path.replace(/^\.\/?/, '').replace(/\/+$/, '')
54 if (trimmed === '') return ''
55 const segments = trimmed.split('/')
56 if (segments.some(segment => segment === '' || segment === '.' || segment === '..') || trimmed.startsWith('-')) return undefined
57 return /^[\w./ -]+$/.test(trimmed) ? trimmed : undefined
58}
59
60export function mirrorDir(cacheRoot: string, url: string): string {
61 const segments = url
62 .replace(/^https:\/\//, '')
63 .replace(/\.git$/, '')
64 .split('/')
65 .map(segment => segment.replace(/[^\w.-]/g, '_'))
66 .filter(segment => segment !== '' && segment !== '.' && segment !== '..')
67 return `${cacheRoot}/${segments.join('/')}.git`
68}
69
70export function parseInstalls(json: string): Install[] {
71 const data = JSON.parse(json) as { plugins?: Record<string, InstallEntry[]> }
72 const installs: Install[] = []
73 for (const [key, entries] of Object.entries(data.plugins ?? {})) {
74 const at = key.lastIndexOf('@')
75 if (at <= 0) continue
76 const distinct = new Map<string, InstallEntry>()
77 for (const entry of entries) distinct.set(`${entry.gitCommitSha ?? ''}|${entry.version ?? ''}`, entry)
78 for (const entry of distinct.values()) {
79 installs.push({
80 key,
81 plugin: key.slice(0, at),
82 marketplace: key.slice(at + 1),
83 version: entry.version,
84 sha: typeof entry.gitCommitSha === 'string' && SHA.test(entry.gitCommitSha) ? entry.gitCommitSha : undefined,
85 where: distinct.size > 1 ? (entry.scope === 'user' ? 'user scope' : `${entry.scope ?? '?'} scope ${entry.projectPath ?? ''}`.trim()) : '',
86 })
87 }
88 }
89 return installs
90}
91
92export type MarketplaceRepo = { url: string; ref?: string; file: string }
93
94export function marketplaceRepos(json: string): Map<string, MarketplaceRepo | string> {
95 const data = JSON.parse(json) as Record<string, { source?: SourceObject }>
96 const repos = new Map<string, MarketplaceRepo | string>()
97 for (const [name, entry] of Object.entries(data)) {
98 const source = entry.source
99 const kind = source?.source
100 if (kind !== 'github' && kind !== 'git') {
101 repos.set(name, `marketplace source "${kind ?? 'unknown'}" is not a git repository`)
102 continue
103 }
104 const url = httpsUrl(kind === 'git' ? { source: 'url', url: source?.url } : source)
105 const file = source?.path === undefined ? '.claude-plugin/marketplace.json' : safePath(source.path)
106 if (!url || !file) {
107 repos.set(name, url ? 'marketplace file path is not a plain relative path' : 'marketplace URL is not a plain https URL')
108 continue
109 }
110 const ref = source?.ref === undefined ? undefined : safeRef(source.ref)
111 if (source?.ref !== undefined && !ref) {
112 repos.set(name, 'marketplace ref is not a plain ref name')
113 continue
114 }
115 repos.set(name, { url, ref, file })
116 }
117 return repos
118}
119
120export function parseSourceFile(name: string, text: string): CopiedSkill | undefined {
121 const match = /Source:\s*(https:\/\/\S+?)(?:\.git)?\s+\(commit ([0-9a-f]{7,40})\)/.exec(text)
122 if (!match) return undefined
123 const url = httpsUrl({ source: 'url', url: `${match[1]}.git` })
124 if (!url) return undefined
125 const pathLine = /^Path:\s*(.+)$/m.exec(text)?.[1] ?? ''
126 const paths = pathLine.split(/\s+/).filter(Boolean).map(safePath)
127 if (paths.some(path => path === undefined)) return undefined
128 return { name, url, commit: match[2]!, paths: paths.filter((path): path is string => !!path) }
129}
130
131// One fetch per repository and one listing read per marketplace for each check, shared by every plugin and skill.
132export class Mirrors {
133 private readonly synced = new Map<string, Promise<string | undefined>>()
134 private readonly listings = new Map<string, Promise<{ dir: string; tip: string; listing: Listing } | string>>()
135
136 constructor(
137 private readonly git: Git,
138 private readonly cacheRoot: string,
139 ) {}
140
141 // Resolves the mirror's directory, or undefined when it could not be cloned or fetched.
142 sync(url: string): Promise<string | undefined> {
143 const dir = mirrorDir(this.cacheRoot, url)
144 let pending = this.synced.get(dir)
145 if (!pending) {
146 pending = this.fetch(url, dir)
147 this.synced.set(dir, pending)
148 }
149 return pending
150 }
151
152 // The marketplace's latest listing, or why it could not be read.
153 listing(repo: MarketplaceRepo) {
154 const key = `${repo.url} ${repo.ref ?? ''} ${repo.file}`
155 let pending = this.listings.get(key)
156 if (!pending) {
157 pending = this.readListing(repo)
158 this.listings.set(key, pending)
159 }
160 return pending
161 }
162
163 private async readListing(repo: MarketplaceRepo) {
164 const dir = await this.sync(repo.url)
165 if (!dir) return `could not fetch ${repo.url}`
166 const tip = await resolveCommit(this.git, dir, repo.ref ?? 'HEAD')
167 if (!tip) return `marketplace ${repo.ref ?? 'default branch'} not found upstream`
168 const text = await readAt(this.git, dir, tip, repo.file)
169 if (text === undefined) return 'marketplace.json not found upstream'
170 try {
171 return { dir, tip, listing: JSON.parse(text) as Listing }
172 } catch {
173 return 'upstream marketplace.json is not valid JSON'
174 }
175 }
176
177 private async fetch(url: string, dir: string): Promise<string | undefined> {
178 // A bare mirror answers "."; anything else is a missing folder or a repository further up.
179 const existing = await this.git(['-C', dir, 'rev-parse', '--git-dir'])
180 if (existing.exitCode !== 0 || existing.stdout.trim() !== '.') {
181 const clone = await this.git(['clone', '--quiet', '--mirror', '--filter=blob:none', '--', url, dir])
182 return clone.exitCode === 0 ? dir : undefined
183 }
184 const fetched = await this.git(['-C', dir, 'fetch', '--quiet', '--prune', 'origin'])
185 if (fetched.exitCode !== 0) return undefined
186 // Fetch never moves a mirror's HEAD, so a renamed default branch would leave it pointing at nothing.
187 const head = await this.git(['-C', dir, 'ls-remote', '--symref', 'origin', 'HEAD'])
188 const branch = /^ref: (refs\/heads\/\S+)\tHEAD$/m.exec(head.stdout)?.[1]
189 if (head.exitCode === 0 && branch && safeRef(branch)) await this.git(['-C', dir, 'symbolic-ref', 'HEAD', branch])
190 return dir
191 }
192}
193
194async function resolveCommit(git: Git, dir: string, rev: string): Promise<string | undefined> {
195 const result = await git(['-C', dir, 'rev-parse', '--verify', '--quiet', '--end-of-options', `${rev}^{commit}`])
196 return result.exitCode === 0 ? result.stdout.trim() : undefined
197}
198
199async function readAt(git: Git, dir: string, commit: string, file: string): Promise<string | undefined> {
200 const result = await git(['-C', dir, 'show', `${commit}:${file}`])
201 return result.exitCode === 0 ? result.stdout : undefined
202}
203
204function finding(name: string, kind: Finding['kind'], fields: Partial<Finding>): Finding {
205 return { name, kind, status: 'unchecked', behind: 0, commits: [], ...fields }
206}
207
208// Counts and lists the commits between the installed `from` and `to` that touch `paths` (the whole repository when empty).
209async function compare(git: Git, dir: string, from: string, to: string, paths: readonly string[], base: Finding): Promise<Finding> {
210 const fromCommit = await resolveCommit(git, dir, from)
211 if (!fromCommit) return { ...base, reason: `installed commit ${from.slice(0, 7)} not found upstream` }
212 const range = [`${fromCommit}..${to}`, '--', ...paths]
213 const count = await git(['-C', dir, 'rev-list', '--count', ...range])
214 const log = await git(['-C', dir, 'log', `-n${MAX_COMMITS}`, '--format=%h %cs %s', ...range])
215 if (count.exitCode !== 0 || log.exitCode !== 0) return { ...base, reason: `git could not list the commits: ${(count.stderr || log.stderr).trim().split('\n')[0]}` }
216 const behind = Number(count.stdout.trim())
217 return { ...base, status: behind > 0 ? 'behind' : 'current', behind, commits: log.stdout.split('\n').filter(Boolean), tip: to }
218}
219
220function pluginPath(source: string, listing: Listing): string | undefined {
221 const root = listing.metadata?.pluginRoot
222 if (!source.startsWith('./') && !source.includes('/') && source !== '.' && root) return safePath(`${root.replace(/\/+$/, '')}/${source}`)
223 return safePath(source)
224}
225
226// Negative when `a` is older; versions that are not dotted numbers compare as different.
227export function compareVersions(a: string, b: string): number {
228 const parse = (version: string) => (/^\d+(\.\d+)*$/.test(version) ? version.split('.').map(Number) : undefined)
229 const left = parse(a)
230 const right = parse(b)
231 if (!left || !right) return a === b ? 0 : -1
232 for (let i = 0; i < Math.max(left.length, right.length); i++) {
233 const difference = (left[i] ?? 0) - (right[i] ?? 0)
234 if (difference !== 0) return difference
235 }
236 return 0
237}
238
239// The version Claude Code computes for a plugin: the manifest's `version`, else the marketplace entry's. Undefined when
240// neither is set, null when the manifest exists but could not be read.
241async function upstreamVersion(git: Git, dir: string, commit: string, path: string, entry: ListingEntry): Promise<string | undefined | null> {
242 const file = `${path ? `${path}/` : ''}.claude-plugin/plugin.json`
243 const listed = await git(['-C', dir, 'ls-tree', '--name-only', commit, '--', file])
244 if (listed.exitCode !== 0) return null
245 let version: unknown
246 if (listed.stdout.trim()) {
247 const manifest = await readAt(git, dir, commit, file)
248 if (manifest === undefined) return null
249 try {
250 version = (JSON.parse(manifest) as { version?: unknown }).version
251 } catch {
252 return null
253 }
254 }
255 if (typeof version === 'string' && version) return version
256 return entry.version || undefined
257}
258
259// Claude Code replaces an installed copy only when the computed version changes, so commits under an unchanged version are held.
260async function comparePlugin(git: Git, dir: string, install: Install & { sha: string }, target: string, path: string, entry: ListingEntry, base: Finding): Promise<Finding> {
261 const result = await compare(git, dir, install.sha, target, path ? [path] : [], base)
262 if (result.status !== 'behind' || !install.version) return result
263 if ((await upstreamVersion(git, dir, target, path, entry)) !== install.version) return result
264 return { ...result, status: 'held', reason: `version ${install.version} unchanged` }
265}
266
267export async function checkPlugin(git: Git, mirrors: Mirrors, install: Install, repo: MarketplaceRepo | string | undefined): Promise<Finding> {
268 const label = install.where ? `${install.key} (${install.where})` : install.key
269 const base = finding(label, 'plugin', { action: `claude plugin update ${install.key}` })
270 if (repo === undefined) return { ...base, reason: 'marketplace not found in known_marketplaces.json' }
271 if (typeof repo === 'string') return { ...base, reason: repo }
272
273 const read = await mirrors.listing(repo)
274 if (typeof read === 'string') return { ...base, reason: read }
275 const { dir, tip, listing } = read
276 const entry = listing.plugins?.find(plugin => plugin.name === install.plugin)
277 if (!entry?.source) return { ...base, reason: 'no longer listed in its marketplace' }
278
279 // A plugin installed without a commit (one inside a marketplace that is not a git checkout) is compared by version.
280 if (!install.sha) {
281 const path = typeof entry.source === 'string' ? pluginPath(entry.source, listing) : undefined
282 const version = path === undefined ? entry.version : await upstreamVersion(git, dir, tip, path, entry)
283 if (version === null) return { ...base, reason: 'upstream plugin.json could not be read' }
284 if (!version || !install.version) return { ...base, reason: 'no installed commit or version to compare' }
285 return compareVersions(install.version, version) >= 0
286 ? { ...base, status: 'current' }
287 : { ...base, status: 'behind', upstream: `version ${install.version} → ${version}`, tip: version }
288 }
289
290 if (typeof entry.source === 'string') {
291 const path = pluginPath(entry.source, listing)
292 if (path === undefined) return { ...base, reason: `unsupported source path "${entry.source}"` }
293 return comparePlugin(git, dir, { ...install, sha: install.sha }, tip, path, entry, base)
294 }
295
296 const source = entry.source
297 if (source.source !== 'github' && source.source !== 'url' && source.source !== 'git-subdir') {
298 return { ...base, reason: `"${source.source ?? 'unknown'}" plugin sources are not checked` }
299 }
300 const url = httpsUrl(source)
301 if (!url) return { ...base, reason: 'plugin source is not a plain https repository' }
302 const path = source.source === 'git-subdir' ? safePath(source.path) : ''
303 if (path === undefined) return { ...base, reason: 'git-subdir source has no plain relative path' }
304 const pinned = source.sha !== undefined ? (SHA.test(source.sha) ? source.sha : undefined) : undefined
305 const ref = source.ref !== undefined ? safeRef(source.ref) : 'HEAD'
306 if ((source.sha !== undefined && !pinned) || !ref) return { ...base, reason: 'plugin source has an unusual ref or sha' }
307 if (pinned === install.sha) return { ...base, status: 'current' }
308
309 const pluginDir = await mirrors.sync(url)
310 if (!pluginDir) return { ...base, reason: `could not fetch ${url}` }
311 const target = await resolveCommit(git, pluginDir, pinned ?? ref)
312 if (!target) return { ...base, reason: `upstream ${pinned ?? ref} not found` }
313 return comparePlugin(git, pluginDir, { ...install, sha: install.sha }, target, path, entry, base)
314}
315
316export async function checkSkill(git: Git, mirrors: Mirrors, skill: CopiedSkill): Promise<Finding> {
317 const base = finding(skill.name, 'skill', {})
318 const dir = await mirrors.sync(skill.url)
319 if (!dir) return { ...base, reason: `could not fetch ${skill.url}` }
320 const tip = await resolveCommit(git, dir, 'HEAD')
321 if (!tip) return { ...base, reason: 'upstream default branch not found' }
322 const github = /^https:\/\/github\.com\/([\w.-]+\/[\w.-]+?)\.git$/.exec(skill.url)?.[1]
323 const action = github
324 ? `review https://github.com/${github}/compare/${skill.commit}...${tip.slice(0, 12)} before copying`
325 : `review the upstream diff since ${skill.commit} before copying`
326 return compare(git, dir, skill.commit, tip, skill.paths, { ...base, action })
327}
328
329export function pending(report: Report | undefined): Finding[] {
330 return report?.findings.filter(item => item.status === 'behind') ?? []
331}
332
333function amount(item: Finding): string {
334 return item.upstream ?? `${item.behind} commit${item.behind === 1 ? '' : 's'}`
335}
336
337export function statusText(report: Report | undefined): string | undefined {
338 // Installs of one plugin in several scopes show once, with the larger gap.
339 const byName = new Map<string, Finding>()
340 for (const item of pending(report)) {
341 const name = item.name.split('@')[0]!.split(' ')[0]!
342 const shown = byName.get(name)
343 if (!shown || item.behind > shown.behind) byName.set(name, item)
344 }
345 if (byName.size === 0) return undefined
346 const parts = [...byName].slice(0, 3).map(([name, item]) => `${name} ${item.upstream ? 'new version' : item.behind}`)
347 return `updates: ${parts.join(', ')}${byName.size > 3 ? ` +${byName.size - 3}` : ''}`
348}
349
350// One entry per pending item and upstream commit; the toast fires only for an entry not seen before.
351export function newsKeys(report: Report | undefined): string[] {
352 return pending(report).map(item => `${item.name}:${item.tip ?? item.upstream ?? ''}`)
353}
354
355export function toastText(report: Report): string {
356 const behind = pending(report)
357 const names = behind.slice(0, 3).map(item => `${item.name.split(' ')[0]} (${amount(item)})`)
358 return `Updates available: ${names.join(', ')}${behind.length > 3 ? ` and ${behind.length - 3} more` : ''}. /plugin-updates lists them.`
359}
360
361function commitLines(item: Finding): string[] {
362 const lines = item.commits.map(commit => ` ${commit}`)
363 if (item.behind > item.commits.length) lines.push(` … ${item.behind - item.commits.length} older`)
364 return lines
365}
366
367export function reportText(report: Report): string {
368 const lines: string[] = []
369 const behind = pending(report)
370 const unchecked = report.findings.filter(item => item.status === 'unchecked')
371 const current = report.findings.filter(item => item.status === 'current')
372 const held = report.findings.filter(item => item.status === 'held')
373 lines.push(`${behind.length} behind, ${held.length ? `${held.length} held, ` : ''}${current.length} current, ${unchecked.length} not checked.`)
374 for (const item of behind) {
375 lines.push('', `${item.kind === 'skill' ? 'Skill' : 'Plugin'} ${item.name}: ${amount(item)} behind`, ...commitLines(item))
376 if (item.action) lines.push(` → ${item.action}`)
377 }
378 for (const item of held) {
379 lines.push('', `Plugin ${item.name}: ${amount(item)} upstream, held until the author bumps the version (${item.reason})`, ...commitLines(item))
380 }
381 if (unchecked.length > 0) {
382 lines.push('', 'Not checked:')
383 for (const item of unchecked) lines.push(` ${item.name}: ${item.reason ?? 'unknown'}`)
384 }
385 if (current.length > 0) lines.push('', `Current: ${current.map(item => item.name).join(', ')}`)
386 return lines.join('\n')
387}
388