diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
new file mode 100644
index 0000000..d4c68a3
--- /dev/null
+++ b/.claude-plugin/marketplace.json
@@ -0,0 +1,14 @@
+{
+ "name": "jsc-anima",
+ "owner": {
+ "name": "jsc"
+ },
+ "plugins": [
+ {
+ "name": "jsc-anima",
+ "source": "./",
+ "description": "動漫助理:用喜愛角色的口吻互動,附單行狀態列"
+ }
+ ],
+ "description": "jsc 的動漫助理插件"
+}
\ No newline at end of file
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
new file mode 100644
index 0000000..4b9d6c0
--- /dev/null
+++ b/.claude-plugin/plugin.json
@@ -0,0 +1,111 @@
+{
+ "name": "jsc-anima",
+ "version": "0.1.0",
+ "description": "動漫助理:用喜愛角色的口吻互動,附單行狀態列",
+ "license": "MIT",
+ "userConfig": {
+ "enabled": {
+ "type": "boolean",
+ "title": "啟用助理",
+ "description": "關閉後停止所有 persona 注入與狀態列",
+ "default": true
+ },
+ "persist": {
+ "type": "boolean",
+ "title": "跨 session 保留啟動",
+ "description": "下次開啟時自動恢復上次的角色",
+ "default": true
+ },
+ "statusbar": {
+ "type": "boolean",
+ "title": "顯示狀態列",
+ "description": "輸入框上方的單行狀態列",
+ "default": true
+ },
+ "fields": {
+ "type": "string",
+ "title": "狀態列欄位與順序",
+ "description": "逗號分隔:character,model,dir,branch,5h,7d,ctx,sys",
+ "default": "character,model,dir,branch,5h,7d,ctx,sys"
+ },
+ "barStyle": {
+ "type": "string",
+ "title": "量表樣式",
+ "description": "block 或 dot",
+ "options": [
+ "block",
+ "dot"
+ ],
+ "default": "block"
+ },
+ "warnAt": {
+ "type": "number",
+ "title": "黃色門檻 (%)",
+ "description": "使用率達此值轉黃",
+ "default": 60
+ },
+ "dangerAt": {
+ "type": "number",
+ "title": "紅色門檻 (%)",
+ "description": "使用率達此值轉紅",
+ "default": 85
+ },
+ "refreshSec": {
+ "type": "number",
+ "title": "系統資源更新秒數",
+ "description": "CPU/RAM/DSK 的取樣間隔",
+ "default": 10
+ },
+ "axisGentle": {
+ "type": "number",
+ "title": "溫柔 (0-10,-1 沿用角色包)",
+ "description": "覆蓋目前角色的數值軸,只存本機",
+ "default": -1
+ },
+ "axisTsundere": {
+ "type": "number",
+ "title": "傲嬌 (0-10,-1 沿用角色包)",
+ "description": "覆蓋目前角色的數值軸,只存本機",
+ "default": -1
+ },
+ "axisLively": {
+ "type": "number",
+ "title": "活潑 (0-10,-1 沿用角色包)",
+ "description": "覆蓋目前角色的數值軸,只存本機",
+ "default": -1
+ },
+ "axisFormal": {
+ "type": "number",
+ "title": "正式 (0-10,-1 沿用角色包)",
+ "description": "覆蓋目前角色的數值軸,只存本機",
+ "default": -1
+ },
+ "axisTalkative": {
+ "type": "number",
+ "title": "話量 (0-10,-1 沿用角色包)",
+ "description": "覆蓋目前角色的數值軸,只存本機",
+ "default": -1
+ },
+ "axisHumor": {
+ "type": "number",
+ "title": "幽默 (0-10,-1 沿用角色包)",
+ "description": "覆蓋目前角色的數值軸,只存本機",
+ "default": -1
+ },
+ "axisProactive": {
+ "type": "number",
+ "title": "主動 (0-10,-1 沿用角色包)",
+ "description": "覆蓋目前角色的數值軸,只存本機",
+ "default": -1
+ },
+ "axisExpressive": {
+ "type": "number",
+ "title": "情緒外顯 (0-10,-1 沿用角色包)",
+ "description": "覆蓋目前角色的數值軸,只存本機",
+ "default": -1
+ }
+ },
+ "author": {
+ "name": "jsc"
+ }
+}
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..7c7ba78
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 jsc
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 6ea2051..1303c54 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,34 @@
+

+
# jsc-anima
+
+動漫助理:用喜愛角色的口吻和你互動,並在輸入框上方顯示單行狀態列(角色、模型、目錄、分支、5h/7d 剩餘額度、ctx、系統資源)。
+
+## 安裝
+
+```
+/plugin install jsc-anima --marketplace jiantw83/jsc-anima
+```
+
+或用 CLI:
+
+```
+claude plugin marketplace add https://gitea.jsc.idv.tw/jiantw83/jsc-anima
+claude plugin install jsc-anima
+```
+
+## 更新
+
+```
+claude plugin update jsc-anima
+```
+
+## 移除
+
+```
+claude plugin uninstall jsc-anima
+```
+
+## 授權
+
+[MIT](LICENSE)
diff --git a/hooks/anima.test.ts b/hooks/anima.test.ts
new file mode 100644
index 0000000..fb2cf4b
--- /dev/null
+++ b/hooks/anima.test.ts
@@ -0,0 +1,49 @@
+import { expect, mock, test } from 'claude-code/testing'
+
+import { HIDE_ORDER, bar, layout, remainColor, usedColor } from './statusline'
+import type { Segment } from './statusline'
+
+const seg = (id: string, text: string): Segment => ({ id, parts: [{ text }] })
+
+test('啟動角色後 persona 注入系統提示', async ($, on) => {
+ mock.store(on)
+ on('command.register', () => ({ value: { name: 'x' } }) as never)
+ on('session.start', () => ({ cwd: '/tmp' }) as never)
+ on('command.run', () => ({ text: '' }))
+ on('prompt.compose', () => ({ sections: [] }))
+ await $.session.start({ source: 'startup', cwd: '/tmp' } as never)
+ await $.command.run({ command: 'sao-asuna', args: '' } as never)
+ const res = await $.prompt.compose({ model: 'm', promptModel: 'm', surfaces: [], tools: [], outputStyle: null, traits: [] } as never)
+ expect(res.sections.some(s => s.id === 'jsc-anima:persona' && s.text.includes('亞絲娜'))).toBe(true)
+})
+
+test('切換角色與離開', async ($, on) => {
+ mock.store(on)
+ on('command.register', () => ({ value: { name: 'x' } }) as never)
+ on('session.start', () => ({ cwd: '/tmp' }) as never)
+ on('command.run', () => ({ text: '' }))
+ on('prompt.compose', () => ({ sections: [] }))
+ await $.session.start({ source: 'startup', cwd: '/tmp' } as never)
+ await $.command.run({ command: 'sao-asuna', args: '' } as never)
+ await $.command.run({ command: 'sao-suguha', args: '' } as never)
+ const compose = () => $.prompt.compose({ model: 'm', promptModel: 'm', surfaces: [], tools: [], outputStyle: null, traits: [] } as never)
+ expect((await compose()).sections.find(s => s.id === 'jsc-anima:persona')?.text).toContain('直葉')
+ await $.command.run({ command: 'anima-off', args: '' } as never)
+ expect((await compose()).sections.some(s => s.id === 'jsc-anima:persona')).toBe(false)
+})
+
+test('狀態列:窄時依序整欄隱藏,不截斷', () => {
+ const all = [seg('character', 'AB'), seg('model', 'CD'), seg('dir', 'EF'), seg('branch', 'GH'), seg('5h', 'IJ'), seg('7d', 'KL'), seg('ctx', 'MN'), seg('sys', 'OP')]
+ expect(layout(all, 999).length).toBe(8)
+ expect(layout(all, 26).map(s => s.id)).not.toContain('sys')
+ const narrow = layout(all, 14).map(s => s.id)
+ expect(narrow).toEqual(['character', 'model', '5h', 'ctx'])
+ expect([...HIDE_ORDER]).toEqual(['sys', '7d', 'dir', 'branch'])
+})
+
+test('量表固定長度,剩餘越少越紅', () => {
+ expect(bar(0).length).toBe(bar(100).length)
+ expect(remainColor(10, 60, 85)).toBe('red')
+ expect(remainColor(90, 60, 85)).toBe('green')
+ expect(usedColor(70, 60, 85)).toBe('yellow')
+})
diff --git a/hooks/characters.ts b/hooks/characters.ts
new file mode 100644
index 0000000..df6e138
--- /dev/null
+++ b/hooks/characters.ts
@@ -0,0 +1,69 @@
+export type Axes = {
+ gentle: number
+ tsundere: number
+ lively: number
+ formal: number
+ talkative: number
+ humor: number
+ proactive: number
+ expressive: number
+}
+
+export type Character = {
+ slug: string
+ name: string
+ work: string
+ persona: string
+ axes: Axes
+}
+
+// 角色包只含自行撰寫的人設,不含原作台詞。
+export const CHARACTERS: readonly Character[] = [
+ {
+ slug: 'sao-asuna',
+ name: '亞絲娜',
+ work: '刀劍神域',
+ persona:
+ '自稱「我」,稱呼使用者為「你」。語氣沉穩溫柔,認真可靠,偶爾嘴上嚴格但行動體貼;重視效率與夥伴的安全,遇到粗心的操作會輕聲提醒。',
+ axes: { gentle: 8, tsundere: 3, lively: 5, formal: 5, talkative: 5, humor: 4, proactive: 7, expressive: 5 },
+ },
+ {
+ slug: 'sao-suguha',
+ name: '直葉',
+ work: '刀劍神域',
+ persona:
+ '自稱「我」,稱呼使用者為「你」。語氣開朗坦率,有點害羞但很努力;喜歡鼓勵人,做事乾脆,被誇獎時會不好意思。',
+ axes: { gentle: 7, tsundere: 2, lively: 8, formal: 3, talkative: 6, humor: 5, proactive: 6, expressive: 8 },
+ },
+]
+
+export const findCharacter = (slug: string | undefined): Character | undefined =>
+ CHARACTERS.find(c => c.slug === slug)
+
+const AXIS_LABEL: Record = {
+ gentle: '溫柔',
+ tsundere: '傲嬌',
+ lively: '活潑',
+ formal: '正式',
+ talkative: '話量',
+ humor: '幽默',
+ proactive: '主動',
+ expressive: '情緒外顯',
+}
+
+export const applyOverrides = (axes: Axes, over: Partial): Axes => {
+ const out = { ...axes }
+ for (const k of Object.keys(over) as (keyof Axes)[]) {
+ const v = over[k]
+ if (typeof v === 'number' && v >= 0 && v <= 10) out[k] = v
+ }
+ return out
+}
+
+export const personaSection = (c: Character, axes: Axes): string =>
+ [
+ `你現在扮演《${c.work}》的${c.name},用她的個性與語氣和使用者互動並協助工作。`,
+ c.persona,
+ `個性數值(0-10):${(Object.keys(AXIS_LABEL) as (keyof Axes)[]).map(k => `${AXIS_LABEL[k]} ${axes[k]}`).join('、')}。`,
+ '預設使用繁體中文,可少量帶入原作口癖;程式碼、路徑、指令、數字與 URL 必須原樣保留;角色扮演不影響技術內容的正確性與安全。',
+ ].join('\n')
diff --git a/hooks/hooks.json b/hooks/hooks.json
new file mode 100644
index 0000000..5a63f64
--- /dev/null
+++ b/hooks/hooks.json
@@ -0,0 +1 @@
+{ "modules": ["./register.tsx"] }
diff --git a/hooks/register.tsx b/hooks/register.tsx
new file mode 100644
index 0000000..2ac24c6
--- /dev/null
+++ b/hooks/register.tsx
@@ -0,0 +1,183 @@
+import type { Engine, Register } from 'claude-code'
+
+import { CHARACTERS, applyOverrides, findCharacter, personaSection } from './characters'
+import type { Axes } from './characters'
+import { layout, meter, parseFields, remainColor, SEPARATOR, usedColor } from './statusline'
+import type { Segment } from './statusline'
+
+const ACTIVE_KEY = 'active'
+const OFF_COMMAND = 'anima-off'
+
+const AXIS_OPTION: Record = {
+ gentle: 'axisGentle',
+ tsundere: 'axisTsundere',
+ lively: 'axisLively',
+ formal: 'axisFormal',
+ talkative: 'axisTalkative',
+ humor: 'axisHumor',
+ proactive: 'axisProactive',
+ expressive: 'axisExpressive',
+}
+
+const SYS_SCRIPT =
+ 'a=$(awk \'{print $1}\' /proc/loadavg); n=$(nproc); ' +
+ 'echo "$a $n"; free | awk \'/Mem:/{printf "%d\\n", $3*100/$2}\'; df / | awk \'NR==2{gsub("%","",$5); print $5}\''
+
+const resetText = (iso: string | undefined, long: boolean): string => {
+ if (!iso) return ''
+ const d = new Date(iso)
+ if (Number.isNaN(d.getTime())) return ''
+ const p = (n: number) => String(n).padStart(2, '0')
+ return long ? ` ${d.getMonth() + 1}/${d.getDate()}` : ` ${p(d.getHours())}:${p(d.getMinutes())}`
+}
+
+const fmtTokens = (n: number): string => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
+
+type Sys = { at: number; cpu: number; ram: number; dsk: number }
+
+async function readSys($: Engine, holder: { sys?: Sys }, refreshMs: number): Promise {
+ const now = await $.clock.now()
+ if (holder.sys && now - holder.sys.at < refreshMs) return holder.sys
+ try {
+ const r = await $.process.run(['sh', '-c', SYS_SCRIPT], { timeoutMs: 3000 })
+ const [l1, l2, l3] = String(r.stdout).trim().split('\n')
+ const [load, cores] = (l1 ?? '').split(' ').map(Number)
+ holder.sys = {
+ at: now,
+ cpu: Math.min(100, Math.round(((load || 0) / (cores || 1)) * 100)),
+ ram: Number(l2) || 0,
+ dsk: Number(l3) || 0,
+ }
+ } catch {
+ holder.sys = undefined
+ }
+ return holder.sys
+}
+
+export const register: Register = (on, options) => {
+ const opt = options as Record
+ const flag = (k: string, d: boolean) => (typeof opt[k] === 'boolean' ? (opt[k] as boolean) : d)
+ const num = (k: string, d: number) => (typeof opt[k] === 'number' ? (opt[k] as number) : d)
+ const str = (k: string, d: string) => (typeof opt[k] === 'string' ? (opt[k] as string) : d)
+
+ const isEnabled = flag('enabled', true)
+ let active: string | undefined
+ const holder: { sys?: Sys } = {}
+
+ const overrides = (): Partial => {
+ const out: Partial = {}
+ for (const k of Object.keys(AXIS_OPTION) as (keyof Axes)[]) {
+ const v = num(AXIS_OPTION[k], -1)
+ if (v >= 0) out[k] = v
+ }
+ return out
+ }
+
+ on('session.start', async ($, e, next) => {
+ if (isEnabled) {
+ for (const c of CHARACTERS) {
+ await $.command.register({ name: c.slug, description: `啟動《${c.work}》的${c.name}` })
+ }
+ await $.command.register({ name: OFF_COMMAND, description: '離開角色,回到一般的 Claude Code' })
+ const saved = await $.store.get(ACTIVE_KEY)
+ active = flag('persist', true) && typeof saved === 'string' && findCharacter(saved) ? saved : undefined
+ }
+ return next(e)
+ })
+
+ on('command.run', async ($, e, next) => {
+ if (!isEnabled) return next(e)
+ if (e.command === OFF_COMMAND) {
+ active = undefined
+ await $.store.delete(ACTIVE_KEY)
+ return { text: '已離開角色,回到一般模式。' }
+ }
+ const c = findCharacter(e.command)
+ if (!c) return next(e)
+ active = c.slug
+ await $.store.set(ACTIVE_KEY, c.slug)
+ return { text: `已啟動:${c.name}(${c.work})。用 /jsc-anima:${OFF_COMMAND} 離開。` }
+ })
+
+ on('prompt.compose', async ($, e, next) => {
+ const res = await next(e)
+ const c = isEnabled ? findCharacter(active) : undefined
+ if (!c) return res
+ const text = personaSection(c, applyOverrides(c.axes, overrides()))
+ return {
+ ...res,
+ sections: [...res.sections.filter(s => s.id !== 'jsc-anima:persona'), { id: 'jsc-anima:persona', text, scope: 'session' as const }],
+ }
+ })
+
+ on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
+ const c = isEnabled ? findCharacter(active) : undefined
+ if (!c || !flag('statusbar', true) || e.props.hasSurvey) return next(e)
+
+ const warn = num('warnAt', 60)
+ const danger = num('dangerAt', 85)
+ const style = str('barStyle', 'block') === 'dot' ? 'dot' : 'block'
+ const usage = await $.session.usage().catch(() => undefined)
+ const cwd = await $.session.cwd().catch(() => '')
+ const branch = await $.process
+ .run(['git', 'branch', '--show-current'], { cwd: cwd || undefined, timeoutMs: 2000 })
+ .then(r => String(r.stdout).trim())
+ .catch(() => '')
+ const model = await $.session.model().catch(() => '')
+
+ const quota = (kind: string, label: string, long: boolean): Segment['parts'] | undefined => {
+ const rl = usage?.rateLimits.find(r => r.kind === kind)
+ if (!rl) return undefined
+ const remain = Math.max(0, 100 - rl.percentUsed)
+ return meter(label, remain, remainColor(remain, warn, danger), style, resetText(rl.resetsAt, long))
+ }
+
+ const builders: Record Promise | Segment['parts'] | undefined> = {
+ character: () => [{ text: c.name, color: 'cyan' }],
+ model: () => (model ? [{ text: model }] : undefined),
+ dir: () => (cwd ? [{ text: cwd.split('/').filter(Boolean).pop() ?? '/' }] : undefined),
+ branch: () => (branch ? [{ text: `⎇ ${branch}` }] : undefined),
+ '5h': () => quota('five_hour', '5h', false),
+ '7d': () => quota('seven_day', '7d', true),
+ ctx: () => {
+ const cx = usage?.context
+ if (!cx || cx.percent === undefined) return undefined
+ return meter('ctx', cx.percent, usedColor(cx.percent, warn, danger), style, ` ${fmtTokens(cx.tokens ?? 0)}/${fmtTokens(cx.window)}`)
+ },
+ sys: async () => {
+ const s = await readSys($, holder, num('refreshSec', 10) * 1000)
+ if (!s) return undefined
+ return [
+ { text: 'CPU ' }, { text: `${s.cpu}%`, color: usedColor(s.cpu, warn, danger) },
+ { text: ' RAM ' }, { text: `${s.ram}%`, color: usedColor(s.ram, warn, danger) },
+ { text: ' DSK ' }, { text: `${s.dsk}%`, color: usedColor(s.dsk, warn, danger) },
+ ]
+ },
+ }
+
+ const segments: Segment[] = []
+ for (const id of parseFields(str('fields', ''))) {
+ const parts = await builders[id]?.()
+ if (parts) segments.push({ id, parts })
+ }
+
+ const shown = layout(segments, e.props.bodyColumns)
+ const { Box, Text } = $.ui.resolve(e)
+ return (
+
+
+
+ {shown.map((s, i) => (
+
+ {i > 0 ? {SEPARATOR} : null}
+ {s.parts.map((p, j) => (
+ {p.text}
+ ))}
+
+ ))}
+
+
+
+ )
+ })
+}
diff --git a/hooks/statusline.ts b/hooks/statusline.ts
new file mode 100644
index 0000000..357aece
--- /dev/null
+++ b/hooks/statusline.ts
@@ -0,0 +1,61 @@
+export type Color = 'green' | 'yellow' | 'red' | 'cyan' | undefined
+export type Part = { text: string; color?: Color; dim?: boolean }
+export type Segment = { id: string; parts: Part[] }
+
+export const BAR_LEN = 8
+
+export const textWidth = (s: string): number => {
+ let w = 0
+ for (const ch of s) {
+ const c = ch.codePointAt(0)!
+ w += c >= 0x1100 && (c <= 0x115f || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe6f) || (c >= 0xff00 && c <= 0xff60) || (c >= 0xffe0 && c <= 0xffe6)) ? 2 : 1
+ }
+ return w
+}
+
+export const bar = (pct: number, style: 'block' | 'dot' = 'block'): string => {
+ const n = Math.round((Math.max(0, Math.min(100, pct)) / 100) * BAR_LEN)
+ const [on, off] = style === 'dot' ? ['●', '○'] : ['█', '░']
+ return on.repeat(n) + off.repeat(BAR_LEN - n)
+}
+
+// 使用率:越高越紅
+export const usedColor = (pct: number, warn: number, danger: number): Color =>
+ pct >= danger ? 'red' : pct >= warn ? 'yellow' : 'green'
+
+// 剩餘:量表越少越紅
+export const remainColor = (remain: number, warn: number, danger: number): Color =>
+ usedColor(100 - remain, warn, danger)
+
+export const FIELD_IDS = ['character', 'model', 'dir', 'branch', '5h', '7d', 'ctx', 'sys'] as const
+// 窄終端機時整欄隱藏的順序
+export const HIDE_ORDER = ['sys', '7d', 'dir', 'branch'] as const
+
+export const parseFields = (s: string): string[] => {
+ const ids = s.split(',').map(x => x.trim()).filter(x => (FIELD_IDS as readonly string[]).includes(x))
+ return ids.length ? [...new Set(ids)] : [...FIELD_IDS]
+}
+
+const SEP = ' │ '
+
+export const segmentWidth = (s: Segment): number => s.parts.reduce((n, p) => n + textWidth(p.text), 0)
+
+// 整欄隱藏,絕不截斷。
+export const layout = (segments: Segment[], columns: number): Segment[] => {
+ const list = [...segments]
+ const total = () => list.reduce((n, s) => n + segmentWidth(s), 0) + Math.max(0, list.length - 1) * textWidth(SEP)
+ for (const id of HIDE_ORDER) {
+ if (total() <= columns) break
+ const i = list.findIndex(s => s.id === id)
+ if (i >= 0) list.splice(i, 1)
+ }
+ return list
+}
+
+export const SEPARATOR = SEP
+
+export const meter = (label: string, pct: number, color: Color, style: 'block' | 'dot', suffix: string): Segment['parts'] => [
+ { text: `${label} ` },
+ { text: bar(pct, style), color },
+ { text: ` ${Math.round(pct)}%${suffix}` },
+]
diff --git a/icon.png b/icon.png
new file mode 100644
index 0000000..57305c9
Binary files /dev/null and b/icon.png differ