SLOPSHOPPER

Uptick

Lists updates for Claude Code, plugins, skills-CLI skills and Homebrew in a pane, and runs one only after you confirm it.

newpanebandcommandtoaststatus
v0.1.0MITupdated 2026-10-24trustmeimlumi/uptick/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · uptick
│ ┃ Uptick ✕ › fix the failing auth test and add an audit log call │ ┃ Uptick · check failed │ ┃ ✗ checker exited 0: no JSON output ⏺ Read(src/auth.ts) │ ┃ run it in a terminal to see the whole ⎿ Read 6 lines │ ┃ error: python3 -I -X utf8 ⏺ Update(src/auth.ts) │ ┃ /plugins/uptick/checker/check_updates.py ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ r: Check now q: Close ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /uptick │ ⎿ uptick: Uptick: not checked yet │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Uptick
Uptick · check failed ✗ checker exited 0: no JSON output run it in a terminal to see the whole error: python3 -I -X utf8 /plugins/uptick/checker/check_updates.py r: Check now q: Close
README

Uptick

Uptick shows what is out of date around Claude Code and updates it only after you confirm the exact command. Type /uptick to open a pane that lists newer versions of Claude Code itself, your plugins, the skills you installed with the skills CLI, and your Homebrew packages. Pick one item, or all of them, read the command it will run, and confirm. A line above the prompt tells you when updates are waiting.

Where it works

Claude Code on your own computer only. In the terminal everything works. In the local session of the desktop app the pane and the line above the prompt draw, but /uptick does not run there; the line's Show button opens the pane. It needs Python 3.9 or newer on that computer. It does not work on claude.ai on the web or on mobile, or in Cowork.

What it runs, and when

  • When the pane opens, when you press Check now, again after each run of confirmed updates, and for a claude -p "/uptick" report: the bundled checker, with python3 (on Windows, the py launcher). The checker asks claude, npm, gh and brew for installed and latest versions. It reads only; with Check now (or on every open, if you turn on fetchOnOpen) it first refreshes marketplace and Homebrew metadata.
  • Only after you press Update in the confirm step: one fixed command per item, built from a template and the item's name, which must match a strict pattern. A plugin updates through claude, a skill through npx running the skills CLI (pinned to version 1.7.4), a Homebrew package through brew. Each runs from your home folder, as an argument list without a shell; on Windows, npx and claude start through cmd.exe.
  • Reload plugins never runs anything: it only types /reload-plugins into your prompt, and you press Enter.
  • Your own extra check command, if you set one in the settings.

The plugin also adds uptick-check, the same check as a command line for a daily scheduler. When it finds updates or problems, or the check fails, it sends a desktop notification (osascript on macOS, notify-send on Linux); --no-notify turns that off. If a settings file in your config folder exists, it runs that file as shell code to set its options (UPTICK_BREWFILE, UPTICK_KNOWN_BROKEN, UPTICK_CHECK_COMMAND, UPTICK_KINDS, UPTICK_PATH), and it puts UPTICK_PATH, or by default your ~/.local/bin and the usual Homebrew folders, before PATH.

What it reads

Its own cache file (the last result, in your cache folder), the metadata of your installed plugins and skills, a Brewfile if you set one, and a known-broken list if you set one or the default file exists in your config folder. It reads the environment variables HOME, USERPROFILE, OS, XDG_CACHE_HOME and XDG_CONFIG_HOME to find those files.

What it passes on: the item names to the update commands; your marketplace names to claude plugin marketplace update; the GitHub repositories, folders and refs named in your plugin and skill metadata to gh api, which asks GitHub for them; and the Brewfile path, the known-broken path and your extra check command to the checker.

What it sends

Uptick itself sends nothing anywhere and has no telemetry. The programs it starts fetch what they need: the npm registry for the latest Claude Code version, the GitHub API through gh for skill folders and plugin manifests, your marketplaces and Homebrew when you check now, and whatever an update you confirm downloads.

What its hooks do

On session start it registers /uptick and reads the cache. It answers the /uptick command and passes every other command on untouched. It notes when its own pane is closed, and it draws its pane and its line above the prompt, which stays while Claude works.

Install

At the prompt of a Claude Code session in a terminal:

/plugin install uptick --marketplace trustmeimlumi/uptick

Full documentation, settings and source: https://github.com/trustmeimlumi/uptick

Source 3 files
hooks/register.tsx 854 lines
1import { atom, read, update } from 'claude-code'
2import type { Args, EngineInterface, Next, PluginOptions, Register, RenderChildren, RenderInput, Timer } from 'claude-code'
3
4import type { CheckError, CheckResult, Run, Step, ToolItem, View } from '../types'
5import {
6  AFTER,
7  argvFor,
8  checkerArgv,
9  checkHint,
10  clean,
11  cleanLines,
12  elapsed,
13  fit,
14  hasOffline,
15  keyOf,
16  lastLine,
17  NO_PYTHON,
18  onHost,
19  parseKinds,
20  parseResult,
21  report,
22  signature,
23  sortItems,
24  UPDATE_ENV,
25  when,
26} from './logic.ts'
27
28const PANE = 'uptick'
29const TITLE = 'Uptick'
30const COMMAND = 'uptick'
31const TEN_MINUTES = 600_000
32const FIFTEEN_MINUTES = 900_000
33const SCOPE: Record<string, string> = { plugin: 'plugins', skill: 'skills', brew: 'Homebrew' }
34const GROUP: Record<string, string> = { plugin: 'Plugins', skill: 'Skills', brew: 'Homebrew' }
35const DAY = 86_400_000
36
37const resultA = atom({ plugin: 'uptick', key: 'result' } as const, null)
38const checkA = atom({ plugin: 'uptick', key: 'check' } as const, null)
39const checkErrorA = atom({ plugin: 'uptick', key: 'checkError' } as const, null)
40const viewA = atom({ plugin: 'uptick', key: 'view' } as const, { name: 'list' })
41const runA = atom({ plugin: 'uptick', key: 'run' } as const, null)
42const nowA = atom({ plugin: 'uptick', key: 'now' } as const, 0)
43const hiddenA = atom({ plugin: 'uptick', key: 'hiddenFor' } as const, null)
44const whereA = atom({ plugin: 'uptick', key: 'where' } as const, null)
45
46type Engine = EngineInterface
47type DrawInput = RenderInput<'Pane'> | RenderInput<'AbovePrompt'>
48
49const isRunning = (run: Run | null) => run !== null && run.endedAt === undefined
50const message = (err: unknown) => (err instanceof Error ? err.message : String(err))
51
52// One load's configuration and session facts. A reload starts a fresh module, and runs register and
53// session.start again; until session.start says otherwise, the read-only (headless) path applies.
54const config: { options: PluginOptions; kinds: string[]; isInteractive: boolean } = { options: {}, kinds: [], isInteractive: false }
55let ticker: Timer | undefined
56let cacheTimer: Timer | undefined
57
58// The engine names no platform; Windows sets OS, and has USERPROFILE where it has no HOME.
59async function isWindows($: Engine): Promise<boolean> {
60  const os = await $.env.get('OS')
61  return os === 'Windows_NT'
62}
63
64async function homeDir($: Engine): Promise<string | undefined> {
65  const home = await $.env.get('HOME')
66  if (home) return home
67  const profile = await $.env.get('USERPROFILE')
68  return profile || undefined
69}
70
71// Commands run from the home folder, not the session's: there a project's own tool shims or npm config
72// could stand in for the `npx skills` or `npm view` the person approved.
73async function homeCwd($: Engine): Promise<{ cwd?: string }> {
74  const home = await homeDir($)
75  return home ? { cwd: home } : {}
76}
77
78async function cachePath($: Engine): Promise<string | undefined> {
79  // The checker's own default: $XDG_CACHE_HOME or ~/.cache, then uptick/last.json.
80  const xdg = await $.env.get('XDG_CACHE_HOME')
81  const home = await homeDir($)
82  return xdg ? `${xdg}/uptick/last.json` : home ? `${home}/.cache/uptick/last.json` : undefined
83}
84
85async function readCache($: Engine): Promise<CheckResult | null> {
86  const path = await cachePath($)
87  if (!path) return null
88  try {
89    const text = await $.fs.read(path)
90    return parseResult(text)
91  } catch {
92    return null // no cache yet
93  }
94}
95
96// A scheduled check (extras/) rewrites the cache while sessions run: a newer result replaces the one shown.
97async function refreshFromCache($: Engine) {
98  const check = await read($, checkA)
99  if (check) return
100  try {
101    const cached = await readCache($)
102    const current = await read($, resultA)
103    if (cached && (!current || Date.parse(cached.checkedAt) > Date.parse(current.checkedAt))) await publish($, cached)
104  } catch (err) {
105    $.ui.log(`uptick: cache not read: ${message(err)}`, { to: 'debug' })
106  }
107}
108
109async function publish($: Engine, result: CheckResult) {
110  await update($, resultA, () => result)
111  if (config.options.hint === 'status') {
112    const n = sortItems(result, config.kinds).updatable.length
113    $.ui.status(n ? `↑ ${n} tool update${n === 1 ? '' : 's'} · /uptick` : undefined)
114  }
115}
116
117// The elapsed timers redraw from `now`; one ticker while a check or a run is under way.
118async function tick($: Engine) {
119  const now = await $.clock.now()
120  await update($, nowA, () => now)
121}
122
123function startTicker($: Engine) {
124  if (ticker) return
125  ticker = $.clock.every(1000, () => void tick($))
126}
127
128async function stopTickerWhenIdle($: Engine) {
129  const check = await read($, checkA)
130  const run = await read($, runA)
131  if (!check && !isRunning(run)) {
132    ticker?.cancel()
133    ticker = undefined
134  }
135}
136
137async function runChecker($: Engine, isFetch: boolean): Promise<{ result: CheckResult } | { error: CheckError }> {
138  const root = $.plugin.root
139  const onWindows = await isWindows($)
140  const cache = await cachePath($)
141  const argv = checkerArgv(root, cache, isFetch, config.options, onWindows)
142  const hint = (failure: Parameters<typeof checkHint>[0]) => checkHint(failure, onWindows, root)
143  try {
144    const cwd = await homeCwd($)
145    const ran = await $.process.run(argv, { timeoutMs: TEN_MINUTES, ...cwd })
146    const result = parseResult(ran.stdout)
147    if (result) return { result }
148    const text = `checker exited ${ran.exitCode}: ${lastLine(ran.stderr) || 'no JSON output'}`
149    return { error: { text, hint: hint(NO_PYTHON.test(`${ran.stderr}\n${ran.stdout}`) ? 'start' : 'output') } }
150  } catch (err) {
151    const text = message(err)
152    return /time/i.test(text)
153      ? { error: { text: 'check timed out', hint: hint('timeout') } }
154      : { error: { text: `checker could not start (${argv[0]}): ${clean(text)}`, hint: hint('start') } }
155  }
156}
157
158async function startCheck($: Engine, isFetch: boolean) {
159  const startedAt = await $.clock.now()
160  let isStarted = false
161  await update($, checkA, check => {
162    isStarted = check === null
163    return check ?? { startedAt, isFetch }
164  })
165  if (!isStarted) return
166  try {
167    await update($, checkErrorA, () => null)
168    startTicker($)
169    const out = await runChecker($, isFetch)
170    if ('result' in out) await publish($, out.result)
171    await update($, checkErrorA, () => ('error' in out ? out.error : null))
172  } catch (err) {
173    await update($, checkErrorA, () => ({ text: clean(message(err)), hint: '' }))
174  } finally {
175    await update($, checkA, () => null)
176    await stopTickerWhenIdle($)
177  }
178}
179
180async function openPane($: Engine) {
181  const run = await read($, runA)
182  if (!isRunning(run)) {
183    await update($, runA, () => null)
184    await update($, viewA, () => ({ name: 'list' }))
185  }
186  const result = await read($, resultA)
187  // 11 rows besides the items: the heading, the ✓ line, five group headers, the action row and its margin,
188  // and two for a Known issue's detail line or a wrapped problem.
189  const rows = Math.min(24, 11 + (result ? result.items.length + result.problems.length + result.skipped.length : 2))
190  const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true, rows, columns: 64 })
191  // Not placed (no surface that places panes): the pane's tree draws in the band instead.
192  await update($, whereA, () => (opened.isPlaced ? 'pane' : 'band'))
193  if (!isRunning(run)) void startCheck($, config.options.fetchOnOpen === true)
194}
195
196async function patchStep($: Engine, index: number, patch: Partial<Step>) {
197  await update($, runA, run => run && { ...run, steps: run.steps.map((s, i) => (i === index ? { ...s, ...patch } : s)) })
198}
199
200/** Runs the confirmed items one by one; reached only from the confirm state's `[ Update ]` Button. */
201async function startRun($: Engine, keys: readonly string[]) {
202  const check = await read($, checkA)
203  if (check) return // the check may be running brew itself
204  const result = await read($, resultA)
205  const { updatable } = sortItems(result, config.kinds)
206  const steps: Step[] = []
207  for (const key of keys) {
208    const item = updatable.find(i => keyOf(i) === key)
209    const argv = item && argvFor(item)
210    if (item && argv) steps.push({ key, name: item.name, kind: item.kind, have: item.have, latest: item.latest, argv, status: 'queued' })
211  }
212  if (!steps.length) return
213  const startedAt = await $.clock.now()
214  let isStarted = false
215  await update($, runA, run => {
216    isStarted = !isRunning(run)
217    return isStarted ? { steps, startedAt, isStopping: false } : run
218  })
219  if (!isStarted) return
220  await update($, viewA, () => ({ name: 'result' }))
221  startTicker($)
222  // One $.process.run per item, so no live output. Move to $.process.spawn once a stream started from a
223  // button press is known to outlive the press.
224  for (const [index, step] of steps.entries()) {
225    const current = await read($, runA)
226    if (current?.isStopping) {
227      await update($, runA, run => run && { ...run, steps: run.steps.map(s => (s.status === 'queued' ? { ...s, status: 'skipped' as const } : s)) })
228      break
229    }
230    await patchStep($, index, { status: 'running' })
231    try {
232      const env = UPDATE_ENV[step.kind]
233      const onWindows = await isWindows($)
234      const cwd = await homeCwd($)
235      const ran = await $.process.run(onHost(step.argv, onWindows), { timeoutMs: TEN_MINUTES, stdin: '', ...(env ? { env } : {}), ...cwd })
236      const log = cleanLines(`${ran.stdout}\n${ran.stderr}`.trim(), 20_000)
237      // The skills CLI exits 0 when no installed skill has the name: nothing was updated.
238      const missed = step.kind === 'skill' ? /No installed skills found matching.*/.exec(log)?.[0] : undefined
239      await patchStep(
240        $,
241        index,
242        ran.exitCode === 0 && !missed
243          ? { status: 'ok', log }
244          : missed
245            ? { status: 'failed', errorLine: clean(missed), log }
246            : { status: 'failed', exitCode: ran.exitCode, errorLine: lastLine(ran.stderr) || lastLine(ran.stdout) || `exit ${ran.exitCode}`, log },
247      )
248    } catch (err) {
249      const text = message(err)
250      await patchStep($, index, { status: 'failed', errorLine: /time/i.test(text) ? 'timed out after 10 minutes' : `could not start ${step.argv[0]}`, log: clean(text, 2_000) })
251    }
252  }
253  const endedAt = await $.clock.now()
254  const ended = await update($, runA, run => run && { ...run, endedAt })
255  await stopTickerWhenIdle($)
256  // In the band fallback the result is on screen already.
257  const where = await read($, whereA)
258  let isShown = where === 'band'
259  if (!isShown) {
260    const panes = await $.ui.panes()
261    isShown = panes.some(p => p.id === PANE && p.isShown)
262  }
263  if (!isShown && ended) {
264    const ok = ended.steps.filter(s => s.status === 'ok').length
265    const failed = ended.steps.filter(s => s.status === 'failed').length
266    $.ui.toast(`${TITLE}: ${ok} updated${failed ? `, ${failed} failed` : ''}`)
267  }
268  void startCheck($, false) // so the band and the list count what is still outdated
269}
270
271// Only types the command into the prompt: the person runs it with Enter.
272async function reloadPlugins($: Engine) {
273  await $.prompt.fill({ text: '/reload-plugins' })
274}
275
276async function closePane($: Engine) {
277  const run = await read($, runA)
278  if (!isRunning(run)) {
279    await update($, runA, () => null)
280    await update($, viewA, () => ({ name: 'list' }))
281  }
282  await $.ui.close({ id: PANE })
283  await update($, whereA, () => null)
284}
285
286/**
287 * The pane's tree, for its own pane or, when an open was not placed, for the band. In the band no
288 * Button has a digit hotkey (a digit typed into an empty prompt can press one), and Close sits on the
289 * heading row with no hotkey.
290 */
291async function draw($: Engine, e: DrawInput, isBand: boolean) {
292  const { Box, Text, Button, Code } = $.ui.resolve(e)
293  const { kinds } = config
294  const result = await read($, resultA)
295  const check = await read($, checkA)
296  const checkError = await read($, checkErrorA)
297  const view = await read($, viewA)
298  const run = await read($, runA)
299  await read($, nowA) // subscribes the drawing to the ticker
300  const now = await $.clock.now()
301  const w = e.props.bodyColumns
302  const sorted = sortItems(result, kinds)
303
304  // Columns: name capped at 40 % of the width, then versions, then the tag or note. A run's steps count
305  // too: after the check that follows a run, the updated items are no longer in the list.
306  const rowsOf = [...sorted.updatable, ...sorted.broken, ...sorted.info, ...(run?.steps ?? [])]
307  const nameW = Math.max(8, Math.min(Math.max(0, ...rowsOf.map(i => i.name.length)), Math.floor(w * 0.4)))
308  const verW = Math.max(0, Math.min(Math.max(0, ...rowsOf.map(i => i.have.length + i.latest.length + 3)), w - 3 - nameW - 4 - 8))
309
310  const setView = async (v: View) => {
311    await update($, viewA, () => v)
312  }
313  const confirmFor = (ids: string[]) => setView({ name: 'confirm', ids })
314  const digit = (index: number) => (!isBand && index < 9 ? { hotkey: String(index + 1) } : {})
315
316  // In the band the heading row ends with Close; the pane's frame has its own close mark and `q`.
317  const headRow = (text: RenderChildren) =>
318    isBand ? (
319      <Box flexDirection="row" gap={2} width={w}>
320        {text}
321        <Box flexGrow={1} />
322        <Button key="band-close" plain label="Close" onPress={() => closePane($)} />
323      </Box>
324    ) : (
325      text
326    )
327  const heading = (status: string) =>
328    headRow(
329      <Text wrap="truncate-end">
330        <Text weight="bold">{TITLE}</Text>
331        <Text dimColor> · {status}</Text>
332      </Text>,
333    )
334  const actions = (...buttons: RenderChildren[]) => (
335    <Box marginTop={1} gap={3} flexWrap="wrap">
336      {buttons.filter(Boolean)}
337    </Box>
338  )
339  const close = isBand ? null : <Button key="close" plain hotkey="q" role="dismiss" label="Close" onPress={() => closePane($)} />
340  // Check now fetches first (marketplaces, `brew update`); it can take a minute.
341  // Offline it only checks: a fetch would wait out every timeout for nothing.
342  const checkNow = (
343    <Button
344      key="check"
345      plain
346      hotkey="r"
347      label="Check now"
348      onPress={async () => {
349        await update($, runA, run => (isRunning(run) ? run : null))
350        await update($, viewA, () => ({ name: 'list' }))
351        await startCheck($, !hasOffline(sorted.problems))
352      }}
353    />
354  )
355
356  const itemRow = (item: ToolItem, index: number) => {
357    const key = `item:${keyOf(item)}`
358    const hotkey = digit(index)
359    const lead = 'hotkey' in hotkey ? '' : '   '
360    const select = () => confirmFor([keyOf(item)])
361    const cask = item.cask === true ? 'cask' : ''
362    if (w < 40)
363      return (
364        <Button key={key} plain {...hotkey} onPress={select}>
365          {lead}
366          {fit(item.name, w - 6).trimEnd()} <Text color="warning">↑</Text>
367        </Button>
368      )
369    const versions = [<Text dimColor>{item.have}</Text>, ' → ', <Text color="warning">{item.latest}</Text>]
370    if (w < 60)
371      return (
372        <Box key={key} flexDirection="column">
373          <Button key={key} plain {...hotkey} onPress={select}>
374            {lead}
375            {fit(item.name, w - 3).trimEnd()}
376          </Button>
377          <Text wrap="truncate-end">
378            {'     '}
379            {versions}
380            {cask ? <Text dimColor>{`  ${cask}`}</Text> : null}
381          </Text>
382        </Box>
383      )
384    const pad = ' '.repeat(Math.max(0, verW - item.have.length - item.latest.length - 3))
385    return (
386      <Button key={key} plain {...hotkey} onPress={select}>
387        {lead}
388        {fit(item.name, nameW)}
389        {'  '}
390        {versions}
391        {cask ? [pad, '  ', <Text dimColor>{cask}</Text>] : null}
392      </Button>
393    )
394  }
395
396  const infoRow = (item: ToolItem) => {
397    const note = item.note ?? 'updates itself'
398    if (w < 40) return <Text dimColor wrap="truncate-end">{`   ${item.name}`}</Text>
399    if (w < 60)
400      return (
401        <Box flexDirection="column">
402          <Text dimColor wrap="truncate-end">{`   ${item.name}`}</Text>
403          <Text dimColor wrap="truncate-end">{`     ${item.have} → ${item.latest}  ${note}`}</Text>
404        </Box>
405      )
406    return <Text dimColor wrap="truncate-end">{`   ${fit(item.name, nameW)}  ${fit(`${item.have} → ${item.latest}`, verW)}  ${note}`}</Text>
407  }
408
409  // The checker's note repeats the group title, so the pane says `held back`; the report keeps the note.
410  const brokenRow = (item: ToolItem) => (
411    <Box flexDirection="column">
412      <Text wrap="truncate-end">
413        {'   '}
414        <Text color="warning">!</Text> {w < 60 ? item.name : fit(item.name, Math.max(1, nameW - 2))}
415        {w < 60 ? null : <Text dimColor>{`  ${fit(`${item.have} → ${item.latest}`, verW)}  `}</Text>}
416        {w < 60 ? null : (
417          <Text dimColor italic>
418            held back
419          </Text>
420        )}
421      </Text>
422      {item.detail ? (
423        <Box paddingLeft={5}>
424          <Text dimColor italic wrap={w >= 60 ? 'wrap' : 'truncate-end'}>
425            {item.detail}
426          </Text>
427        </Box>
428      ) : null}
429    </Box>
430  )
431
432  const problemRows = () =>
433    sorted.problems.map(p => (
434      <Box paddingLeft={3}>
435        <Text wrap="wrap">
436          <Text color="error">?</Text> {p.text}
437          {p.hint ? ` (${p.hint})` : ''}
438        </Text>
439      </Box>
440    ))
441
442  const groupHead = (title: string, n: number) => <Text dimColor>{`${title} (${n})`}</Text>
443
444  // Plugins, Skills, Homebrew, Known issues, Info; the hotkeys run on across groups.
445  const groups = () => [
446    ...kinds.flatMap(kind => {
447      const group = sorted.updatable.filter(i => i.kind === kind)
448      return group.length ? [groupHead(GROUP[kind] ?? kind, group.length), ...group.map(i => itemRow(i, sorted.updatable.indexOf(i)))] : []
449    }),
450    ...(sorted.broken.length ? [groupHead('Known issues', sorted.broken.length), ...sorted.broken.map(brokenRow)] : []),
451    ...(sorted.info.length ? [groupHead('Info', sorted.info.length), ...sorted.info.map(infoRow)] : []),
452    ...(result?.skipped ?? []).map(s => <Text dimColor wrap="truncate-end">{`   ${s}`}</Text>),
453  ]
454
455  const plainRow = (item: ToolItem) => (
456    <Text dimColor wrap="truncate-end">
457      {w < 60 ? `  ${item.name}` : `  ${fit(item.name, nameW)}  ${item.have} → ${item.latest}`}
458    </Text>
459  )
460
461  const stepRow = (step: Step, isDone: boolean) => {
462    const name = w < 40 ? step.name : fit(step.name, nameW)
463    const glyph =
464      step.status === 'ok' ? <Text color="success">✓</Text> : step.status === 'failed' ? <Text color="error">✗</Text> : <Text dimColor>{step.status === 'running' ? '…' : '·'}</Text>
465    const word =
466      step.status === 'ok'
467        ? isDone
468          ? `${step.have} → ${step.latest}`
469          : 'updated'
470        : step.status === 'failed'
471          ? step.exitCode === undefined
472            ? (step.errorLine ?? 'failed')
473            : `exit ${step.exitCode}`
474          : step.status === 'skipped'
475            ? 'skipped (stopped)'
476            : step.status
477    return (
478      <Box flexDirection="column">
479        <Text wrap="truncate-end">
480          {'  '}
481          {glyph} {name}
482          {w < 40 ? '' : '  '}
483          {w < 40 ? '' : step.status === 'ok' || step.status === 'failed' ? word : <Text dimColor>{word}</Text>}
484        </Text>
485        {isDone && step.status === 'failed' && step.exitCode !== undefined && step.errorLine ? (
486          <Text wrap="wrap">{`      ${step.errorLine}`}</Text>
487        ) : null}
488      </Box>
489    )
490  }
491
492  // Running: a progress list; no close, no reload (a reload would kill the child).
493  if (run && isRunning(run)) {
494    const current = run.steps.findIndex(s => s.status === 'running')
495    const step = run.steps[current]
496    return (
497      <Box flexDirection="column">
498        <Text wrap="truncate-end">
499          <Text weight="bold">
500            Updating {current + 1} of {run.steps.length}
501          </Text>
502          <Text dimColor>
503            {step ? ` · ${step.name}` : ''} · {elapsed(now - run.startedAt)}
504          </Text>
505        </Text>
506        {run.steps.map(s => stepRow(s, false))}
507        {actions(
508          run.isStopping ? (
509            <Text dimColor>stops after this item</Text>
510          ) : (
511            <Button key="stop" plain hotkey="s" label="Stop after this item" onPress={async () => { await update($, runA, r => r && { ...r, isStopping: true }) }} />
512          ),
513        )}
514      </Box>
515    )
516  }
517
518  if (run && view.name === 'output') {
519    const log = run.steps
520      .filter(s => s.status === 'failed')
521      .map(s => `$ ${s.argv.join(' ')}\n${s.log ?? ''}`)
522      .join('\n\n')
523    return (
524      <Box flexDirection="column">
525        {heading('output')}
526        <Code source={log || 'no output'} wrap="truncate-end" />
527        {actions(<Button key="back" plain hotkey="b" label="Back" onPress={() => setView({ name: 'result' })} />)}
528      </Box>
529    )
530  }
531
532  if (run && view.name === 'result') {
533    const count = (status: Step['status']) => run.steps.filter(s => s.status === status).length
534    const [ok, failed, skipped] = [count('ok'), count('failed'), count('skipped')]
535    const parts = [`${ok} updated`, ...(failed ? [`${failed} failed`] : []), ...(skipped ? [`${skipped} skipped`] : []), elapsed((run.endedAt ?? now) - run.startedAt)]
536    const okKinds = new Set(run.steps.filter(s => s.status === 'ok').map(s => s.kind))
537    const failedSteps = run.steps.filter(s => s.status === 'failed')
538    return (
539      <Box flexDirection="column">
540        {headRow(
541          <Text wrap="truncate-end">
542            <Text weight="bold">Done</Text>
543            <Text dimColor> · {parts.join(' · ')}</Text>
544          </Text>,
545        )}
546        {run.steps.map(s => stepRow(s, true))}
547        {okKinds.has('plugin') || okKinds.has('skill') ? (
548          <Box marginTop={1} flexDirection="column">
549            {okKinds.has('plugin') ? <Text wrap="wrap">Next: {AFTER.plugin.next}</Text> : null}
550            {okKinds.has('skill') ? <Text wrap="wrap">Next: {AFTER.skill}.</Text> : null}
551          </Box>
552        ) : null}
553        {actions(
554          ...failedSteps.slice(0, 9).map((s, i) => <Button key={`retry:${s.key}`} plain {...digit(i)} label={`Retry ${s.name}`} onPress={() => confirmFor([s.key])} />),
555          failedSteps.length ? <Button key="output" plain hotkey="o" label="Show output" onPress={() => setView({ name: 'output' })} /> : null,
556          okKinds.has('plugin') ? <Button key="reload" plain hotkey="l" label="Reload plugins" onPress={() => reloadPlugins($)} /> : null,
557          check ? null : checkNow, // the re-check after the run is under way: a press would be dropped
558          close,
559        )}
560      </Box>
561    )
562  }
563
564  if (view.name === 'confirm') {
565    const items = view.ids.map(key => sorted.updatable.find(i => keyOf(i) === key)).filter((i): i is ToolItem => i !== undefined)
566    const cancel = <Button key="cancel" plain hotkey="n" label="Cancel" autoFocus onPress={() => setView({ name: 'list' })} />
567    // No hotkey: a letter typed into the focused pane must never run an update. Update runs only when its
568    // Button is activated: Tab or the arrows onto it, then Enter, or a click. Cancel holds the focus.
569    // While a check runs there is no Update: the check may be running brew.
570    const confirm = (label: string) =>
571      check ? (
572        <Text key="wait" dimColor>
573          checking… Update appears when the check ends
574        </Text>
575      ) : (
576        <Button
577          key="confirm"
578          variant="primary"
579          label={label}
580          onPress={async () => {
581            const current = await read($, viewA)
582            if (current.name === 'confirm') void startRun($, current.ids)
583          }}
584        />
585      )
586    const howTo = <Text dimColor wrap="truncate-end">Tab or arrows to Update, then Enter</Text>
587    const [first] = items
588    if (items.length === 1 && first) {
589      const after = first.kind === 'plugin' ? AFTER.plugin.one : first.kind === 'skill' ? AFTER.skill : ''
590      return (
591        <Box flexDirection="column">
592          <Text weight="bold" wrap="wrap">
593            Update {first.name} {first.have} → {first.latest}?
594          </Text>
595          <Text wrap="truncate-end">{`  runs   ${(argvFor(first) ?? []).join(' ')}`}</Text>
596          {after ? <Text wrap="wrap">{`  then   ${after}`}</Text> : null}
597          {actions(confirm('Update'), cancel)}
598          {howTo}
599        </Box>
600      )
601    }
602    if (items.length > 1) {
603      const shown = items.length > 6 ? items.slice(0, 5) : items
604      const kindsIn = new Set(items.map(i => i.kind))
605      const skips = [...sorted.broken.map(i => `${i.name} (known issue)`), ...sorted.info.map(i => `${i.name} (info)`)]
606      return (
607        <Box flexDirection="column">
608          <Text weight="bold" wrap="wrap">
609            Update {items.length} items?
610          </Text>
611          {shown.map((i, n) => (
612            <Text wrap="truncate-end">{`  ${n + 1} ${(argvFor(i) ?? []).join(' ')}`}</Text>
613          ))}
614          {items.length > shown.length ? <Text dimColor>{`  + ${items.length - shown.length} more`}</Text> : null}
615          {kindsIn.has('plugin') ? <Text wrap="wrap">{`  then   ${AFTER.plugin.all}`}</Text> : null}
616          {kindsIn.has('skill') ? <Text wrap="wrap">{`  then   ${AFTER.skill}`}</Text> : null}
617          {skips.length ? <Text wrap="wrap">{`  skips  ${skips.join(', ')}`}</Text> : null}
618          {actions(confirm(`Update ${items.length}`), cancel)}
619          {howTo}
620        </Box>
621      )
622    }
623    // The items left the list (a re-check finished): fall through to it.
624  }
625
626  if (check) {
627    return (
628      <Box flexDirection="column">
629        {heading(`checking… ${elapsed(now - check.startedAt)}`)}
630        <Text dimColor wrap="truncate-end">
631          {`  ${kinds.map(k => SCOPE[k]).join(', ')} · ${check.isFetch ? 'fetching metadata' : 'no network fetch'}`}
632        </Text>
633        {result ? (
634          <Box marginTop={1} flexDirection="column">
635            <Text dimColor>{`  last check ${when(result.checkedAt, now)}`}</Text>
636            {sorted.updatable.map(plainRow)}
637          </Box>
638        ) : null}
639        {actions(close)}
640      </Box>
641    )
642  }
643
644  if (checkError) {
645    return (
646      <Box flexDirection="column">
647        {heading('check failed')}
648        <Text wrap="wrap">
649          {'  '}
650          <Text color="error">✗</Text> {checkError.text}
651        </Text>
652        {checkError.hint ? (
653          <Text dimColor wrap="wrap">
654            {`    ${checkError.hint}`}
655          </Text>
656        ) : null}
657        {actions(checkNow, close)}
658      </Box>
659    )
660  }
661
662  if (!result) {
663    return (
664      <Box flexDirection="column">
665        {heading('not checked yet')}
666        {actions(checkNow, close)}
667      </Box>
668    )
669  }
670
671  const n = sorted.updatable.length
672  return (
673    <Box flexDirection="column">
674      {heading(`${n ? `${n} available` : 'all current'} · checked ${when(result.checkedAt, now)}`)}
675      {problemRows()}
676      {n === 0 && result.checked.length ? (
677        <Text wrap="wrap">
678          {'  '}
679          <Text color="success">✓</Text> {result.checked.join(' · ')}
680        </Text>
681      ) : null}
682      {groups()}
683      {actions(
684        n ? <Button key="update-all" plain hotkey="a" label={`Update all (${n})`} onPress={() => confirmFor(sorted.updatable.map(keyOf))} /> : null,
685        checkNow,
686        close,
687      )}
688    </Box>
689  )
690}
691
692/** The band row: one row or nothing, above whatever the rest of the band chain drew. */
693async function bandRow($: Engine, e: RenderInput<'AbovePrompt'>) {
694  const { Box, Text, Button } = $.ui.resolve(e)
695  const result = await read($, resultA)
696  const hiddenFor = await read($, hiddenA)
697  const run = await read($, runA)
698  const where = await read($, whereA) // subscribes the band to opens and closes
699  await read($, nowA)
700  const w = e.props.bodyColumns
701  // `$.ui.panes()` is a call, not a subscription; `where` above is what redraws the band.
702  if (where === 'pane') {
703    const panes = await $.ui.panes()
704    if (panes.some(p => p.id === PANE && p.isShown)) return null
705  }
706  const show = <Button key="show" plain label="Show" onPress={() => void openPane($)} />
707  if (run && isRunning(run)) {
708    const current = run.steps.findIndex(s => s.status === 'running')
709    const now = await $.clock.now()
710    return (
711      <Box flexDirection="row" gap={2} width={w}>
712        <Text wrap="truncate-end">
713          <Text dimColor>…</Text> Updating {current + 1} of {run.steps.length} · {run.steps[current]?.name ?? ''} · {elapsed(now - run.startedAt)}
714        </Text>
715        <Box flexGrow={1} />
716        {show}
717      </Box>
718    )
719  }
720  const sorted = sortItems(result, config.kinds)
721  const { updatable } = sorted
722  const sig = signature(updatable)
723  if (!result || !updatable.length || hiddenFor === sig) return null
724  const hide = async () => {
725    await update($, hiddenA, () => sig)
726    await $.store.set('hiddenFor', sig)
727  }
728  const n = updatable.length
729  const now = await $.clock.now()
730  const checkedAt = Date.parse(result.checkedAt)
731  const problems = sorted.problems.filter(p => p.offline !== true).length
732  // Drawn only from 64 columns; below, the row is the count and the buttons.
733  const tail =
734    w < 64
735      ? []
736      : [
737          ...config.kinds.map(k => [GROUP[k], updatable.filter(i => i.kind === k).length] as const).filter(([, c]) => c > 0).map(([g, c]) => `${g} ${c}`),
738          ...(problems ? [`? ${problems}`] : []),
739          ...(hasOffline(sorted.problems) ? ['offline'] : []),
740          ...(now - checkedAt > DAY ? [`checked ${when(result.checkedAt, now).slice(0, 5)}`] : []),
741        ]
742  return (
743    <Box flexDirection="row" gap={2} width={w}>
744      <Text wrap="truncate-end">
745        <Text color="warning">↑</Text> {n}
746        {w < 40 ? '' : ` tool update${n === 1 ? '' : 's'}`}
747        {tail.length ? <Text dimColor>{` · ${tail.join(' · ')}`}</Text> : null}
748      </Text>
749      <Box flexGrow={1} />
750      {show}
751      {w < 40 ? null : <Button key="hide" plain dimColor label="Hide" onPress={() => void hide()} />}
752    </Box>
753  )
754}
755
756async function onSessionStart($: Engine, e: Args<'session.start'>, next: Next<'session.start'>) {
757  config.isInteractive = e.isInteractive
758  // A reload (or a /config change) drops the old module's work in flight while $.state keeps its marks:
759  // clear them, or the pane would wait on a check or a run that no longer exists.
760  const reloadedAt = await $.clock.now()
761  await update($, checkA, () => null)
762  await update($, runA, run =>
763    run && isRunning(run)
764      ? {
765          ...run,
766          endedAt: reloadedAt,
767          steps: run.steps.map(s =>
768            s.status === 'running'
769              ? { ...s, status: 'failed' as const, errorLine: 'interrupted by a plugin reload' }
770              : s.status === 'queued'
771                ? { ...s, status: 'skipped' as const }
772                : s,
773          ),
774        }
775      : run,
776  )
777  await $.command.register({
778    name: COMMAND,
779    description: `${TITLE}: show updates for Claude Code, plugins, skills and Homebrew; run one after you confirm it`,
780  })
781  if (config.isInteractive) {
782    try {
783      const cached = await readCache($)
784      if (cached) await publish($, cached)
785      const hidden = await $.store.get('hiddenFor')
786      if (typeof hidden === 'string') await update($, hiddenA, () => hidden)
787    } catch (err) {
788      $.ui.log(`uptick: cache not read: ${message(err)}`, { to: 'debug' })
789    }
790    cacheTimer?.cancel()
791    cacheTimer = $.clock.every(FIFTEEN_MINUTES, () => void refreshFromCache($))
792  }
793  return next(e)
794}
795
796// Answers /uptick only: any other command passes on untouched.
797async function onCommandRun($: Engine, e: Args<'command.run'>, next: Next<'command.run'>) {
798  if (e.command !== COMMAND) return next(e)
799  if (!config.isInteractive) {
800    // Headless: a read-only report, nothing is ever run.
801    const out = await runChecker($, false)
802    if ('error' in out) return { text: `${TITLE}: check failed: ${out.error.text}\n  ${out.error.hint}`, exitCode: 1 }
803    const now = await $.clock.now()
804    return { text: report(out.result, config.kinds, true, now) }
805  }
806  const cached = await read($, resultA)
807  await openPane($)
808  const now = await $.clock.now()
809  return { text: report(cached, config.kinds, false, now) }
810}
811
812// The person's close mark closes the pane without our Close: the band must learn of it too.
813async function onClose($: Engine, e: Args<'ui.close'>, next: Next<'ui.close'>) {
814  if (e.id === PANE) await update($, whereA, () => null)
815  return next(e)
816}
817
818// The band is shared with other mods' rows: always keep what next(e) drew, ours on top.
819async function onBand($: Engine, e: RenderInput<'AbovePrompt'>, next: Next<'ui.render', RenderInput<'AbovePrompt'>>) {
820  if (e.props.hasSurvey) return next(e)
821  const where = await read($, whereA)
822  const isFallback = where === 'band'
823  // The row stays while Claude works, so Show can open the pane mid-turn.
824  if (!isFallback && config.options.hint !== 'band') return next(e)
825  const mine = isFallback ? await draw($, e, true) : await bandRow($, e)
826  if (!mine) return next(e)
827  // next(e) rejects only when nothing answers beneath (a test without an engine hook): ours still draws.
828  const below = await next(e).catch(() => undefined)
829  const { Box } = $.ui.resolve(e)
830  return below ? (
831    <Box flexDirection="column">
832      {mine}
833      {below}
834    </Box>
835  ) : (
836    mine
837  )
838}
839
840async function onPane($: Engine, e: RenderInput<'Pane'>, next: Next<'ui.render', RenderInput<'Pane'>>) {
841  if (e.requestId !== PANE) return next(e)
842  return draw($, e, false)
843}
844
845export const register: Register = (on, options) => {
846  config.options = options
847  config.kinds = parseKinds(options.kinds)
848  on('session.start', onSessionStart)
849  on('command.run', { command: COMMAND }, onCommandRun)
850  on('ui.close', onClose)
851  on('ui.render', { component: 'AbovePrompt' }, onBand)
852  on('ui.render', { component: 'Pane', requestId: PANE }, onPane)
853}
854
hooks/logic.ts 224 lines
1import type { PluginOptions } from 'claude-code'
2
3import type { CheckResult, Problem, ToolItem } from '../types'
4
5/** The kinds the mod may update, each by one fixed argv template. */
6export const UPDATABLE = ['plugin', 'skill', 'brew'] as const
7
8/** The skills CLI, pinned to the release this mod was tested with (checker/check_updates.py SKILLS_CLI). */
9export const SKILLS_CLI = 'skills@1.7.4'
10
11const PLUGIN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}@[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
12const SKILL_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
13const BREW_ID = /^[A-Za-z0-9][A-Za-z0-9@._+-]{0,127}(\/[A-Za-z0-9][A-Za-z0-9@._+-]{0,127}){0,2}$/
14
15/**
16 * The one argv an item may run, or undefined. The id comes from a command's output (a trust
17 * boundary), so it must match its kind's pattern: no leading dash, no shell, no path tricks.
18 */
19export function argvFor(item: Pick<ToolItem, 'id' | 'kind' | 'cask'>): string[] | undefined {
20  const { id, kind } = item
21  if (kind === 'plugin' && PLUGIN_ID.test(id)) return ['claude', 'plugin', 'update', id]
22  if (kind === 'skill' && SKILL_ID.test(id)) return ['npx', '--yes', SKILLS_CLI, 'update', '-g', id]
23  if (kind === 'brew' && BREW_ID.test(id)) return ['brew', 'upgrade', ...(item.cask === true ? ['--cask'] : []), id]
24  return undefined
25}
26
27/** An item's identity: a skill and a formula, or a formula and a cask, may share a name. */
28export const keyOf = (item: Pick<ToolItem, 'id' | 'kind' | 'cask'>): string => `${item.kind}:${item.id}${item.cask === true ? ':cask' : ''}`
29
30/** Set over the host's environment for each kind's update. */
31export const UPDATE_ENV: Partial<Record<string, Record<string, string>>> = {
32  // The skills CLI reports each update to its telemetry unless told not to.
33  skill: { DISABLE_TELEMETRY: '1', DO_NOT_TRACK: '1' },
34  // Homebrew then runs `sudo -A`, which fails at once: a cask that wants a password must not wait on a prompt no one sees.
35  brew: { SUDO_ASKPASS: '/usr/bin/false' },
36}
37
38const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
39
40// Terminal escape sequences (CSI, OSC) whole, so no fragment of one reaches the drawing.
41const ESCAPES = /\u001b\[[0-9;?]*[ -\/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g
42
43/** Outside text as one safe line: no escape sequences or control characters, bounded. */
44export const clean = (v: unknown, max = 300): string =>
45  typeof v === 'string' || typeof v === 'number'
46    ? String(v).replace(ESCAPES, '').replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').slice(0, max)
47    : ''
48
49/** Outside text kept as lines (a command's output), otherwise as `clean`; the last `max` characters. */
50export const cleanLines = (text: string, max: number): string =>
51  text.replace(ESCAPES, '').replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, ' ').slice(-max)
52
53/** The checker's JSON, or null when the text is not that shape. Unknown fields are dropped. */
54export function parseResult(text: string): CheckResult | null {
55  let data: unknown
56  try {
57    data = JSON.parse(text)
58  } catch {
59    return null
60  }
61  if (!isObject(data) || !Array.isArray(data.items)) return null
62  const lines = (v: unknown) => (Array.isArray(v) ? v.filter(s => typeof s === 'string').map(s => clean(s)) : [])
63  const items: ToolItem[] = []
64  for (const i of data.items) {
65    if (!isObject(i) || typeof i.id !== 'string' || typeof i.kind !== 'string') continue
66    items.push({
67      id: i.id.slice(0, 300),
68      kind: clean(i.kind, 20),
69      name: clean(i.name) || clean(i.id),
70      have: clean(i.have, 80),
71      latest: clean(i.latest, 80),
72      ...(i.note ? { note: clean(i.note) } : {}),
73      ...(i.detail ? { detail: clean(i.detail, 600) } : {}),
74      ...(i.cask === true ? { cask: true } : {}),
75    })
76  }
77  const problems: Problem[] = []
78  for (const p of Array.isArray(data.problems) ? data.problems : []) {
79    if (typeof p === 'string') problems.push({ text: clean(p) })
80    else if (isObject(p) && typeof p.text === 'string')
81      problems.push({ text: clean(p.text), ...(p.hint ? { hint: clean(p.hint) } : {}), ...(p.offline === true ? { offline: true } : {}) })
82  }
83  return {
84    checkedAt: clean(data.checkedAt, 40),
85    checked: lines(data.checked),
86    skipped: lines(data.skipped),
87    items,
88    problems,
89  }
90}
91
92export function parseKinds(option: PluginOptions[string] | undefined): string[] {
93  const listed = typeof option === 'string' ? option.split(',').map(k => k.trim()) : []
94  return UPDATABLE.filter(k => listed.includes(k))
95}
96
97export type Sorted = { updatable: ToolItem[]; info: ToolItem[]; broken: ToolItem[]; problems: Problem[] }
98
99/** Splits a result into what the pane draws; an item with an id that is not valid becomes a problem row. */
100export function sortItems(result: CheckResult | null, kinds: readonly string[]): Sorted {
101  const out: Sorted = { updatable: [], info: [], broken: [], problems: [...(result?.problems ?? [])] }
102  for (const item of result?.items ?? []) {
103    if (item.kind === 'broken') out.broken.push(item)
104    else if (item.kind === 'self' || item.kind === 'info') out.info.push(item)
105    else if (!kinds.includes(item.kind)) continue
106    else if (argvFor(item)) out.updatable.push(item)
107    else out.problems.push({ text: `${item.name}: not a valid ${item.kind} id, not offered for update` })
108  }
109  // Grouped in display order (Plugins, Skills, Homebrew); Update all runs in this order too.
110  out.updatable.sort((a, b) => kinds.indexOf(a.kind) - kinds.indexOf(b.kind))
111  return out
112}
113
114/** Which updates the band was hidden for: it shows again when this changes. */
115export const signature = (items: readonly ToolItem[]): string =>
116  items.map(i => `${keyOf(i)}@${i.latest}`).sort().join('|')
117
118export const hasOffline = (problems: readonly Problem[]): boolean => problems.some(p => p.offline === true)
119
120/** What to tell after a kind ran: confirm-one, confirm-all and the result's Next line; skills say one thing. */
121export const AFTER = {
122  plugin: { one: 'run /reload-plugins to load it in this session', all: 'run /reload-plugins to load the plugin updates', next: 'run /reload-plugins to load the plugin update.' },
123  skill: 'updated skills load in the next session',
124} as const
125
126export function checkerArgv(root: string, cachePath: string | undefined, isFetch: boolean, options: PluginOptions, isWindows = false): string[] {
127  const opt = (key: string) => {
128    const v = options[key]
129    return typeof v === 'string' ? v.trim() : ''
130  }
131  // `--flag=value` so a value that starts with a dash is never read as a flag.
132  // Windows: the py launcher, as `python3` there may be the Store alias that opens the Store.
133  // UTF-8 mode, so the files and the tools' output are not read in the Windows code page.
134  return [
135    ...(isWindows ? ['py', '-3'] : ['python3']),
136    '-I',
137    '-X',
138    'utf8',
139    `${root}/checker/check_updates.py`,
140    '--json',
141    ...(cachePath ? [`--cache=${cachePath}`] : []),
142    ...(isFetch ? ['--fetch'] : []),
143    ...(opt('brewfile') ? [`--brewfile=${opt('brewfile')}`] : []),
144    ...(opt('knownBroken') ? [`--known-broken=${opt('knownBroken')}`] : []),
145    ...(opt('checkCommand') ? [`--extra=${opt('checkCommand')}`] : []),
146    ...(typeof options.kinds === 'string' ? [`--kinds=${parseKinds(options.kinds).join(',')}`] : []),
147  ]
148}
149
150export type CheckFailure = 'start' | 'timeout' | 'output'
151
152/** What to do about a check that failed, by how it failed and on which system. */
153export function checkHint(failure: CheckFailure, isWindows: boolean, root: string): string {
154  if (failure === 'timeout') return 'a tool or the network hung: press r to check again, or narrow kinds in /config'
155  if (failure === 'start')
156    return isWindows
157      ? 'install Python 3 from python.org, with its py launcher'
158      : 'install Python 3 (macOS: xcode-select --install or brew install python; Linux: the python3 package)'
159  return `run it in a terminal to see the whole error: ${isWindows ? 'py -3' : 'python3'} -I -X utf8 ${root}/checker/check_updates.py`
160}
161
162// What a Python that is missing says when its launcher or stub starts: the macOS stub without the developer
163// tools, and Windows' py launcher with no Python 3 installed.
164export const NO_PYTHON = /xcrun: error|developer tools|No suitable Python runtime|Python was not found/i
165
166/**
167 * The argv as the host starts it. On Windows `npx`, and `claude` when npm installed it, are .cmd shims,
168 * which only cmd.exe starts (a native claude.exe starts through it too); the plugin and skill id patterns
169 * admit none of cmd's special characters.
170 * Drop cmd.exe once $.process.run starts .cmd shims itself.
171 */
172export const onHost = (argv: readonly string[], isWindows: boolean): string[] =>
173  isWindows && (argv[0] === 'npx' || argv[0] === 'claude') ? ['cmd.exe', '/d', '/c', ...argv] : [...argv]
174
175/** `m:ss` */
176export function elapsed(ms: number): string {
177  const s = Math.max(0, Math.floor(ms / 1000))
178  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
179}
180
181/** The check's local time as `09:12`, or `10-19 09:12` when it was not today (in the check's own time zone). */
182export function when(iso: string, nowMs: number): string {
183  const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$/.exec(iso)
184  if (!m) return iso || 'unknown'
185  const [, day = '', time = '', zone = 'Z'] = m
186  const offset = zone === 'Z' ? 0 : (zone.startsWith('-') ? -1 : 1) * (Number(zone.slice(1, 3)) * 60 + Number(zone.slice(4, 6)))
187  const today = new Date(nowMs + offset * 60_000).toISOString().slice(0, 10)
188  return day === today ? time : `${day.slice(5)} ${time}`
189}
190
191/** Pads or cuts to exactly `width` cells (an ellipsis marks a cut). */
192export function fit(text: string, width: number): string {
193  if (width <= 0) return ''
194  return text.length > width ? `${text.slice(0, width - 1)}…` : text.padEnd(width)
195}
196
197/** The last non-empty line of a command's output, as one safe line. */
198export const lastLine = (text: string): string =>
199  clean(
200    cleanLines(text, 100_000)
201      .split('\n')
202      .map(l => l.trim())
203      .filter(Boolean)
204      .at(-1) ?? '',
205  )
206
207const versions = (i: ToolItem) => `${i.have} → ${i.latest}`
208
209/** The plain-text report: the command's reply everywhere, and the whole UX under `claude -p`. */
210export function report(result: CheckResult | null, kinds: readonly string[], isHeadless: boolean, nowMs: number): string {
211  if (!result) return 'Uptick: not checked yet'
212  const { updatable, info, broken, problems } = sortItems(result, kinds)
213  const status = updatable.length ? `${updatable.length} available` : 'all current'
214  const lines = [`Uptick: ${status} (checked ${when(result.checkedAt, nowMs)})`]
215  if (!updatable.length && result.checked.length) lines.push(`  ✓ ${result.checked.join(' · ')}`)
216  for (const i of updatable) lines.push(`  ↑ ${i.name} ${versions(i)}`, `      ${(argvFor(i) ?? []).join(' ')}`)
217  for (const i of info) lines.push(`  · ${i.name} ${versions(i)} (${i.note ?? 'updates itself; new sessions get it'})`)
218  for (const i of broken) lines.push(`  ! ${i.name}: ${i.note ?? 'upstream known broken'}`)
219  for (const p of problems) lines.push(`  ? ${p.text}${p.hint ? ` (${p.hint})` : ''}`)
220  for (const s of result.skipped) lines.push(`  · ${s}`)
221  if (isHeadless && updatable.length) lines.push('To update, run /uptick in an interactive session.')
222  return lines.join('\n')
223}
224
types/index.d.ts 64 lines
1/** One row of the checker's JSON: something with a newer version, or a note about one. */
2export type ToolItem = {
3  id: string
4  /** plugin | skill | brew (the mod may update these), self | info (shown only), broken (a note) */
5  kind: string
6  name: string
7  have: string
8  latest: string
9  note?: string
10  detail?: string
11  cask?: boolean
12}
13
14export type Problem = { text: string; hint?: string; offline?: boolean }
15
16/** The checker's whole JSON output, as the cache file holds it. */
17export type CheckResult = {
18  checkedAt: string
19  checked: string[]
20  skipped: string[]
21  items: ToolItem[]
22  problems: Problem[]
23}
24
25export type Check = { startedAt: number; isFetch: boolean }
26
27/** A check that gave no result: what happened, and what to do about it. */
28export type CheckError = { text: string; hint: string }
29
30/** `ids` are item keys (`kind:id`, `:cask` for a cask). */
31export type View = { name: 'list' } | { name: 'confirm'; ids: string[] } | { name: 'result' } | { name: 'output' }
32
33export type Step = {
34  /** The item's key: `kind:id`, `:cask` for a cask. */
35  key: string
36  name: string
37  kind: string
38  have: string
39  latest: string
40  argv: string[]
41  status: 'queued' | 'running' | 'ok' | 'failed' | 'skipped'
42  exitCode?: number
43  errorLine?: string
44  log?: string
45}
46
47export type Run = { steps: Step[]; startedAt: number; endedAt?: number; isStopping: boolean }
48
49declare module 'claude-code' {
50  interface PluginState {
51    'uptick': {
52      result: CheckResult | null
53      check: Check | null
54      checkError: CheckError | null
55      view: View
56      run: Run | null
57      now: number
58      hiddenFor: string | null
59      /** Where the pane is drawn: its own pane, the band (an open that was not placed), or closed. */
60      where: 'pane' | 'band' | null
61    }
62  }
63}
64