SLOPSHOPPER

ts-band

Tailscale nodes' state in a band above the prompt, with a toast when a node comes up or goes down

newbandguardcommandtoastprompt
★ 5v0.8.1MITupdated 2026-10-09hoobnn/hoobnn-agent-mods/claude-code/ts-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ts-band
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /ts ⎿ ts-band: Tailscale band hidden ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

ts-band:在 Claude Code 里看 Tailscale 节点状态

简体中文 · English

在输入框上方显示 Tailscale 组网里各节点的连接状态。全部直连时只占一个小标记;有节点走中继或掉线时才把它们列出来。让 Claude 往远程机器部署、同步文件之前,先看一眼链路是否正常。

ts-band:两个节点走中继,一个节点离线

功能

  • 正常时几乎不占地方:所有节点在线且直连时,只显示 TS ● 4/4 直连。
  • 有异常才展开:显示在线节点数,然后只列出需要注意的节点:◐ 黄色表示走中继或 DERP(会标出 DERP 区域,如 DERP-sfo),○ 红色表示离线。
  • 上线 / 掉线提醒:节点上线或掉线时弹 toast。
  • 及时刷新:每分钟读一次 tailscale status --json;Claude 执行 tailscale up、down、set、switch、login、logout 后立即刷新。
  • 读取失败不清空:读取失败时保留上次的节点,后面附上错误信息;之后的重试间隔逐次翻倍(最长 10 分钟),没装 CLI 或守护进程没开时不会每次都重试。
  • 在 claude -p 和 SDK 里不运行。

安装

先装好 Tailscale,确保 tailscale 命令可用(macOS 应用自带的 CLI 也能找到),然后:

claude plugin marketplace add hoobnn/hoobnn-agent-mods
claude plugin install ts-band@hoobnn-agent-mods

命令

  • /ts:切换显示和隐藏;/ts off、/ts on 直接设定。这个设置会写回 /config,以后的会话也会沿用。

选项

在 /config 里修改,或写在 ~/.claude/settings.json 的 pluginConfigs 里:

选项作用默认
visible显示横条(/ts 改的就是它)开
nodes只显示这些节点,按填写顺序排列;host=名字 可以改显示名,如 nas=家里, dev-box, vps-west=美西。留空显示全部,在线数和提醒也只按选中的节点算空
hideOffline横条里不列离线节点(在线数仍然计入)关
tailscalePathtailscale 命令的路径;留空时依次在 PATH、Homebrew 和 macOS 应用里找空
intervalSeconds两次读取之间的秒数(至少 10)60
language界面语言:auto、en、zh-Hans、zh-Hant、ja、ko、es、fr、de、pt-BR、ruauto

language 为 auto 时,依次跟随 Claude Code 的 language 设置和系统语言环境,都没有时用英语。

同系列 mod

hoobnn-agent-mods 里还有状态栏 HUD(hud)、任务进度条(todo-bar)、回合回执(receipt)、运行动画和宠物(spinner)和一言(hitokoto),可以搭配使用。

开发

  • hooks/register.tsx:钩子(会话开始时的语言、/ts 和定时读取,命令和横条)。
  • hooks/config.ts:选项,一次性读成类型化的 Config。
  • hooks/parse.ts:把 tailscale status --json 解析成节点,把 nodes 选项解析成筛选规则。
  • hooks/i18n.ts:各语言文案。
  • hooks/kit/:claude-code/kit 的副本;改源文件后运行 scripts/sync-kit.sh。
Source 9 files
hooks/register.tsx 247 lines
1import { atom, derive, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { Node, Snapshot } from '../types'
5import { readConfig } from './config'
6import type { Config } from './config'
7import { m, setLang } from './i18n'
8import { isPickerOpen, stackAbove } from './kit/band'
9import { resolveLanguage } from './kit/lang'
10import { keptRows, migrateStore, persist, switchArg } from './kit/prefs'
11import type { Prefs } from './kit/prefs'
12import { parseStatus, selectNodes } from './parse'
13
14const snapshot = atom({ plugin: 'ts-band', key: 'snapshot' } as const, null)
15// Set this session by `/ts` or session.start; null in a session resumed or
16// cleared, which gets no session.start: `isHidden` is the `visible` row's then.
17const hiddenSet = atom({ plugin: 'ts-band', key: 'isHidden' } as const, null as boolean | null)
18// The `visible` row's, set in register.
19let isRowHidden = false
20const isHidden = derive([hiddenSet], set => set ?? isRowHidden)
21// True while a picker is open above the band (see kit/band).
22const isPicking = atom({ plugin: 'ts-band', key: 'isPicking' } as const, false)
23
24function linkText(node: Node): string {
25  if (node.link === 'derp') return node.via ? `DERP-${node.via}` : 'DERP'
26  return m(node.link === 'direct' ? 'link.direct' : node.link === 'peer-relay' ? 'link.relay' : 'link.offline')
27}
28
29/** The kit's hold on this mod's store and `/config` rows. */
30function prefsOf($: EngineInterface): Prefs {
31  return {
32    kept: key => $.store.get(key),
33    forget: key => $.store.delete(key),
34    write: (field, value) => $.config.set({ key: `ts-band.${field}`, value }),
35  }
36}
37
38/** Reads into the snapshot sooner than the timer would, when it is older than this. */
39const PROMPT_REFRESH_MS = 10_000
40/** A shell command that changes this machine's tailnet: `tailscale up`, `…/Tailscale switch`. */
41const TAILSCALE_CHANGE = /(?:^|[\s/;&|(])tailscale\s+(?:up|down|set|switch|login|logout)\b/i
42
43/** After failed reads the next waits longer, doubling up to this. */
44const MAX_BACKOFF_MS = 10 * 60_000
45
46/**
47 * Reads `tailscale status` on a timer into the snapshot, toasting nodes that
48 * come up or go down. Returns a read to run now, skipped while one runs or,
49 * unless forced, while the snapshot is fresh or failed reads wait.
50 */
51function startPolling($: EngineInterface, config: Config): (isForced?: boolean) => Promise<void> {
52  // The first candidate that runs is kept for the session.
53  let tailscale: string | null = null
54  const status = async () => {
55    for (const candidate of tailscale ? [tailscale] : config.tailscale) {
56      try {
57        const out = await $.process.run([candidate, 'status', '--json'], { timeoutMs: 10_000 })
58        tailscale = candidate
59        return out
60      } catch {
61        // Not found or hung: try the next location.
62      }
63    }
64    throw new Error(`tailscale not found (tried ${config.tailscale.join(', ')})`)
65  }
66
67  // Failed reads in a row, and when the next may run: a missing CLI or a stopped
68  // daemon is not tried again every tick.
69  let failures = 0
70  let retryAt = 0
71
72  const refresh = async () => {
73    const previous = await read($, snapshot)
74    const checkedAt = await $.clock.now()
75    let fresh: Snapshot
76    try {
77      const { exitCode, stdout, stderr } = await status()
78      fresh =
79        exitCode === 0
80          ? { nodes: selectNodes(parseStatus(stdout), config.nodes), checkedAt, error: null }
81          : { nodes: [], checkedAt, error: stderr.trim() || `exit ${exitCode}` }
82    } catch (err) {
83      fresh = { nodes: [], checkedAt, error: String(err) }
84    }
85
86    if (fresh.error) {
87      failures += 1
88      retryAt = checkedAt + Math.min(config.intervalMs * 2 ** failures, MAX_BACKOFF_MS)
89      // The nodes last read stay on the band, marked as not current.
90      fresh = { ...fresh, nodes: previous?.nodes ?? [] }
91    } else {
92      failures = 0
93      retryAt = 0
94    }
95
96    if (previous && previous.nodes.length > 0 && !fresh.error) {
97      const was = new Map(previous.nodes.map(n => [n.name, n.isOnline]))
98      for (const node of fresh.nodes) {
99        if (was.has(node.name) && was.get(node.name) !== node.isOnline) {
100          $.ui.toast(m(node.isOnline ? 'toast.up' : 'toast.down', { name: node.name }))
101        }
102      }
103    }
104    await update($, snapshot, () => fresh)
105  }
106
107  let isRunning = false
108  const run = async (isForced = false) => {
109    if (isRunning || (!isForced && (await $.clock.now()) < retryAt)) return
110    isRunning = true
111    try {
112      await refresh()
113    } finally {
114      isRunning = false
115    }
116  }
117
118  // Not awaited: session.start holds the first prompt until it settles.
119  void run()
120  $.clock.every(config.intervalMs, () => void run())
121  return async (isForced = false) => {
122    const snap = await read($, snapshot)
123    if (!isForced && snap && (await $.clock.now()) - snap.checkedAt < PROMPT_REFRESH_MS) return
124    await run(isForced)
125  }
126}
127
128// Before 0.6 `/ts off` was kept in the store; it is the `visible` row now.
129const STORE_MOVES = { isHidden: (kept: unknown) => ['visible', kept !== true] as const }
130
131export const register: Register = (on, options) => {
132  const config = readConfig(options)
133  isRowHidden = !config.isVisible
134  // Set in session.start: a read now, for each prompt the person sends.
135  let refreshNow: (isForced?: boolean) => Promise<void> | void = () => {}
136
137  on('session.start', async ($, e, next) => {
138    // `-p` runs and the SDK draw no band: no tailscale to run for it.
139    if (!e.isInteractive) return next(e)
140    const settings = (await $.settings.read().catch(() => ({}))) as { language?: unknown }
141    const locale = await Promise.all([
142      $.env.get('LC_ALL').catch(() => undefined),
143      $.env.get('LC_MESSAGES').catch(() => undefined),
144      $.env.get('LANG').catch(() => undefined),
145    ])
146    setLang(resolveLanguage(config.language, settings.language, locale))
147    await $.command.register({ name: 'ts', description: m('cmd.description'), argumentHint: '[off|on]' })
148    const kept = await keptRows(prefsOf($), STORE_MOVES)
149    await update($, hiddenSet, () => !(kept.visible ?? config.isVisible))
150    refreshNow = startPolling($, config)
151    const result = await next(e)
152    await migrateStore(prefsOf($), STORE_MOVES)
153    return result
154  })
155
156  on('command.run', { command: 'ts' }, async ($, e) => {
157    const was = await read($, isHidden)
158    const hidden = await update($, hiddenSet, () => switchArg(e.args.trim().toLowerCase(), was))
159    if (hidden !== was) await persist(prefsOf($), 'visible', !hidden)
160    return { text: m(hidden ? 'cmd.hidden' : 'cmd.shown') }
161  })
162
163  // A picker (`/` commands, `@` files) opens above the band: the band steps aside meanwhile.
164  on('prompt.edit', async ($, e, next) => {
165    const box = await next(e)
166    const isOpen = isPickerOpen(box.text, box.cursor)
167    if ((await read($, isPicking)) !== isOpen) await update($, isPicking, () => isOpen)
168    return box
169  })
170  on('prompt.submit', async ($, e, next) => {
171    if (await read($, isPicking)) await update($, isPicking, () => false)
172    // A new round: the nodes as they are now, not as the last tick saw them.
173    void refreshNow()
174    return next(e)
175  })
176
177  // Claude ran `tailscale up`, `down`, `switch`…: the band shows what it did at once.
178  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
179    const result = await next(e)
180    if (TAILSCALE_CHANGE.test(String(e.command ?? ''))) void refreshNow(true)
181    return result
182  })
183
184  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
185    const snap = await read($, snapshot)
186    if (e.props.hasSurvey || snap === null || (await read($, isHidden)) || (await read($, isPicking))) return next(e)
187    const ui = $.ui.resolve(e)
188    return stackAbove(ui, drawNodes(ui, snap, config), await next(e))
189  })
190}
191
192/** The band's row: the nodes, with the error when the last read failed. */
193function drawNodes(ui: Pick<Elements['terminal'], 'Box' | 'Text'>, snap: Snapshot, config: Config): RenderElement {
194  const { Box, Text } = ui
195
196  const error = snap.error ? (
197    <Box>
198      <Text color="red">{m('error.read')}</Text>
199      <Text dimColor wrap="truncate-end">{snap.error}</Text>
200    </Box>
201  ) : null
202  // Nothing read yet: the error alone. Nodes read before it stay, the error after them.
203  if (error && snap.nodes.length === 0) return error
204  const nodes = drawRow(ui, snap, config)
205  if (!error) return nodes
206  return (
207    <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
208      {nodes}
209      {error}
210    </Box>
211  )
212}
213
214/** The nodes' row: a short mark when every node shown is direct, else the nodes that need a look. */
215function drawRow(ui: Pick<Elements['terminal'], 'Box' | 'Text'>, snap: Snapshot, config: Config): RenderElement {
216  const { Box, Text } = ui
217
218  const online = snap.nodes.filter(n => n.isOnline).length
219  const shown = config.isOfflineHidden ? snap.nodes.filter(n => n.isOnline) : snap.nodes
220  // Only what needs a look gets a place: nodes on a relay or DERP, and offline ones.
221  const issues = shown.filter(n => !n.isOnline || n.link !== 'direct')
222  const isAllUp = online === snap.nodes.length
223
224  if (issues.length === 0 && snap.nodes.length > 0) {
225    return (
226      <Box flexDirection="row" columnGap={1}>
227        <Text bold>TS</Text>
228        <Text color={isAllUp ? 'green' : 'yellow'}>●</Text>
229        <Text>{online}/{snap.nodes.length}</Text>
230        <Text dimColor>{m('link.direct')}</Text>
231      </Box>
232    )
233  }
234  return (
235    <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
236      <Text bold>TS {online}/{snap.nodes.length}</Text>
237      {issues.map(node => (
238        <Box key={node.name}>
239          <Text color={node.isOnline ? 'yellow' : 'red'}>{node.isOnline ? '◐' : '○'} </Text>
240          <Text dimColor={!node.isOnline}>{node.name} </Text>
241          <Text dimColor>{linkText(node)}</Text>
242        </Box>
243      ))}
244    </Box>
245  )
246}
247
hooks/config.ts 39 lines
1// ts-band's options (plugin.json `userConfig`), read once into a typed config.
2import type { PluginOptions } from 'claude-code'
3
4import { count, flag, text } from './kit/options'
5import { parseNodeSpec } from './parse'
6import type { NodePick } from './parse'
7
8export type Config = {
9  /** The CLI's locations, tried in order: the `tailscalePath` option, else the usual places. */
10  tailscale: readonly string[]
11  intervalMs: number
12  nodes: readonly NodePick[]
13  isOfflineHidden: boolean
14  isVisible: boolean
15  /** `auto` or a language; the kit resolves it (kit/lang.ts). */
16  language: string
17}
18
19// Where the CLI is looked for when the `tailscalePath` option is empty: PATH,
20// then the Homebrew and macOS app locations a GUI-started session's PATH may lack.
21export const TAILSCALE_CANDIDATES = [
22  'tailscale',
23  '/usr/local/bin/tailscale',
24  '/opt/homebrew/bin/tailscale',
25  '/Applications/Tailscale.app/Contents/MacOS/Tailscale',
26]
27
28export function readConfig(options: PluginOptions): Config {
29  const path = text(options.tailscalePath)
30  return {
31    tailscale: path ? [path] : TAILSCALE_CANDIDATES,
32    intervalMs: count(options.intervalSeconds, 60, { min: 10 }) * 1000,
33    nodes: parseNodeSpec(text(options.nodes)),
34    isOfflineHidden: flag(options.hideOffline, false),
35    isVisible: flag(options.visible, true),
36    language: text(options.language, 'auto'),
37  }
38}
39
hooks/i18n.ts 123 lines
1// ts-band's messages; the language is resolved by the kit (kit/lang.ts).
2import { createMessages } from './kit/lang'
3import type { Lang } from './kit/lang'
4
5export { parseLanguage, resolveLanguage } from './kit/lang'
6
7export type Key = 'cmd.description' | 'cmd.hidden' | 'cmd.shown' | 'link.direct' | 'link.relay' | 'link.offline' | 'toast.up' | 'toast.down' | 'error.read'
8
9export const MESSAGES: Record<Lang, Record<Key, string>> = {
10  en: {
11    'cmd.description': 'Show or hide the Tailscale nodes band; off / on sets it (kept across sessions)',
12    'cmd.hidden': 'Tailscale band hidden',
13    'cmd.shown': 'Tailscale band shown',
14    'link.direct': 'direct',
15    'link.relay': 'relay',
16    'link.offline': 'offline',
17    'toast.up': 'Tailscale: {name} is online',
18    'toast.down': 'Tailscale: {name} went offline',
19    'error.read': 'Tailscale status unavailable: ',
20  },
21  'zh-Hans': {
22    'cmd.description': '显示 / 隐藏 Tailscale 节点状态横条;off / on 直接指定(跨会话保持)',
23    'cmd.hidden': 'Tailscale 横条已隐藏',
24    'cmd.shown': 'Tailscale 横条已显示',
25    'link.direct': '直连',
26    'link.relay': '中继',
27    'link.offline': '离线',
28    'toast.up': 'Tailscale:{name} 已上线',
29    'toast.down': 'Tailscale:{name} 已离线',
30    'error.read': 'TS 读取失败:',
31  },
32  'zh-Hant': {
33    'cmd.description': '顯示/隱藏 Tailscale 節點狀態橫條;off / on 直接指定(跨工作階段保留)',
34    'cmd.hidden': 'Tailscale 橫條已隱藏',
35    'cmd.shown': 'Tailscale 橫條已顯示',
36    'link.direct': '直連',
37    'link.relay': '中繼',
38    'link.offline': '離線',
39    'toast.up': 'Tailscale:{name} 已上線',
40    'toast.down': 'Tailscale:{name} 已離線',
41    'error.read': 'TS 讀取失敗:',
42  },
43  ja: {
44    'cmd.description': 'Tailscale ノード状態バーの表示を切り替えます。off / on で指定(セッションをまたいで保持)',
45    'cmd.hidden': 'Tailscale バーを非表示にしました',
46    'cmd.shown': 'Tailscale バーを表示しました',
47    'link.direct': '直接',
48    'link.relay': 'リレー',
49    'link.offline': 'オフライン',
50    'toast.up': 'Tailscale: {name} がオンラインになりました',
51    'toast.down': 'Tailscale: {name} がオフラインになりました',
52    'error.read': 'Tailscale の状態を取得できません: ',
53  },
54  ko: {
55    'cmd.description': 'Tailscale 노드 상태 표시줄 보이기/숨기기. off / on으로 지정(세션 간 유지)',
56    'cmd.hidden': 'Tailscale 표시줄을 숨겼습니다',
57    'cmd.shown': 'Tailscale 표시줄을 표시했습니다',
58    'link.direct': '직접',
59    'link.relay': '릴레이',
60    'link.offline': '오프라인',
61    'toast.up': 'Tailscale: {name} 온라인',
62    'toast.down': 'Tailscale: {name} 오프라인',
63    'error.read': 'Tailscale 상태를 읽을 수 없습니다: ',
64  },
65  es: {
66    'cmd.description': 'Muestra u oculta la barra de nodos de Tailscale; off / on lo fija (se mantiene entre sesiones)',
67    'cmd.hidden': 'Barra de Tailscale oculta',
68    'cmd.shown': 'Barra de Tailscale visible',
69    'link.direct': 'directo',
70    'link.relay': 'vía relay',
71    'link.offline': 'desconectado',
72    'toast.up': 'Tailscale: {name} está en línea',
73    'toast.down': 'Tailscale: {name} se desconectó',
74    'error.read': 'No se pudo leer el estado de Tailscale: ',
75  },
76  fr: {
77    'cmd.description': 'Affiche ou masque la barre des nœuds Tailscale\u00a0; off / on la fixe (réglage conservé entre les sessions)',
78    'cmd.hidden': 'Barre Tailscale masquée',
79    'cmd.shown': 'Barre Tailscale affichée',
80    'link.direct': 'direct',
81    'link.relay': 'via relais',
82    'link.offline': 'hors ligne',
83    'toast.up': 'Tailscale\u00a0: {name} est en ligne',
84    'toast.down': 'Tailscale\u00a0: {name} est hors ligne',
85    'error.read': 'Impossible de lire l’état de Tailscale\u00a0: ',
86  },
87  de: {
88    'cmd.description': 'Tailscale-Knotenleiste ein- oder ausblenden; off / on legt sie fest (bleibt über Sitzungen erhalten)',
89    'cmd.hidden': 'Tailscale-Leiste ausgeblendet',
90    'cmd.shown': 'Tailscale-Leiste eingeblendet',
91    'link.direct': 'direkt',
92    'link.relay': 'Relay',
93    'link.offline': 'offline',
94    'toast.up': 'Tailscale: {name} ist online',
95    'toast.down': 'Tailscale: {name} ist offline',
96    'error.read': 'Tailscale-Status nicht lesbar: ',
97  },
98  'pt-BR': {
99    'cmd.description': 'Mostra ou oculta a barra de nós do Tailscale; off / on define (configuração mantida entre sessões)',
100    'cmd.hidden': 'Barra do Tailscale oculta',
101    'cmd.shown': 'Barra do Tailscale visível',
102    'link.direct': 'direto',
103    'link.relay': 'via relay',
104    'link.offline': 'offline',
105    'toast.up': 'Tailscale: {name} está online',
106    'toast.down': 'Tailscale: {name} ficou offline',
107    'error.read': 'Não foi possível ler o status do Tailscale: ',
108  },
109  ru: {
110    'cmd.description': 'Показать или скрыть панель узлов Tailscale; off / on задают явно (сохраняется между сессиями)',
111    'cmd.hidden': 'Панель Tailscale скрыта',
112    'cmd.shown': 'Панель Tailscale показана',
113    'link.direct': 'напрямую',
114    'link.relay': 'через релей',
115    'link.offline': 'не в сети',
116    'toast.up': 'Tailscale: {name} в сети',
117    'toast.down': 'Tailscale: {name} не в сети',
118    'error.read': 'Не удалось прочитать статус Tailscale: ',
119  },
120}
121
122export const { m, setLang } = createMessages(MESSAGES)
123
hooks/kit/band.tsx 31 lines
1// Generated from claude-code/kit/band.tsx by scripts/sync-kit.sh: edit the source, then re-run it.
2// A mod's rows in the band above the prompt. Several mods draw there, so each
3// stacks its rows over what the rest of the chain drew (`await next(e)`)
4// instead of replacing it, two cells in, as the engine indents the lines under
5// the prompt. The hook stays the mod's own (`on('ui.render', ...)` is spelled
6// in the hooks module): it yields to a survey, draws, and hands both here.
7import type { Elements, RenderElement } from 'claude-code'
8
9/**
10 * Whether the prompt's draft has a picker open: a slash command being named
11 * (`/sp`, before any space) or a file being mentioned (`@src/a`, the word at the
12 * cursor). The engine draws the picker above the band, so a band steps aside
13 * while one is open and the picker sits right on the prompt.
14 */
15export function isPickerOpen(text: string, cursor = text.length): boolean {
16  const before = text.slice(0, cursor)
17  return /^\/\S*$/.test(before) || /(^|\s)@\S*$/.test(before)
18}
19
20export function stackAbove(ui: Pick<Elements['terminal'], 'Box'>, mine: RenderElement, below: RenderElement): RenderElement {
21  const { Box } = ui
22  return (
23    <Box flexDirection="column">
24      <Box flexDirection="column" paddingLeft={2}>
25        {mine}
26      </Box>
27      {below}
28    </Box>
29  )
30}
31
hooks/kit/lang.ts 68 lines
1// Generated from claude-code/kit/lang.ts by scripts/sync-kit.sh: edit the source, then re-run it.
2// A mod's language: the `language` option, or with `auto` Claude Code's own
3// `language` setting (free text: "简体中文", "Japanese", "pt-BR"), then the locale
4// (LC_ALL, LC_MESSAGES, LANG), then English. The messages stay the mod's own:
5// `createMessages(MESSAGES)` gives it `m` over them.
6
7export type Lang = 'en' | 'zh-Hans' | 'zh-Hant' | 'ja' | 'ko' | 'es' | 'fr' | 'de' | 'pt-BR' | 'ru'
8
9export const LANGS: readonly Lang[] = ['en', 'zh-Hans', 'zh-Hant', 'ja', 'ko', 'es', 'fr', 'de', 'pt-BR', 'ru']
10
11/** The `language` option's values, as a manifest's `options` lists them. */
12export const LANGUAGE_OPTIONS = ['auto', ...LANGS] as const
13
14// Names a person may write, folded (lowercase, no spaces, `-`, `_` or `.`).
15const NAMES: Record<string, Lang> = {
16  en: 'en', english: 'en', 英语: 'en', 英文: 'en', 英語: 'en',
17  zh: 'zh-Hans', zhcn: 'zh-Hans', zhsg: 'zh-Hans', zhhans: 'zh-Hans', chinese: 'zh-Hans',
18  simplifiedchinese: 'zh-Hans', 中文: 'zh-Hans', 简体中文: 'zh-Hans', 简体: 'zh-Hans', 汉语: 'zh-Hans', 普通话: 'zh-Hans',
19  zhtw: 'zh-Hant', zhhk: 'zh-Hant', zhmo: 'zh-Hant', zhhant: 'zh-Hant', traditionalchinese: 'zh-Hant',
20  繁體中文: 'zh-Hant', 繁体中文: 'zh-Hant', 正體中文: 'zh-Hant', 繁體: 'zh-Hant', 繁体: 'zh-Hant',
21  ja: 'ja', japanese: 'ja', 日本語: 'ja', 日语: 'ja',
22  ko: 'ko', korean: 'ko', 한국어: 'ko', 韩语: 'ko', 韓語: 'ko',
23  es: 'es', spanish: 'es', español: 'es', espanol: 'es', castellano: 'es', 西班牙语: 'es',
24  fr: 'fr', french: 'fr', français: 'fr', francais: 'fr', 法语: 'fr',
25  de: 'de', german: 'de', deutsch: 'de', 德语: 'de',
26  pt: 'pt-BR', ptbr: 'pt-BR', portuguese: 'pt-BR', brazilianportuguese: 'pt-BR', português: 'pt-BR', portugues: 'pt-BR', 葡萄牙语: 'pt-BR',
27  ru: 'ru', russian: 'ru', русский: 'ru', 俄语: 'ru',
28}
29
30const fold = (s: string) => s.trim().toLowerCase().replace(/[\s_.-]/g, '')
31
32/** "简体中文", "zh_TW.UTF-8", "es-MX", "Deutsch" → a language the mods have, else null. */
33export function parseLanguage(text: unknown): Lang | null {
34  if (typeof text !== 'string' || !text.trim()) return null
35  const bare = text.split('.')[0]!.split('@')[0]!
36  const named = NAMES[fold(bare)]
37  if (named) return named
38  // A tag with a region or script this table does not list: its first subtag.
39  const [primary = '', ...rest] = bare.trim().toLowerCase().split(/[-_\s]/)
40  if (primary === 'zh') return rest.some(s => ['tw', 'hk', 'mo', 'hant'].includes(s)) ? 'zh-Hant' : 'zh-Hans'
41  return NAMES[primary] ?? null
42}
43
44/** The language to draw in: the option unless `auto`, then the setting, then the locale. */
45export function resolveLanguage(option: unknown, setting: unknown, locale: readonly (string | undefined)[]): Lang {
46  if (typeof option === 'string' && option !== 'auto' && (LANGS as readonly string[]).includes(option)) return option as Lang
47  return parseLanguage(setting) ?? locale.map(parseLanguage).find(Boolean) ?? 'en'
48}
49
50
51export type Params = Record<string, string | number>
52
53/**
54 * `m(key, params?, lang?)` over a mod's table: the message in the current
55 * language (English when missing), its `{placeholders}` filled.
56 */
57export function createMessages<K extends string>(table: Record<Lang, Record<K, string>>) {
58  let current: Lang = 'en'
59  return {
60    m: (key: K, params: Params = {}, lang: Lang = current): string =>
61      (table[lang][key] ?? table.en[key]).replace(/\{(\w+)\}/g, (_, k: string) => String(params[k] ?? '')),
62    setLang: (lang: Lang): void => {
63      current = lang
64    },
65    lang: (): Lang => current,
66  }
67}
68
hooks/kit/prefs.ts 60 lines
1// Generated from claude-code/kit/prefs.ts by scripts/sync-kit.sh: edit the source, then re-run it.
2// `/config` is where a mod's settings live: a slash command that changes one
3// (`/ts off`, `/spinner neon`) writes that row as the person would in the menu,
4// so the menu shows it, settings.json keeps it, and the engine reloads the
5// module with it. The command also sets the mod's session state, so the change
6// shows at once, and stays for the session where no row can be written
7// (`claude -p` has no plugin rows).
8//
9// The engine follows `$` only within the hooks module's own file, so the mod
10// hands the kit closures over `$` (a `Prefs`) rather than `$` itself.
11import type { ConfigValue } from 'claude-code'
12
13/** The mod's store and its own `/config` rows, as closures the hooks module builds over `$`. */
14export type Prefs = {
15  kept: (key: string) => Promise<unknown>
16  forget: (key: string) => Promise<void>
17  /** `$.config.set` on `<plugin>.<field>`. */
18  write: (field: string, value: ConfigValue) => Promise<{ deny?: string }>
19}
20
21/** Writes one of the mod's rows; false when no row took it. */
22export async function persist(prefs: Prefs, field: string, value: ConfigValue): Promise<boolean> {
23  const result = await prefs.write(field, value).catch(() => null)
24  return result !== null && result.deny === undefined
25}
26
27/** What a value kept in the store under an older version becomes: a row and its value, or nothing. */
28export type Move = (kept: unknown) => readonly [field: string, value: ConfigValue] | null
29
30/** The rows values older versions kept in `$.store` stand for, which the session applies at once. */
31export async function keptRows(prefs: Prefs, moves: Readonly<Record<string, Move>>): Promise<Record<string, ConfigValue>> {
32  const rows: Record<string, ConfigValue> = {}
33  for (const [key, move] of Object.entries(moves)) {
34    const kept = await prefs.kept(key)
35    const target = kept === undefined ? null : move(kept)
36    if (target) rows[target[0]] = target[1]
37  }
38  return rows
39}
40
41/**
42 * Moves those values to their `/config` rows, once: a key is dropped only
43 * after its row took the value (or it has none to give). Run it after
44 * `session.start`'s `next(e)`: the plugin's rows join `/config` as the
45 * session comes up, and the write brings a reload that applies them.
46 */
47export async function migrateStore(prefs: Prefs, moves: Readonly<Record<string, Move>>): Promise<void> {
48  for (const [key, move] of Object.entries(moves)) {
49    const kept = await prefs.kept(key)
50    if (kept === undefined) continue
51    const target = move(kept)
52    if (!target || (await persist(prefs, target[0], target[1]))) await prefs.forget(key)
53  }
54}
55
56/** `off` → true, `on` → false, anything else flips `isOff`. */
57export function switchArg(arg: string, isOff: boolean): boolean {
58  return arg === 'off' ? true : arg === 'on' ? false : !isOff
59}
60
hooks/parse.ts 56 lines
1import type { Link, Node } from '../types'
2
3type Peer = {
4  HostName?: string
5  Online?: boolean
6  CurAddr?: string
7  PeerRelay?: string
8  Relay?: string
9}
10
11export function parseStatus(json: string): Node[] {
12  const status = JSON.parse(json) as { Peer?: Record<string, Peer> }
13  const nodes = Object.values(status.Peer ?? {}).map(peer => {
14    const isOnline = peer.Online === true
15    let link: Link = 'offline'
16    let via = ''
17    if (isOnline && peer.CurAddr) {
18      link = 'direct'
19    } else if (isOnline && peer.PeerRelay) {
20      link = 'peer-relay'
21    } else if (isOnline) {
22      link = 'derp'
23      via = peer.Relay ?? ''
24    }
25
26    return { name: peer.HostName ?? '?', isOnline, link, via }
27  })
28
29  return nodes.sort((a, b) => a.name.localeCompare(b.name))
30}
31
32export type NodePick = { host: string; label: string }
33
34/** The `nodes` option ("nas=家里, dev-box"): hosts to show, in order, each with its label. */
35export function parseNodeSpec(spec: string): NodePick[] {
36  return spec
37    .split(',')
38    .map(item => item.trim())
39    .filter(Boolean)
40    .map(item => {
41      const [host = '', label = ''] = item.split('=').map(s => s.trim())
42      return { host: host.toLowerCase(), label: label || host }
43    })
44}
45
46/** The nodes picked, in the picks' order and renamed; no picks keeps every node. Host names match case-insensitively. */
47export function selectNodes(nodes: Node[], picks: readonly NodePick[]): Node[] {
48  if (picks.length === 0) {
49    return nodes
50  }
51  return picks.flatMap(({ host, label }) => {
52    const node = nodes.find(n => n.name.toLowerCase() === host)
53    return node ? [{ ...node, name: label }] : []
54  })
55}
56
hooks/kit/options.ts 31 lines
1// Generated from claude-code/kit/options.ts by scripts/sync-kit.sh: edit the source, then re-run it.
2// Readers for `register`'s options: each takes the raw value and the field's
3// default, so a mod's `readConfig` is one typed line per `userConfig` field.
4// The engine has validated the type already; these hold the ranges.
5
6export function text(value: unknown, fallback = ''): string {
7  return typeof value === 'string' ? value.trim() : fallback
8}
9
10export function flag(value: unknown, fallback: boolean): boolean {
11  return typeof value === 'boolean' ? value : fallback
12}
13
14/** A finite number, clamped to `min` / `max` and floored with `isInteger`. */
15export function count(
16  value: unknown,
17  fallback: number,
18  range: { min?: number; max?: number; isInteger?: boolean } = {},
19): number {
20  let n = typeof value === 'number' && Number.isFinite(value) ? value : fallback
21  if (range.isInteger) n = Math.floor(n)
22  if (range.min !== undefined) n = Math.max(range.min, n)
23  if (range.max !== undefined) n = Math.min(range.max, n)
24  return n
25}
26
27/** One of `values`, else the fallback. */
28export function oneOf<const T extends string>(value: unknown, values: readonly T[], fallback: T): T {
29  return values.find(v => v === value) ?? fallback
30}
31
types/index.d.ts 12 lines
1export type Link = 'direct' | 'peer-relay' | 'derp' | 'offline'
2
3export type Node = { name: string; isOnline: boolean; link: Link; via: string }
4
5export type Snapshot = { nodes: Node[]; checkedAt: number; error: string | null }
6
7declare module 'claude-code' {
8  interface PluginState {
9    'ts-band': { snapshot: Snapshot | null; isHidden: boolean | null; isPicking: boolean }
10  }
11}
12