SLOPSHOPPER

Updates

A weekly check of your Claude and Codex plugins, global npm packages and adapted skills for newer versions, with a one-line summary of each

newcommandmodelprocesstimer
v0.2.0Apache-2.0updated 2026-10-04hacksurvivor/claude-mods/mods/updates
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · updates
› 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 › /updates ⎿ updates: Couldn't check for updates: the check printed nothing ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Updates

A Claude Code mod that checks once a week what has a newer version: Claude Code plugins from git marketplaces, your global npm packages, and anything else you list. Haiku writes one line per update on what changed. /updates checks now.

Updates: /updates in the chat, and the Updates tab of The Tree of Mods with Update all, the updates and a copy to review

Updates only reads. To see and apply what it found, use the Updates tab of The Tree of Mods: Update all, Update per row, and Review for copies you adapted from someone else's repo, which are never overwritten.

What it checks

  • Claude Code plugins installed from git or GitHub marketplaces (the official marketplace updates itself).
  • Global npm packages with a newer release, except linked ones.
  • And, from ~/.claude/updates/sources.json:
{
  "updaters": [{ "script": "my-tool-update" }],
  "updaterDir": "~/.local/bin",
  "adapted": [{ "id": "my-skill", "name": "my skill", "repo": "owner/repo",
                "pinFile": "~/skills/my-skill/SOURCE.md", "pinPattern": "Revision: `([0-9a-f]{40})`",
                "paths": ["skills/"], "local": "~/skills/my-skill" }],
  "codexPlugins": [{ "name": "my-plugin", "repo": "owner/repo" }],
  "skipMarketplaces": ["claude-plugins-official"],
  "npmSkip": ["a-package-an-updater-handles"]
}

updaters are your own update scripts with a --check mode that prints lines like ==> name: 1.2.0 -> 1.3.0 available. adapted are copies of someone else's repo: Updates reads the upstream revision you started from in pinFile and compares it with the repo's latest.

Requirements

  • Claude Code 2.1.286 or later (mods), Python 3, npm, and the GitHub CLI (gh) signed in, for release and commit checks.
  • The Tree of Mods to apply updates.

Install

In Claude Code:

/plugin marketplace add hacksurvivor/claude-mods
/plugin install updates@hacksurvivor
/reload-plugins

What it runs, reads and sends

  • Runs bin/check.py once a week (and on /updates). It calls gh api (GET only), npm ls -g and npm outdated -g, and your listed updaters with --check. It installs nothing.
  • Reads ~/.claude/plugins (installed plugins and marketplaces), ~/.claude/settings.json (which plugins are on), Codex's plugin cache, and the files your config names.
  • Sends the updates' release notes to Claude's Haiku model, under your Claude account, for the one-line summaries.
  • Keeps the latest report in Claude Code's plugin store on your computer.

See PRIVACY.md.

License

Apache-2.0

Source 3 files
hooks/register.tsx 85 lines
1import { atom, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Report } from '../types'
5import { describe, isDue, parse, summaryPrompt, withLines } from './check'
6
7// A weekly look at the person's Claude and Codex plugins, skills and CLIs.
8// bin/check.py only reads; the report lands in this plugin's store, where the
9// Updates tab in The Tree of Mods reads it and applies what is ready. /updates checks now.
10
11const CHECK_TIMEOUT_MS = 180_000
12const FIRST_LOOK_MS = 20_000
13const REPORT_KEY = 'report'
14
15const isChecking = atom({ plugin: 'updates', key: 'isChecking' } as const, false)
16
17// The check in flight; a second caller waits for it instead of starting another.
18let checking: Promise<Report | string> | undefined
19
20function check($: EngineInterface): Promise<Report | string> {
21  checking ??= look($).finally(() => {
22    checking = undefined
23  })
24
25  return checking
26}
27
28async function look($: EngineInterface): Promise<Report | string> {
29  await update($, isChecking, () => true)
30
31  try {
32    // Through a login shell, so gh, npm and the person's updaters are on the PATH.
33    const ran = await $.process.run(['/bin/zsh', '-lc', '"$@"', 'updates', 'python3', `${$.plugin.root}/bin/check.py`], {
34      timeoutMs: CHECK_TIMEOUT_MS,
35    })
36    const found = parse(ran.stdout)
37
38    if ('error' in found) {
39      return found.error
40    }
41
42    const written = await summarise($, found.report)
43    await $.store.set(REPORT_KEY, written)
44
45    return written
46  } catch (error) {
47    return (error instanceof Error ? error.message : String(error)).slice(0, 200)
48  } finally {
49    await update($, isChecking, () => false)
50  }
51}
52
53// One short line per item, from its notes; the plain fallback when it can't.
54async function summarise($: EngineInterface, found: Report): Promise<Report> {
55  const worded = found.items.filter(item => item.kind !== 'manual' && item.notes.trim() !== '')
56
57  if (worded.length === 0) {
58    return found
59  }
60
61  try {
62    const reply = await $.model.complete({ model: 'haiku', prompt: summaryPrompt(worded), maxTokens: 600, effort: 'low' })
63
64    return reply.isAnswered ? withLines(found, reply.text) : found
65  } catch {
66    return found
67  }
68}
69
70export const register: Register = on => {
71  on('session.start', async ($, e, next) => {
72    await $.command.register({ name: 'updates', description: 'Check your plugins, skills and CLIs for updates now', immediate: true })
73
74    const kept = (await $.store.get(REPORT_KEY)) as Report | undefined
75
76    if (isDue(kept ?? null, await $.clock.now())) {
77      $.clock.after(FIRST_LOOK_MS, () => void check($))
78    }
79
80    return next(e)
81  })
82
83  on('command.run', { command: 'updates' }, async $ => ({ text: describe(await check($)) }))
84}
85
hooks/check.ts 97 lines
1import type { Item, Report } from '../types'
2
3export const WEEK_MS = 7 * 24 * 60 * 60_000
4
5// What the checker printed: a report, or why there is none.
6export function parse(stdout: string): { report: Report } | { error: string } {
7  const line = stdout.trim().split('\n').at(-1) ?? ''
8
9  if (line === '') {
10    return { error: 'the check printed nothing' }
11  }
12
13  try {
14    const found = JSON.parse(line) as Partial<Report> & { error?: string }
15
16    if (typeof found.error === 'string') {
17      return { error: found.error }
18    }
19
20    return { report: { checkedAt: found.checkedAt ?? '', items: found.items ?? [], errors: found.errors ?? [] } }
21  } catch {
22    return { error: `the check printed something else: ${line.slice(0, 120)}` }
23  }
24}
25
26export function isDue(report: Report | null, now: number): boolean {
27  if (report === null || report.checkedAt === '') {
28    return true
29  }
30
31  return now - Date.parse(report.checkedAt) >= WEEK_MS
32}
33
34export const ready = (report: Report | null): Item[] => report?.items.filter(item => item.kind === 'ready') ?? []
35
36export function versions(item: Item): string {
37  if (item.from === '' || item.from === item.to) {
38    return item.to
39  }
40
41  return `${item.from} → ${item.to}`
42}
43
44// The model's brief: one short line per item, from its notes.
45export function summaryPrompt(items: Item[]): string {
46  const rows = items.map(item => ({ id: item.id, name: item.name, from: item.from, to: item.to, notes: item.notes }))
47
48  return [
49    'For each update below, write what changed in at most seven plain words, for a developer deciding whether',
50    'to update. Across several releases, name the biggest new capability or fix; skip release chores, docs,',
51    'version bumps and merge commits. No trailing period, no quotes, no marketing words.',
52    'Answer with one JSON object mapping each id to its line, and nothing else.',
53    '',
54    JSON.stringify(rows),
55  ].join('\n')
56}
57
58export function withLines(report: Report, answer: string): Report {
59  const json = answer.slice(answer.indexOf('{'), answer.lastIndexOf('}') + 1)
60  let lines: Record<string, unknown> = {}
61
62  try {
63    lines = JSON.parse(json) as Record<string, unknown>
64  } catch {
65    return report
66  }
67
68  return {
69    ...report,
70    items: report.items.map(item => {
71      const line = lines[item.id]
72
73      return typeof line === 'string' && line.trim() !== '' ? { ...item, line: line.trim().replace(/\.$/, '') } : item
74    }),
75  }
76}
77
78// /updates: what the check found, and where to apply it.
79export function describe(found: Report | string): string {
80  if (typeof found === 'string') {
81    return `Couldn't check for updates: ${found}`
82  }
83
84  const updates = ready(found)
85  const others = found.items.length - updates.length
86
87  if (found.items.length === 0) {
88    return 'Everything is up to date.'
89  }
90
91  const list = updates.map(item => `${item.name} ${versions(item)}`).join(', ')
92  const head = updates.length === 0 ? 'Nothing to update by itself' : `${updates.length} ready: ${list}`
93  const rest = others > 0 ? `; ${others} more to review` : ''
94
95  return `${head}${rest}. Apply them in /mods, on the Updates tab.`
96}
97
types/index.d.ts 32 lines
1// One thing with a newer upstream, as bin/check.py reports it.
2export type Item = {
3  kind: 'ready' | 'review' | 'manual'
4  id: string
5  name: string
6  from: string
7  to: string
8  // Release notes or commit subjects, for the one-line summary.
9  notes: string
10  // ready: the commands Update runs, in order.
11  apply?: string[][]
12  // ready: a Claude plugin, so plugins reload afterwards.
13  reload?: boolean
14  // review: the prompt that starts the merge with Claude.
15  review?: string
16  // manual: the command the person runs.
17  command?: string
18  commits?: number
19  // The one-line summary, written once per check.
20  line?: string
21}
22
23export type Report = { checkedAt: string; items: Item[]; errors: string[] }
24
25declare module 'claude-code' {
26  interface PluginState {
27    updates: {
28      isChecking: boolean
29    }
30  }
31}
32