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

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

TS ● 4/4 直连。◐ 黄色表示走中继或 DERP(会标出 DERP 区域,如 DERP-sfo),○ 红色表示离线。tailscale status --json;Claude 执行 tailscale up、down、set、switch、login、logout 后立即刷新。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 | 横条里不列离线节点(在线数仍然计入) | 关 |
tailscalePath | tailscale 命令的路径;留空时依次在 PATH、Homebrew 和 macOS 应用里找 | 空 |
intervalSeconds | 两次读取之间的秒数(至少 10) | 60 |
language | 界面语言:auto、en、zh-Hans、zh-Hant、ja、ko、es、fr、de、pt-BR、ru | auto |
language 为 auto 时,依次跟随 Claude Code 的 language 设置和系统语言环境,都没有时用英语。
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。hooks/register.tsx 247 lines1import { 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}
247hooks/config.ts 39 lines1// 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}
39hooks/i18n.ts 123 lines1// 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)
123hooks/kit/band.tsx 31 lines1// 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}
31hooks/kit/lang.ts 68 lines1// 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}
68hooks/kit/prefs.ts 60 lines1// 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}
60hooks/parse.ts 56 lines1import 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}
56hooks/kit/options.ts 31 lines1// 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}
31types/index.d.ts 12 lines1export 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