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

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.
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.
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.
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.
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.
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.
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
hooks/register.tsx 854 lines1import { 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}
854hooks/logic.ts 224 lines1import 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}
224types/index.d.ts 64 lines1/** 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