SLOPSHOPPER

aib-languages

Ez2 AIB 切換語言(zh-TW / en / ja / ko):/aib-languages 開選單;切完介面、狀態列、mod、AI 回答下一句就跟,不用 /clear

newpanecommandtoaststatusprompt
v0.1.0Apache-2.0updated 2026-10-15ez2app/ez2-aib/extensions/aib-languages
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · aib-languages
│ ┃ !!mod.aib_languages.title!! ✕ › fix the failing auth test and add an audit log call │ ┃ !!mod.aib_languages.current!! │ ┃ [ 1 繁體中文 ✓ ][ 2 English ][ 3 日本語 ][ 4 ⏺ Read(src/auth.ts) │ ┃ !!mod.aib_languages.hint!! ⎿ 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 │ │ › /aib-languages │ ⎿ aib-languages: !!mod.aib_languages.title!! │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · !!mod.aib_languages.title!!
!!mod.aib_languages.current!! [ 1 繁體中文 ✓ ][ 2 English ][ 3 日本語 ][ 4 한국어 ] !!mod.aib_languages.hint!!
README

aib-languages — 切換 AIB 語言(Claude Code mod,免費,公開版可帶)

/aib-languages 開一個小框:四個語言各用自己的寫法(1 繁體中文 · 2 English · 3 日本語 · 4 한국어),s 切「這台全部艙 / 只這一艙」,Esc 關。 /aib-languages en 直接切,不開框。

切了之後(設計稿 docs/DESIGN-aib-languages-04e0a-1014.md):

  • 設定檔:aib lang <loc> 寫 <狀態根>/locale(全部艙)或 $EZ2_AIB_INSTANCE_HOME/locale(只這艙)。順位:明給 > EZ2_AIB_LOCALE > 艙檔 > 人檔 > zh-TW。
  • 本席:mod 立刻 $.env.set("EZ2_AIB_LOCALE")(掛勾子行程跟)、狀態列 🌐 en;每次請求的系統提示多一段「回答語言」⇒ 下一句就換,不用 /clear。
  • 別席 / 艦長室切的:mod 每 5 秒看設定檔 mtime,變了就跟(env、狀態列、toast)。
  • 其他畫面:每輪重算的狀態列、底部頁簽、右欄、hub、aib 指令輸出都讀設定檔;非 Claude 席收到一封 dm 請改口。
  • mod 自己的字走 repo i18n/<loc>.json 的 mod.aib_languages.* 鍵(i18n_lib.py --prefix mod.aib_languages --json 一次取);別的 mod 照 hooks/i18n.ts(純函式)+ register.tsx 的 bundle() 兩段做。

裝法:/plugin marketplace add <repo>/tools/claude-mods/aib-languages,再 /plugin install aib-languages@ez2-aib-languages。 測試:tests/test-claude-mods-languages-04e0a-1014.py(複製到暫存目錄跑 claude plugin validate + claude plugin test,6 支)。

Source 3 files
hooks/register.tsx 167 lines
1// Ez2 AIB 切換語言(10-14,the maintainer「所有介面、按鈕、mod、顯示、對話都要對應到」;設計 docs/DESIGN-aib-languages-04e0a-1014.md §4)。
2// 真相在設定檔(aib lang 寫);這個 mod 做四件事:
3//   1. /aib-languages 開選單(四個語言各用自己的寫法;範圍:這台全部艙 / 只這一艙)→ 跑 aib lang → toast。
4//   2. 本席行程 $.env.set("EZ2_AIB_LOCALE"):掛勾子行程(aib-now-gate、claim-gate…)立刻跟。
5//   3. 每 5 秒看設定檔 mtime:別席 / 艦長室切了,本席 env、狀態列、別的 mod 的字跟上(bundle 快取以語系為鍵)。
6//   4. prompt.compose 每次請求加「回答語言」段(session scope):切完**下一句**就換,不用 /clear,蓋過人格包舊行。
7import { atom, read, update } from 'claude-code'
8import type { Register, EngineInterface } from 'claude-code'
9import type { LangStatus, Scope } from '../types'
10import { LOCALES, SELF_NAMES, normalize, cached, remember, forget, makeT } from './i18n'
11import type { Loc } from './i18n'
12
13const PANE = 'aib-languages'
14const NO_AIB_FALLBACK = 'Cannot find the aib command (EZ2_AIB_ROOT unset and no ~/ez2-ai-b)'
15const PREFIX = 'mod.aib_languages'
16const POLL_MS = 5000
17// 初始值要是物件:引擎用 WeakMap 記版本,null 當鍵會炸(kit 二分 10-14 實測);locale 空 = 還沒讀到
18const EMPTY: LangStatus = { locale: '', source: 'default', choices: [], files: { user: '' } }
19const status = atom({ plugin: 'aib-languages', key: 'status' } as const, EMPTY)
20const scope = atom({ plugin: 'aib-languages', key: 'scope' } as const, 'machine' as Scope)
21let poll: (() => void) | undefined          // $.clock.every 回的是「取消」函式
22let seen: Record<string, number> = {}     // 檔路徑 → 上次看到的 mtimeMs(模組變數:重載後第一輪重讀一次,無妨)
23
24/** aib 倉的根:$EZ2_AIB_ROOT / $AIB_SOURCE_ROOT → ~/ez2-ai-b。回 null = 找不到(呼叫端要說出來,不猜) */
25async function repoRoot($: EngineInterface): Promise<string | null> {
26  // 驗證器要 $.env.get 的名字是字面值(它列得出模組讀寫哪些變數),所以不用迴圈
27  const explicit = (await $.env.get('EZ2_AIB_ROOT').catch(() => undefined)) || (await $.env.get('AIB_SOURCE_ROOT').catch(() => undefined))
28  if (explicit && (await $.fs.exists(`${explicit}/src/cockpit/i18n_lib.py`).catch(() => false))) return explicit
29  const home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
30  const guess = `${home}/ez2-ai-b`
31  if (home && (await $.fs.exists(`${guess}/src/cockpit/i18n_lib.py`).catch(() => false))) return guess
32  return null
33}
34
35/** 讀一個 mod 的整組字(設計 §5):同語系第二次起走快取 */
36async function bundle($: EngineInterface, prefix: string, loc: Loc): Promise<Record<string, string>> {
37  const hit = cached(prefix, loc)
38  if (hit) return hit
39  const root = await repoRoot($)
40  if (!root) return {}
41  const py = (await $.env.get('EZ2_PY3').catch(() => undefined)) ?? 'python3'
42  const r = await $.process.run([py, '-I', `${root}/src/cockpit/i18n_lib.py`, '--locale', loc, '--prefix', prefix, '--json'], { timeoutMs: 10_000 }).catch(() => null)
43  if (!r || r.exitCode !== 0) return {}
44  let data: Record<string, string> = {}
45  try { data = JSON.parse(r.stdout.trim().split('\n').pop() || '{}') } catch { data = {} }
46  remember(prefix, loc, data)
47  return data
48}
49
50async function aibBin($: EngineInterface): Promise<string | null> {
51  const root = await repoRoot($)
52  return root ? `${root}/src/cockpit/aib` : null
53}
54
55async function readStatus($: EngineInterface, args: string[] = []): Promise<LangStatus> {
56  const bin = await aibBin($)
57  if (!bin) throw new Error('no_aib')
58  const r = await $.process.run([bin, 'lang', ...args, '--json'], { timeoutMs: 60_000 })
59  const last = r.stdout.trim().split('\n').pop() || ''
60  if (r.exitCode !== 0 || !last.startsWith('{')) throw new Error((r.stderr || r.stdout).trim().split('\n').pop() || `rc=${r.exitCode}`)
61  return JSON.parse(last) as LangStatus
62}
63
64async function tFor($: EngineInterface, loc: Loc) { return makeT(PREFIX, await bundle($, PREFIX, loc)) }
65
66/** 把新語系套到本席:env、狀態列、重畫 */
67async function apply($: EngineInterface, st: LangStatus, announce: boolean) {
68  const loc = normalize(st.locale) ?? 'zh-TW'
69  const prev = (await read($, status)).locale
70  await update($, status, () => st)
71  await $.env.set('EZ2_AIB_LOCALE', loc)
72  if (prev !== st.locale) forget()
73  const t = await tFor($, loc)
74  $.ui.status(t('status', { value: loc }))
75  if (announce && prev !== st.locale) $.ui.toast(t('switched', { value: SELF_NAMES[loc] }))
76  $.ui.invalidate('ui.render')
77}
78
79async function checkFiles($: EngineInterface) {
80  const st = await read($, status)
81  if (!st.locale) return
82  let changed = false
83  for (const p of Object.values(st.files)) {
84    if (!p) continue
85    const m = await $.fs.stat(p).then(s => s.mtimeMs).catch(() => -1)     // -1 = 檔不在(被 --clear 掉也算變)
86    if (seen[p] !== undefined && seen[p] !== m) changed = true
87    seen[p] = m
88  }
89  if (!changed) return
90  const fresh = await readStatus($).catch(() => null)
91  if (fresh && fresh.locale !== st.locale) await apply($, fresh, true)
92}
93
94async function switchTo($: EngineInterface, loc: Loc, sc: Scope): Promise<string | null> {
95  try {
96    const st = await readStatus($, [loc, ...(sc === 'cabin' ? ['--cabin'] : [])])
97    for (const p of Object.values(st.files)) if (p) seen[p] = await $.fs.stat(p).then(s => s.mtimeMs).catch(() => -1)
98    await apply($, st, true)
99    return null
100  } catch (err) {
101    return String((err as Error).message ?? err)
102  }
103}
104
105export const register: Register = on => {
106  on('session.start', async ($, e, next) => {
107    await $.command.register({ name: 'aib-languages', description: 'Switch the AIB language (zh-TW / en / ja / ko)' })
108    const st = await readStatus($).catch(() => null)
109    if (st) {
110      await apply($, st, false)
111      for (const p of Object.values(st.files)) if (p) seen[p] = await $.fs.stat(p).then(s => s.mtimeMs).catch(() => -1)
112    }
113    poll?.()
114    poll = $.clock.every(POLL_MS, () => { void checkFiles($) })
115    return next(e)
116  })
117
118  on('command.run', { command: 'aib-languages' }, async ($, e) => {
119    const cur = normalize((await read($, status)).locale) ?? 'zh-TW'
120    const t = await tFor($, cur)
121    const want = normalize(e.args.trim().split(/\s+/)[0])
122    if (want) {                                   // /aib-languages en:直接切,不開框
123      const sc: Scope = /--cabin\b/.test(e.args) ? 'cabin' : await read($, scope)
124      const err = await switchTo($, want, sc)
125      if (err === 'no_aib') { const m = t('no_aib'); return { text: m.startsWith('!!') ? NO_AIB_FALLBACK : m } }   // 找不到倉 ⇒ 字典也讀不到 ⇒ 英文保底
126      const t2 = await tFor($, want)
127      return { text: err ? t2('failed', { what: err }) : t2('switched', { value: SELF_NAMES[want] }) }
128    }
129    const opened = await $.ui.open({ id: PANE, title: t('title'), focus: true, closeOnEscape: true, rows: 7 })
130    return { text: opened.isPlaced ? t('title') : t('failed', { what: 'pane' }) }
131  })
132
133  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
134    const { Box, Button, Text } = $.ui.resolve(e)
135    const st = await read($, status)
136    const cur = normalize(st.locale) ?? 'zh-TW'
137    const sc = await read($, scope)
138    const t = await tFor($, cur)
139    const hasCabin = !!st.files.cabin
140    const scopeLabel = t(sc === 'cabin' && hasCabin ? 'scope_cabin' : 'scope_machine')
141    return (
142      <Box flexDirection="column">
143        <Text>{t('current', { value: cur, what: t('source_' + st.source) })}</Text>
144        <Box>
145          {LOCALES.map((loc, i) => (
146            <Button key={`loc-${loc}`} hotkey={String(i + 1)} label={`${i + 1} ${SELF_NAMES[loc]}${loc === cur ? ' ✓' : ''}`}
147              onPress={async () => { const err = await switchTo($, loc, await read($, scope)); if (err) { const t2 = await tFor($, cur); $.ui.toast(err === 'no_aib' ? (t2('no_aib').startsWith('!!') ? NO_AIB_FALLBACK : t2('no_aib')) : t2('failed', { what: err })) } else await $.ui.close({ id: PANE }) }} />
148          ))}
149        </Box>
150        <Box>
151          {hasCabin && <Button key="scope" hotkey="s" label={`s ${scopeLabel}`} onPress={() => update($, scope, v => (v === 'cabin' ? 'machine' : 'cabin'))} />}
152          <Text dimColor> {t('hint', { what: scopeLabel })}</Text>
153        </Box>
154      </Box>
155    )
156  })
157
158  on('prompt.compose', async ($, e, next) => {
159    const r = await next(e)
160    const st = await read($, status)
161    const loc = normalize(st.locale || (await $.env.get('EZ2_AIB_LOCALE').catch(() => undefined))) ?? 'zh-TW'
162    const t = await tFor($, loc)
163    const text = `${t('reply_line', { value: `${SELF_NAMES[loc]} (${loc})` })} Reply language: ${SELF_NAMES[loc]} (${loc}); this AIB language setting overrides the persona card or any earlier pack. Answer in this language.`
164    return { sections: [...r.sections.filter(s => s.id !== 'aib-languages:reply-language'), { id: 'aib-languages:reply-language', text, scope: 'session' }] }
165  })
166}
167
hooks/i18n.ts 35 lines
1// mod 端 i18n(docs/DESIGN-aib-languages-04e0a-1014.md §5):字放 repo i18n/<loc>.json 的 mod.<名>.* 鍵,
2// 由 `i18n_lib.py --locale L --prefix mod.<名> --json` 一次取整組(缺譯已由 i18n_lib 補 en);依語系快取;
3// 缺鍵回 !!key!!(loader-spec fail-loud,畫面上一眼看得到)。
4// 這一檔只放**純函式**:驗證器不准把 $ 傳進別的檔的函式,所以讀字典那段(要 $)住在 register.tsx 的 bundle()。別的 mod 照抄兩段即可。
5export const LOCALES = ['zh-TW', 'en', 'ja', 'ko'] as const
6export type Loc = (typeof LOCALES)[number]
7/** 每個語言用自己的寫法(選單上不管現在是哪個語系,都看得懂自己那顆) */
8export const SELF_NAMES: Record<Loc, string> = { 'zh-TW': '繁體中文', en: 'English', ja: '日本語', ko: '한국어' }
9
10export function normalize(raw: string | undefined | null): Loc | null {
11  const t = (raw ?? '').trim().split('@')[0]!.split('.')[0]!.replace('_', '-').toLowerCase()
12  const m: Record<string, Loc> = { zh: 'zh-TW', 'zh-tw': 'zh-TW', 'zh-hant': 'zh-TW', 'zh-hant-tw': 'zh-TW', en: 'en', 'en-us': 'en', 'en-gb': 'en', ja: 'ja', 'ja-jp': 'ja', ko: 'ko', 'ko-kr': 'ko' }
13  return m[t] ?? null
14}
15
16export function fmt(template: string, params?: Record<string, string | number>): string {
17  if (!params) return template
18  return template.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (whole, name: string) => (name in params ? String(params[name]) : whole))
19}
20
21const bundles = new Map<string, Record<string, string>>()
22
23/** 快取:prefix@loc → 整組字;切語系時 forget() */
24export function cached(prefix: string, loc: Loc): Record<string, string> | undefined { return bundles.get(`${prefix}@${loc}`) }
25export function remember(prefix: string, loc: Loc, data: Record<string, string>): void { bundles.set(`${prefix}@${loc}`, data) }
26export function forget(): void { bundles.clear() }
27
28export function makeT(prefix: string, data: Record<string, string>) {
29  return (key: string, params?: Record<string, string | number>): string => {
30    const full = `${prefix}.${key}`
31    const v = data[full]
32    return v === undefined ? `!!${full}!!` : fmt(v, params)
33  }
34}
35
types/index.d.ts 24 lines
1// aib-languages 的 $.state 契約(claude plugin validate 用它核對模組裡每個 $.state 鍵)
2export type Locale = 'zh-TW' | 'en' | 'ja' | 'ko'
3export type Scope = 'machine' | 'cabin'
4
5/** `aib lang --json` 回的那一包(src/cockpit/aib_lang.py status()) */
6export interface LangStatus {
7  locale: string
8  source: string
9  choices: string[]
10  files: { cabin?: string; user: string }
11  notified?: number
12}
13
14declare module 'claude-code' {
15  interface PluginState {
16    'aib-languages': {
17      /** 現在生效的語系與來源(session.start 與每 5 秒輪詢更新) */
18      status: LangStatus
19      /** 選單上「套用範圍」的選擇;重載後還在 */
20      scope: Scope
21    }
22  }
23}
24