diff --git a/hooks/extract.test.ts b/hooks/extract.test.ts index 0c8dde9..0864c5e 100644 --- a/hooks/extract.test.ts +++ b/hooks/extract.test.ts @@ -17,25 +17,29 @@ const reply = (c: Extracted['characters'][number]) => type Setup = { files: Map; cmds: string[][]; prompts: string[] } +let cloneMissing = false +let registered: string[] = [] +const probed = new Set() +let sessionCwd = '/repo' const setup = (on: never, answers: string[], files = new Map()): Setup => { const s: Setup = { files, cmds: [], prompts: [] } const o = on as unknown as (n: string, h: (...a: never[]) => unknown) => void mock.store(on) - o('command.register', () => ({ value: { name: 'x' } })) + o('command.register', ((_$: unknown, e: { name: string }) => { registered.push(e.name); return { value: { name: e.name } } }) as never) o('session.start', () => ({ cwd: '/repo' })) - o('session.cwd', (() => ({ value: '/repo' })) as never) - o('fs.exists', ((_$: unknown, e: { path: string }) => ({ value: s.files.has(e.path) })) as never) + o('session.cwd', (() => ({ value: sessionCwd })) as never) + mock.env(on, { HOME: '/home/u' }) + o('fs.exists', ((_$: unknown, e: { path: string }) => (e.path.endsWith('/characters/index.json') && probed.add(e.path), { value: s.files.has(e.path) || (e.path === '/home/u/.cache/jsc-anima/repo/.git' && !cloneMissing) })) as never) o('fs.read', ((_$: unknown, e: { path: string }) => ({ value: s.files.get(e.path) ?? '' })) as never) o('fs.write', ((_$: unknown, e: { path: string; text: string }) => { s.files.set(e.path, e.text); return { value: undefined } }) as never) o('model.complete', ((_$: unknown, e: { prompt: string }) => { s.prompts.push(e.prompt) return { value: { isAnswered: true, text: answers.shift() ?? '', usage: {} } } }) as never) - o('env.get', (() => ({ value: undefined })) as never) - o('process.run', ((_$: unknown, e: { argv: string[] }) => { + o('process.run', ((_$: unknown, e: { argv: string[]; }) => { s.cmds.push(e.argv) const j = e.argv.join(' ') - const out = j.includes('--abbrev-ref') ? 'issue-4' : j.includes('symbolic-ref') ? 'origin/main' : j.includes('get-url') ? 'https://gitea.example/o/r.git' : j.startsWith('tea logins') ? 'admin https://gitea.example u' : j.startsWith('tea pulls') ? 'PR#1' : '' + const out = j.includes('--abbrev-ref') ? 'issue-4' : j.includes('symbolic-ref') ? 'origin/main' : j.includes('get-url') ? 'https://gitea.jsc.idv.tw/jiantw83/jsc-anima.git' : j.startsWith('tea logins') ? 'admin https://gitea.jsc.idv.tw u' : j.startsWith('tea pulls') ? 'PR#1' : '' return { value: { exitCode: 0, stdout: out, stderr: '' } } }) as never) return s @@ -50,18 +54,18 @@ test('萃取:先確認代號、再看變更摘要、最後寫檔並建立 PR', expect(t1).toContain('ts') expect(t1).toContain('aoi') expect(t1).toContain('尚未建立') - expect(s.files.has('/repo/characters/ts/aoi.json')).toBe(false) + expect(s.files.has('/home/u/.cache/jsc-anima/repo/characters/ts/aoi.json')).toBe(false) const t2 = await run($ as never, 'confirm work=testy aoi=ao') expect(t2).toContain('變更摘要') - expect(s.files.has('/repo/characters/testy/ao.json')).toBe(false) + expect(s.files.has('/home/u/.cache/jsc-anima/repo/characters/testy/ao.json')).toBe(false) const t3 = await run($ as never, 'confirm') expect(t3).toContain('已建立 PR') - const pack = JSON.parse(s.files.get('/repo/characters/testy/ao.json')!) + const pack = JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/testy/ao.json')!) expect(pack.timeline[0].chapter).toBe('ch1') expect(pack.timeline[0].age).toBe(16) - expect(JSON.parse(s.files.get('/repo/characters/testy/work.json')!).terms[0].term).toBe('魔核') + expect(JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/testy/work.json')!).terms[0].term).toBe('魔核') const pr = s.cmds.find(c => c[0] === 'tea' && c[1] === 'pulls')! expect(pr).toContain('main') expect(pr).toContain('feat/extract-testy-ch1') @@ -79,14 +83,14 @@ test('同章節重跑覆蓋、新章節追加、基礎設定不同標記待確 } await write('ch1.txt') await write('ch1.txt') - expect(JSON.parse(s.files.get('/repo/characters/ts/aoi.json')!).timeline.length).toBe(1) + expect(JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/ts/aoi.json')!).timeline.length).toBe(1) await run($ as never, 'ch2.txt') const summary = await run($ as never, 'confirm') expect(summary).toContain('追加') expect(summary).toContain('待確認') await run($ as never, 'confirm') - const pack = JSON.parse(s.files.get('/repo/characters/ts/aoi.json')!) + const pack = JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/ts/aoi.json')!) expect(pack.timeline.map((t: { chapter: string }) => t.chapter)).toEqual(['ch1', 'ch2']) expect(pack.persona.tone).toBe('溫柔') expect(pack.conflicts[0]).toMatchObject({ field: 'persona.tone', proposed: '傲嬌', status: '待確認' }) @@ -112,10 +116,10 @@ test('貼上章節內容:paste <章節名稱> 加下一行起的內容,並 expect(s.prompts[0]).toContain('這是貼上的章節內容。') await run($ as never, 'confirm') await run($ as never, 'confirm') - const pack = JSON.parse(s.files.get('/repo/characters/ts/aoi.json')!) + const pack = JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/ts/aoi.json')!) expect(pack.timeline[0].chapter).toBe('第一章') expect(pack.language).toBe('繁體中文,少量原作口癖') - expect(JSON.parse(s.files.get('/repo/characters/index.json')!).packs[0]).toEqual({ work: 'ts', workName: '測試物語', slug: 'aoi' }) + expect(JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/index.json')!).packs[0]).toEqual({ work: 'ts', workName: '測試物語', slug: 'aoi' }) }) test('欄位含長段引號內容視為抄錄原作台詞,不寫檔', async ($, on) => { @@ -126,3 +130,55 @@ test('欄位含長段引號內容視為抄錄原作台詞,不寫檔', async ($ expect(await run($ as never, 'confirm')).toContain('疑似抄錄原作台詞') expect([...s.files.keys()].some(k => k.includes('characters'))).toBe(false) }) + +test('在非 jsc-anima 目錄執行:git 與 PR 都走 jsc-anima 的快取 clone,不碰 session 目錄', async ($, on) => { + sessionCwd = '/other/proj' + try { + const s = setup(on as never, [reply(char())], new Map([['/other/proj/ch1.txt', '第一段']])) + await $.session.start({ source: 'startup', cwd: '/other/proj' } as never) + await run($ as never, '/other/proj/ch1.txt') + await run($ as never, 'confirm') + expect(await run($ as never, 'confirm')).toContain('已建立 PR') + expect(s.cmds.some(c => c.includes('fetch'))).toBe(true) + const pr = s.cmds.find(c => c[0] === 'tea' && c[1] === 'pulls')! + expect(pr).toContain('jiantw83/jsc-anima') + expect(s.files.has('/other/proj/characters/index.json')).toBe(false) + expect(JSON.parse(s.files.get('/home/u/.cache/jsc-anima/repo/characters/index.json')!).packs[0].slug).toBe('aoi') + } finally { + sessionCwd = '/repo' + } +}) + +test('快取 clone 不存在時先 clone jsc-anima', async ($, on) => { + cloneMissing = true + try { + const s = setup(on as never, [reply(char())], new Map([['/repo/ch1.txt', 'a']])) + await $.session.start({ source: 'startup', cwd: '/repo' } as never) + await run($ as never, 'ch1.txt') + const clone = s.cmds.find(c => c.includes('clone'))! + expect(clone).toContain('https://gitea.jsc.idv.tw/jiantw83/jsc-anima.git') + } finally { + cloneMissing = false + } +}) + +test('paste:CRLF 換行也能分出章節名稱與內容', async ($, on) => { + const s = setup(on as never, [reply(char())]) + await $.session.start({ source: 'startup', cwd: '/repo' } as never) + await run($ as never, 'paste 第二章\r\n第一行\r\n第二行') + expect(s.prompts[0]).toContain('第一行') +}) + +test('合併並更新外掛後:啟動時從外掛目錄的 index.json 註冊 {作品代號}-{角色代號} 並可啟動', async ($, on) => { + const s = setup(on as never, [reply(char())], new Map([['/repo/ch1.txt', 'a']])) + registered = [] + await $.session.start({ source: 'startup', cwd: '/repo' } as never) + await run($ as never, 'ch1.txt') + await run($ as never, 'confirm') + await run($ as never, 'confirm') + // 模擬 PR 合併、外掛更新:clone 內的 characters/ 內容出現在外掛目錄。 + const root = ([...probed].find(q => !q.startsWith('/home/u/')) ?? '').replace('/characters/index.json', '') + for (const [k, v] of [...s.files]) if (k.startsWith('/home/u/.cache/jsc-anima/repo/characters/')) s.files.set(k.replace('/home/u/.cache/jsc-anima/repo', root), v) + await $.session.start({ source: 'startup', cwd: '/anywhere' } as never) + expect(registered).toContain('ts-aoi') +}) diff --git a/hooks/extractCommand.ts b/hooks/extractCommand.ts index d125bbb..4903d50 100644 --- a/hooks/extractCommand.ts +++ b/hooks/extractCommand.ts @@ -1,9 +1,12 @@ import type { Engine } from 'claude-code' +import { teaToken } from './wiki' import { chunkText, extractPrompt, mergeChunks, mergeCharacter, mergeWork, parseExtracted, parseRemote, slugify, validateCharacter } from './extract' import type { CharacterPack, Conflict, Extracted, WorkPack } from './extract' export const EXTRACT_COMMAND = 'anima-extract' +// PR 永遠開在 jsc-anima repo,不論 session 在哪個專案:改用專屬的快取 clone 讀寫角色包。 +export const ANIMA_REPO_URL = 'https://gitea.jsc.idv.tw/jiantw83/jsc-anima.git' const PENDING_KEY = 'extract:pending' const USAGE = `用法:/jsc-anima:${EXTRACT_COMMAND} <章節檔案路徑>(或 paste <章節名稱> 並於下一行起貼上內容),之後用 /jsc-anima:${EXTRACT_COMMAND} confirm 逐步確認,cancel 取消。` @@ -38,6 +41,40 @@ async function readJson($: Engine, path: string): Promise { const slugKey = (c: Extracted['characters'][number]) => slugify(c.slug || c.nameEn || c.name) || c.name +type Repo = { dir: string; base: string; auth: string[] } + +async function gitIn($: Engine, auth: string[], dir: string, a: string[]) { + const r = await $.process.run(['git', ...auth, ...a], { cwd: dir, timeoutMs: 120000 }) + if (r.exitCode !== 0) throw new Error(`git ${a[0]} 失敗:${String(r.stderr).trim()}`) + return String(r.stdout).trim() +} + +// 取得 jsc-anima 的快取 clone 並對齊 origin 預設分支(認證優先 tea 登入,其次 GITEA_TOKEN;token 只放在指令參數)。 +async function syncRepo($: Engine): Promise { + const home = (await $.env.get('HOME')) ?? '' + const dir = `${home}/.cache/jsc-anima/repo` + let token: string | undefined + try { + token = teaToken(await $.fs.read(`${home}/.config/tea/config.yml`), new URL(ANIMA_REPO_URL).host) + } catch {} + token ??= (await $.env.get('GITEA_TOKEN')) || undefined + const auth = token ? ['-c', `http.extraHeader=Authorization: token ${token}`] : [] + try { + if (!(await $.fs.exists(`${dir}/.git`).catch(() => false))) { + await $.process.run(['mkdir', '-p', dir.replace(/\/[^/]+$/, '')], { timeoutMs: 5000 }) + await gitIn($, auth, home || '/', ['clone', ANIMA_REPO_URL, dir]) + } + await gitIn($, auth, dir, ['fetch', 'origin', '--prune']) + await gitIn($, auth, dir, ['remote', 'set-head', 'origin', '--auto']) + const base = (await gitIn($, auth, dir, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])).replace(/^origin\//, '') + await gitIn($, auth, dir, ['checkout', '--detach', '--force', `origin/${base}`]) + await gitIn($, auth, dir, ['clean', '-fd', 'characters']) + return { dir, base, auth } + } catch (err) { + return `無法取得 jsc-anima repo(${ANIMA_REPO_URL}):${(err as Error).message}` + } +} + async function stageFile($: Engine, path: string): Promise { const cwd = await $.session.cwd().catch(() => '') const full = path.startsWith('/') ? path : `${cwd}/${path}` @@ -48,7 +85,6 @@ async function stageFile($: Engine, path: string): Promise { } async function stage($: Engine, chapter: string, text: string): Promise { - const cwd = await $.session.cwd().catch(() => '') const chunks = chunkText(text) const parts: Extracted[] = [] for (const [i, chunk] of chunks.entries()) { @@ -58,6 +94,9 @@ async function stage($: Engine, chapter: string, text: string): Promise if (!parsed) return `第 ${i + 1}/${chunks.length} 段的回覆不是有效的 JSON,尚未寫入任何檔案。` parts.push(parsed) } + const repo = await syncRepo($) + if (typeof repo === 'string') return repo + const cwd = repo.dir const extracted = mergeChunks(parts) const workSlug = slugify(extracted.work.slug || extracted.work.nameEn || extracted.work.name) if (!workSlug) return '無法產生作品代號,請在章節中提供作品的英文名。' @@ -115,23 +154,19 @@ function run($: Engine, argv: string[], cwd: string) { } async function openPr($: Engine, p: Pending, plan: Plan): Promise { - const root = p.root - const git = async (...a: string[]) => { - const r = await run($, ['git', ...a], root) - if (r.exitCode !== 0) throw new Error(`git ${a[0]} 失敗:${String(r.stderr).trim()}`) - return String(r.stdout).trim() - } + const repo = await syncRepo($) + if (typeof repo === 'string') return repo + const root = repo.dir + const git = (...a: string[]) => gitIn($, repo.auth, root, a) try { - const prev = await git('rev-parse', '--abbrev-ref', 'HEAD') - const base = (await git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD')).replace(/^origin\//, '') + const base = repo.base const branch = `feat/extract-${plan.workSlug}-${slugify(p.chapter) || 'chapter'}` - await git('fetch', 'origin', base) - await git('switch', '-c', branch, '--no-track', `origin/${base}`) + await git('checkout', '-B', branch, '--no-track', `origin/${base}`) for (const f of plan.files) await $.fs.write(f.path, `${JSON.stringify(f.json, null, 2)}\n`) await git('add', 'characters') await git('commit', '-m', `feat: 萃取《${plan.workName}》${p.chapter} 的作品與角色包`) - await git('push', '-u', 'origin', branch) - await git('switch', prev) + await git('push', '-u', '--force-with-lease', 'origin', branch) + await git('checkout', '--detach', `origin/${base}`) const title = `feat: 萃取《${plan.workName}》${p.chapter} 的角色包` const body = [ @@ -177,7 +212,7 @@ export async function runExtract($: Engine, args: string): Promise { } if (head === 'paste') { // 貼上:第一行 paste <章節名稱>,下一行起為章節內容。 - const [first = '', ...body] = args.trim().split('\n') + const [first = '', ...body] = args.trim().replace(/\r\n?/g, '\n').split('\n') const name = first.replace(/^paste\s*/, '').trim() const text = body.join('\n') if (!name || !text.trim()) return `貼上章節的用法:/jsc-anima:${EXTRACT_COMMAND} paste <章節名稱>,下一行起貼上章節內容。` diff --git a/hooks/skillgen.ts b/hooks/skillgen.ts index fd649c8..3fad8d9 100644 --- a/hooks/skillgen.ts +++ b/hooks/skillgen.ts @@ -62,7 +62,7 @@ export const upsertEntry = (list: SkillEntry[], e: SkillEntry): SkillEntry[] => const USAGE_SECTIONS = [ '## 角色與萃取', '', - '- `/jsc-anima:anima-extract <章節檔案路徑>`:從輕小說章節萃取作品與角色,依序確認代號與變更摘要後,寫入 `characters/<作品代號>/` 並對預設分支建立 PR(不自動合併)。', + '- `/jsc-anima:anima-extract <章節檔案路徑>`:從輕小說章節萃取作品與角色,依序確認代號與變更摘要後,寫入 `characters/<作品代號>/` 並對 jsc-anima repo 的預設分支建立 PR(不自動合併)。在任何專案目錄執行都一樣,檔案會寫進 `~/.cache/jsc-anima/repo` 的快取 clone;PR 合併並更新外掛、重開 Claude Code 後,用 `/jsc-anima:<作品代號>-<角色代號>` 啟動。', '- `/jsc-anima:anima-extract paste <章節名稱>`(下一行起貼上章節內容):不想存檔時直接貼上,之後流程相同。', '', '## 待辦事項', diff --git a/skills/jsc-anima/README.md b/skills/jsc-anima/README.md index 89fdacb..764d30f 100644 --- a/skills/jsc-anima/README.md +++ b/skills/jsc-anima/README.md @@ -17,7 +17,7 @@ ## 角色與萃取 -- `/jsc-anima:anima-extract <章節檔案路徑>`:從輕小說章節萃取作品與角色,依序確認代號與變更摘要後,寫入 `characters/<作品代號>/` 並對預設分支建立 PR(不自動合併)。 +- `/jsc-anima:anima-extract <章節檔案路徑>`:從輕小說章節萃取作品與角色,依序確認代號與變更摘要後,寫入 `characters/<作品代號>/` 並對 jsc-anima repo 的預設分支建立 PR(不自動合併)。在任何專案目錄執行都一樣,檔案會寫進 `~/.cache/jsc-anima/repo` 的快取 clone;PR 合併並更新外掛、重開 Claude Code 後,用 `/jsc-anima:<作品代號>-<角色代號>` 啟動。 - `/jsc-anima:anima-extract paste <章節名稱>`(下一行起貼上章節內容):不想存檔時直接貼上,之後流程相同。 ## 待辦事項