SLOPSHOPPER

hitokoto

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

newbandcommandpromptnetworktimer
★ 5v0.6.1MITupdated 2026-10-09hoobnn/hoobnn-agent-mods/claude-code/hitokoto
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · hitokoto
› 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 › /hitokoto ⎿ hitokoto: Couldn’t fetch a Hitokoto line: HTTP 0 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

hitokoto:Claude Code 输入框上方的一言

简体中文 · English

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

hitokoto:输入框上方的一句一言和出处

功能

  • 一句话加出处:句子后面是作者和出处,点末尾的 ↻ 换一句(daily 模式下没有这个按钮)。
  • 四种更换方式:定时换(默认每 30 分钟)、每天一句(所有会话同一句,过了零点换新的)、每个会话一句、每次发消息换一句。
  • 按分类挑:可以只要诗词、文学、哲学等分类。
  • 省流量、不空白:横条隐藏时不请求;同一时间只发一个请求,10 秒没响应算失败;上次取到的句子会保存,新会话或离线时直接显示它,不会空着。
  • 在 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 改的就是它)开
refreshModeinterval 定时换、daily 每天一句、session 每个会话一句、prompt 每次发消息换一句interval
categories一言的分类字母,逗号分隔,留空不限。a 动画、b 漫画、c 游戏、d 文学、e 原创、f 网络、g 其他、h 影视、i 诗词、j 网易云、k 哲学、l 抖机灵,例如 d,i,k空
intervalMinutesinterval 模式下几分钟换一句(至少 1)30
language/hitokoto 回复和报错的语言:auto、en、zh-Hans、zh-Hant、ja、ko、es、fr、de、pt-BR、ru(句子本身是中文)auto

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

同系列 mod

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。
Source 9 files
hooks/register.tsx 207 lines
1import { 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}
207
hooks/config.ts 29 lines
1// 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}
29
hooks/i18n.ts 83 lines
1// 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)
83
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 41 lines
1import 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}
41
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 8 lines
1export 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