A line from Hitokoto (一言) in a band above the prompt, refreshed on a timer, per session, per prompt or once a day

简体中文 · English
在输入框上方显示一句一言:诗词、文学、动画台词或哲理短句,后面淡淡地附上作者和出处。等 Claude 干活的时候,顺便读一句。

↻ 换一句(daily 模式下没有这个按钮)。claude -p 和 SDK 里不请求。claude plugin marketplace add hoobnn/hoobnn-agent-mods
claude plugin install hitokoto@hoobnn-agent-mods
/hitokoto:立刻换一句(daily 模式下替换今天的这句)。/hitokoto off、/hitokoto on:隐藏或显示横条。这个设置会写回 /config,以后的会话也会沿用;重新显示时会先取一句新的。在 /config 里修改,或写在 ~/.claude/settings.json 的 pluginConfigs 里:
| 选项 | 作用 | 默认 |
|---|---|---|
visible | 显示横条(/hitokoto off / on 改的就是它) | 开 |
refreshMode | interval 定时换、daily 每天一句、session 每个会话一句、prompt 每次发消息换一句 | interval |
categories | 一言的分类字母,逗号分隔,留空不限。a 动画、b 漫画、c 游戏、d 文学、e 原创、f 网络、g 其他、h 影视、i 诗词、j 网易云、k 哲学、l 抖机灵,例如 d,i,k | 空 |
intervalMinutes | interval 模式下几分钟换一句(至少 1) | 30 |
language | /hitokoto 回复和报错的语言: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)和 Tailscale 节点状态(ts-band),可以搭配使用。
hooks/register.tsx:钩子(会话开始时的语言、/hitokoto 和对应模式的刷新计划,命令和横条)。hooks/config.ts:选项,一次性读成类型化的 Config。hooks/parse.ts:接口地址和返回内容的解析、出处和本地日期。hooks/i18n.ts:各语言文案。hooks/kit/:claude-code/kit 的副本;改源文件后运行 scripts/sync-kit.sh。hooks/register.tsx 207 lines1import { atom, derive, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Quote } from '../types'
5import { readConfig } from './config'
6import { m, setLang } from './i18n'
7import { isPickerOpen, stackAbove } from './kit/band'
8import { resolveLanguage } from './kit/lang'
9import { keptRows, migrateStore, persist } from './kit/prefs'
10import type { Prefs } from './kit/prefs'
11import { attribution, localDate, parseQuote } from './parse'
12
13const quote = atom({ plugin: 'hitokoto', key: 'quote' } as const, null)
14// Set this session by `/hitokoto` or session.start; null in a session resumed or
15// cleared, which gets no session.start: `isHidden` is the `visible` row's then.
16const hiddenSet = atom({ plugin: 'hitokoto', key: 'isHidden' } as const, null as boolean | null)
17// The `visible` row's, set in register.
18let isRowHidden = false
19const isHidden = derive([hiddenSet], set => set ?? isRowHidden)
20// True while a picker is open above the band (see kit/band).
21const isPicking = atom({ plugin: 'hitokoto', key: 'isPicking' } as const, false)
22
23// How often daily mode looks whether the date has turned.
24const DAY_CHECK_MS = 10 * 60_000
25// A request that has not answered by then counts as failed.
26const FETCH_TIMEOUT_MS = 10_000
27
28/** The kit's hold on this mod's store and `/config` rows. */
29function prefsOf($: EngineInterface): Prefs {
30 return {
31 kept: key => $.store.get(key),
32 forget: key => $.store.delete(key),
33 write: (field, value) => $.config.set({ key: `hitokoto.${field}`, value }),
34 }
35}
36
37// One request at a time: a slow API or a burst of prompts never stacks them up.
38let inFlight: Promise<string | null> | null = null
39
40// A failed fetch keeps the line already shown; the error goes back to /hitokoto.
41function refresh($: EngineInterface, url: string): Promise<string | null> {
42 inFlight ??= request($, url).finally(() => {
43 inFlight = null
44 })
45 return inFlight
46}
47
48async function request($: EngineInterface, url: string): Promise<string | null> {
49 try {
50 const timeout = $.clock.sleep(FETCH_TIMEOUT_MS).then(() => null)
51 const response = await Promise.race([$.http.fetch(url), timeout])
52 if (response === null) {
53 return `timed out after ${FETCH_TIMEOUT_MS / 1000}s`
54 }
55 const { ok, status, text } = response
56 if (!ok) {
57 return `HTTP ${status}`
58 }
59 const fresh = parseQuote(text)
60 if (!fresh) {
61 return m('error.parse')
62 }
63 await update($, quote, () => fresh)
64 await $.store.set('last', fresh)
65 return null
66 } catch (err) {
67 return String(err)
68 }
69}
70
71// A new line; daily mode keeps it in the store as today's, for every session.
72async function fetchNew($: EngineInterface, url: string, isDaily: boolean): Promise<string | null> {
73 const error = await refresh($, url)
74 if (!error && isDaily) {
75 await $.store.set('daily', { date: localDate(await $.clock.now()), quote: await read($, quote) })
76 }
77 return error
78}
79
80// Today's line from the store, or a new one once the date has turned.
81async function showDaily($: EngineInterface, url: string): Promise<void> {
82 const kept = (await $.store.get('daily')) as { date?: unknown; quote?: Quote | null } | undefined
83 if (kept?.date === localDate(await $.clock.now()) && kept.quote) {
84 const q = kept.quote
85 if ((await read($, quote))?.text !== q.text) {
86 await update($, quote, () => q)
87 }
88 return
89 }
90 await fetchNew($, url, true)
91}
92
93// The last line any session fetched, shown while a new one is on its way (or offline).
94async function showLast($: EngineInterface): Promise<void> {
95 const kept = (await $.store.get('last')) as Quote | undefined
96 if (kept?.text && (await read($, quote)) === null) await update($, quote, () => kept)
97}
98
99// A timer's fetch, skipped while the band is hidden: no requests nobody sees.
100async function whenShown($: EngineInterface, fn: () => Promise<unknown>): Promise<void> {
101 if (!(await read($, isHidden))) await fn()
102}
103
104/** Shown or hidden; the `visible` row keeps it. */
105async function show($: EngineInterface, isShown: boolean): Promise<void> {
106 if ((await read($, isHidden)) === !isShown) return
107 await update($, hiddenSet, () => !isShown)
108 await persist(prefsOf($), 'visible', isShown)
109}
110
111// Before 0.4 `/hitokoto off` was kept in the store; it is the `visible` row now.
112const STORE_MOVES = { isHidden: (kept: unknown) => ['visible', kept !== true] as const }
113
114export const register: Register = (on, options) => {
115 const config = readConfig(options)
116 isRowHidden = !config.isVisible
117 const { url, mode } = config
118 const isDaily = mode === 'daily'
119
120 on('session.start', async ($, e, next) => {
121 // `-p` runs and the SDK draw no band: nothing to fetch for.
122 if (!e.isInteractive) return next(e)
123 const settings = (await $.settings.read().catch(() => ({}))) as { language?: unknown }
124 const locale = await Promise.all([
125 $.env.get('LC_ALL').catch(() => undefined),
126 $.env.get('LC_MESSAGES').catch(() => undefined),
127 $.env.get('LANG').catch(() => undefined),
128 ])
129 setLang(resolveLanguage(config.language, settings.language, locale))
130 await $.command.register({
131 name: 'hitokoto',
132 description: m('cmd.description'),
133 argumentHint: '[off|on]',
134 })
135 const kept = await keptRows(prefsOf($), STORE_MOVES)
136 await update($, hiddenSet, () => !(kept.visible ?? config.isVisible))
137
138 // Not awaited: session.start holds the first prompt until it settles.
139 if (isDaily) {
140 void showDaily($, url)
141 $.clock.every(DAY_CHECK_MS, () => void whenShown($, () => showDaily($, url)))
142 } else {
143 await showLast($)
144 void fetchNew($, url, false)
145 if (mode === 'interval') {
146 $.clock.every(config.intervalMs, () => void whenShown($, () => fetchNew($, url, false)))
147 }
148 }
149
150 const result = await next(e)
151 await migrateStore(prefsOf($), STORE_MOVES)
152 return result
153 })
154
155 on('prompt.submit', async ($, e, next) => {
156 if (await read($, isPicking)) await update($, isPicking, () => false)
157 if (mode === 'prompt') void whenShown($, () => fetchNew($, url, false))
158 return next(e)
159 })
160
161 on('command.run', { command: 'hitokoto' }, async ($, e) => {
162 const arg = e.args.trim().toLowerCase()
163 if (arg === 'off' || arg === 'on') {
164 await show($, arg === 'on')
165 // Hidden, the timers skipped their fetches: back on, the line is brought up to date.
166 if (arg === 'on') void (isDaily ? showDaily($, url) : fetchNew($, url, false))
167 return { text: m(arg === 'off' ? 'cmd.hidden' : 'cmd.shown') }
168 }
169
170 const error = await fetchNew($, url, isDaily)
171 await show($, true)
172 if (error) {
173 return { text: m('error.fetch', { error }) }
174 }
175 const q = await read($, quote)
176 return { text: q ? `${q.text} ${attribution(q)}`.trim() : m('error.fetch', { error: m('error.parse') }) }
177 })
178
179 // A picker (`/` commands, `@` files) opens above the band: the band steps aside meanwhile.
180 on('prompt.edit', async ($, e, next) => {
181 const box = await next(e)
182 const isOpen = isPickerOpen(box.text, box.cursor)
183 if ((await read($, isPicking)) !== isOpen) await update($, isPicking, () => isOpen)
184 return box
185 })
186
187 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
188 const q = await read($, quote)
189 if (e.props.hasSurvey || q === null || (await read($, isHidden)) || (await read($, isPicking))) {
190 return next(e)
191 }
192
193 const ui = $.ui.resolve(e)
194 const { Box, Button, Text } = ui
195 const by = attribution(q)
196 const line = (
197 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
198 <Text dimColor italic>『{q.text}』</Text>
199 {by ? <Text dimColor>{by}</Text> : null}
200 {/* A click brings a new line; daily mode keeps today's. */}
201 {isDaily ? null : <Button key="hitokoto-next" plain dimColor label="↻" onPress={() => void fetchNew($, url, false)} />}
202 </Box>
203 )
204 return stackAbove(ui, line, await next(e))
205 })
206}
207hooks/config.ts 29 lines1// hitokoto's options (plugin.json `userConfig`), read once into a typed config.
2import type { PluginOptions } from 'claude-code'
3
4import { count, flag, oneOf, text } from './kit/options'
5import { hitokotoUrl } from './parse'
6
7export const MODES = ['interval', 'daily', 'session', 'prompt'] as const
8export type Mode = (typeof MODES)[number]
9
10export type Config = {
11 /** The API's URL, the `categories` option in its query. */
12 url: string
13 mode: Mode
14 intervalMs: number
15 isVisible: boolean
16 /** `auto` or a language; the kit resolves it (kit/lang.ts). */
17 language: string
18}
19
20export function readConfig(options: PluginOptions): Config {
21 return {
22 url: hitokotoUrl(text(options.categories)),
23 mode: oneOf(options.refreshMode, MODES, 'interval'),
24 intervalMs: count(options.intervalMinutes, 30, { min: 1 }) * 60_000,
25 isVisible: flag(options.visible, true),
26 language: text(options.language, 'auto'),
27 }
28}
29hooks/i18n.ts 83 lines1// hitokoto'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' | 'error.fetch' | 'error.parse'
8
9export const MESSAGES: Record<Lang, Record<Key, string>> = {
10 en: {
11 'cmd.description': 'A new Hitokoto line; off / on hides or shows the band (kept across sessions)',
12 'cmd.hidden': 'Hitokoto band hidden',
13 'cmd.shown': 'Hitokoto band shown',
14 'error.fetch': 'Couldn’t fetch a Hitokoto line: {error}',
15 'error.parse': 'unreadable response',
16 },
17 'zh-Hans': {
18 'cmd.description': '换一句一言;off / on 隐藏或显示横条(跨会话保持)',
19 'cmd.hidden': '一言横条已隐藏',
20 'cmd.shown': '一言横条已显示',
21 'error.fetch': '一言获取失败:{error}',
22 'error.parse': '返回内容无法解析',
23 },
24 'zh-Hant': {
25 'cmd.description': '換一句一言;off / on 隱藏或顯示橫條(跨工作階段保留)',
26 'cmd.hidden': '一言橫條已隱藏',
27 'cmd.shown': '一言橫條已顯示',
28 'error.fetch': '一言取得失敗:{error}',
29 'error.parse': '回應內容無法解析',
30 },
31 ja: {
32 'cmd.description': '新しい Hitokoto(一言)を表示します。off / on でバーを非表示/表示(セッションをまたいで保持)',
33 'cmd.hidden': 'Hitokoto バーを非表示にしました',
34 'cmd.shown': 'Hitokoto バーを表示しました',
35 'error.fetch': 'Hitokoto を取得できませんでした: {error}',
36 'error.parse': '応答を解析できません',
37 },
38 ko: {
39 'cmd.description': 'Hitokoto(一言) 새로 고침. off / on으로 표시줄 숨기기/표시(세션 간 유지)',
40 'cmd.hidden': 'Hitokoto 표시줄을 숨겼습니다',
41 'cmd.shown': 'Hitokoto 표시줄을 표시했습니다',
42 'error.fetch': 'Hitokoto를 가져오지 못했습니다: {error}',
43 'error.parse': '응답을 해석할 수 없습니다',
44 },
45 es: {
46 'cmd.description': 'Otra frase de Hitokoto; off / on oculta o muestra la barra (se mantiene entre sesiones)',
47 'cmd.hidden': 'Barra de Hitokoto oculta',
48 'cmd.shown': 'Barra de Hitokoto visible',
49 'error.fetch': 'No se pudo obtener una frase de Hitokoto: {error}',
50 'error.parse': 'respuesta ilegible',
51 },
52 fr: {
53 'cmd.description': 'Nouvelle citation Hitokoto\u00a0; off / on masque ou affiche la barre (réglage conservé entre les sessions)',
54 'cmd.hidden': 'Barre Hitokoto masquée',
55 'cmd.shown': 'Barre Hitokoto affichée',
56 'error.fetch': 'Impossible de récupérer une citation Hitokoto\u00a0: {error}',
57 'error.parse': 'réponse illisible',
58 },
59 de: {
60 'cmd.description': 'Neuer Hitokoto-Spruch; off / on blendet die Leiste aus oder ein (bleibt über Sitzungen erhalten)',
61 'cmd.hidden': 'Hitokoto-Leiste ausgeblendet',
62 'cmd.shown': 'Hitokoto-Leiste eingeblendet',
63 'error.fetch': 'Hitokoto-Spruch konnte nicht geladen werden: {error}',
64 'error.parse': 'Antwort nicht lesbar',
65 },
66 'pt-BR': {
67 'cmd.description': 'Nova frase do Hitokoto; off / on oculta ou mostra a barra (configuração mantida entre sessões)',
68 'cmd.hidden': 'Barra do Hitokoto oculta',
69 'cmd.shown': 'Barra do Hitokoto visível',
70 'error.fetch': 'Não foi possível obter uma frase do Hitokoto: {error}',
71 'error.parse': 'resposta ilegível',
72 },
73 ru: {
74 'cmd.description': 'Новая цитата Hitokoto; off / on скрывает или показывает панель (сохраняется между сессиями)',
75 'cmd.hidden': 'Панель Hitokoto скрыта',
76 'cmd.shown': 'Панель Hitokoto показана',
77 'error.fetch': 'Не удалось получить цитату Hitokoto: {error}',
78 'error.parse': 'не удалось разобрать ответ',
79 },
80}
81
82export const { m, setLang } = createMessages(MESSAGES)
83hooks/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 41 lines1import type { Quote } from '../types'
2
3const CATEGORY = /^[a-l]$/
4
5// `categories` option ("d, i,k") to the API's query: one `c` per letter.
6export function hitokotoUrl(categories: string): string {
7 const letters = categories
8 .split(/[\s,]+/)
9 .map(c => c.trim().toLowerCase())
10 .filter(c => CATEGORY.test(c))
11 const query = ['encode=json', ...letters.map(c => `c=${c}`)].join('&')
12 return `https://v1.hitokoto.cn/?${query}`
13}
14
15// The API's text as one plain line: control characters (escapes, newlines) drawn as nothing.
16const clean = (value: unknown): string =>
17 typeof value === 'string' ? value.replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').replace(/\s+/g, ' ').trim() : ''
18
19export function parseQuote(body: string): Quote | null {
20 const json = JSON.parse(body) as { hitokoto?: unknown; from?: unknown; from_who?: unknown }
21 const text = clean(json.hitokoto)
22 if (!text) {
23 return null
24 }
25 return { text, from: clean(json.from), fromWho: clean(json.from_who) }
26}
27
28// "—— 鲁迅「呐喊」"; empty when the API names neither.
29export function attribution(quote: Quote): string {
30 const source = quote.from && quote.from !== quote.fromWho ? `「${quote.from}」` : ''
31 const who = quote.fromWho + source
32 return who ? `—— ${who}` : ''
33}
34
35// "2026-10-02" in the machine's time zone: the day daily mode keeps a line for.
36export function localDate(ms: number): string {
37 const d = new Date(ms)
38 const pad = (n: number) => String(n).padStart(2, '0')
39 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
40}
41hooks/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 8 lines1export type Quote = { text: string; from: string; fromWho: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 hitokoto: { quote: Quote | null; isHidden: boolean | null; isPicking: boolean }
6 }
7}
8