Files

115 lines
6.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Engine } from 'claude-code'
import { TODO_COMMAND } from './todo'
import {
GUIDE_SKILL, SKILL_DIR, SKILL_PREFIX, bumpVersion, commandOf, generatePrompt, guideSkillMd, parseArgs,
parseEntries, parseGenerated, renderGuide, setPluginVersion, setReadmeVersion, upsertEntry, validateSkill,
} from './skillgen'
export const TOOL_COMMAND = 'anima-skill'
export const OFF_COMMAND = 'anima-off'
export const NEED_ACTIVE = '請先啟動角色(/jsc-anima:{作品代號}-{角色代號})再使用這個工具。'
export async function registerTools($: Engine): Promise<void> {
await $.command.register({ name: TOOL_COMMAND, description: '依描述產生助理技能並建立 PR:<技能名稱> <描述>' })
await $.command.register({ name: OFF_COMMAND, description: '離開角色,回到一般的 Claude Code' })
await $.command.register({ name: TODO_COMMAND, description: '待辦事項:add/update/list/type/run/done' })
}
const MODEL = 'sonnet'
function git($: Engine, cwd: string, ...argv: string[]) {
return $.process.run(['git', ...argv], { cwd, timeoutMs: 60000 })
}
async function complete($: Engine, prompt: string, system?: string): Promise<string | undefined> {
const r = await $.model.complete({ model: MODEL, prompt, system, maxTokens: 4000, timeoutMs: 120000 })
return r.isAnswered && r.text.trim() ? r.text : undefined
}
// Gitea 認證:優先 tea 登入,其次 GITEA_TOKEN;token 只放在行程環境與標頭,不寫檔。
async function createPr($: Engine, cwd: string, base: string, head: string, title: string, body: string): Promise<string> {
const tea = await $.process.run(['tea', 'logins', 'list'], { cwd, timeoutMs: 10000 }).catch(() => undefined)
if (tea && tea.exitCode === 0 && tea.stdout.trim()) {
const r = await $.process.run(['tea', 'pulls', 'create', '--base', base, '--head', head, '--title', title, '--description', body], { cwd, timeoutMs: 60000 })
if (r.exitCode !== 0) throw new Error(`建立 PR 失敗:${r.stderr.trim()}`)
return r.stdout.trim()
}
const token = (await $.env.get('GITEA_TOKEN').catch(() => undefined)) as string | undefined
if (!token) throw new Error('找不到 Gitea 認證:請先 tea login add,或設定 GITEA_TOKEN。')
const remote = (await git($, cwd, 'remote', 'get-url', 'origin')).stdout.trim()
const m = remote.match(/^(https?:\/\/[^/]+)\/(.+?)(?:\.git)?$/)
if (!m) throw new Error(`無法解析 origin:${remote}`)
const r = await $.process.run(
['curl', '-fsS', '-X', 'POST', `${m[1]}/api/v1/repos/${m[2]}/pulls`, '-H', `Authorization: token ${token}`, '-H', 'Content-Type: application/json',
'-d', JSON.stringify({ base, head, title, body })],
{ cwd, timeoutMs: 60000 },
)
if (r.exitCode !== 0) throw new Error(`建立 PR 失敗:${r.stderr.trim()}`)
return (JSON.parse(r.stdout) as { html_url?: string }).html_url ?? ''
}
export async function runSkillTool($: Engine, args: string): Promise<string> {
const a = parseArgs(args)
if (!a) return '用法:/jsc-anima:anima-skill <技能名稱> <描述>'
const { skill, description } = a
const name = `${SKILL_PREFIX}${skill}`
// 1. 產生
const gen = await complete($, generatePrompt(skill, description)).then(t => (t ? parseGenerated(t) : undefined))
if (!gen) return '產生技能失敗(模型沒有回傳可用內容),未建立 PR。'
// 2. 驗證
const errs = validateSkill(gen.skill, skill)
if (errs.length) return `技能驗證失敗,未建立 PR:\n- ${errs.join('\n- ')}`
// 3. 以範例輸入實跑
const run = await complete($, gen.example, gen.skill)
if (!run) return '技能實跑失敗(範例輸入沒有得到回應),未建立 PR。'
// 4. 在獨立 worktree 寫檔,不動使用者目前的工作樹
const cwd = await $.session.cwd()
const top = (await git($, cwd, 'rev-parse', '--show-toplevel')).stdout.trim()
if (!top) return '目前目錄不在 git repo 內,未建立 PR。'
const head = (await git($, top, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD')).stdout.trim().replace(/^origin\//, '')
const base = head || 'main'
const branch = `feat/${name}`
const wt = `${top}/.anima-wt-${skill}`
await git($, top, 'fetch', 'origin', base)
const add = await git($, top, 'worktree', 'add', '-B', branch, wt, `origin/${base}`)
if (add.exitCode !== 0) return `建立工作目錄失敗:${add.stderr.trim()}`
try {
const skillPath = `${wt}/${SKILL_DIR}/${name}/SKILL.md`
const isOverwrite = await $.fs.exists(skillPath)
const pluginPath = `${wt}/.claude-plugin/plugin.json`
const plugin = await $.fs.read(pluginPath)
const cur = plugin.match(/"version":\s*"([^"]*)"/)?.[1] ?? '0.0.0'
const next = bumpVersion(cur, isOverwrite ? 'patch' : 'minor')
const guidePath = `${wt}/${SKILL_DIR}/${GUIDE_SKILL}/README.md`
const old = (await $.fs.exists(guidePath)) ? await $.fs.read(guidePath) : ''
const list = upsertEntry(parseEntries(old), { skill, description })
await $.fs.write(skillPath, gen.skill.trim() + '\n')
await $.fs.write(`${wt}/${SKILL_DIR}/${GUIDE_SKILL}/SKILL.md`, guideSkillMd())
await $.fs.write(guidePath, renderGuide(list, { skill, input: gen.example }))
await $.fs.write(pluginPath, setPluginVersion(plugin, next))
const readmePath = `${wt}/README.md`
await $.fs.write(readmePath, setReadmeVersion(await $.fs.read(readmePath), next))
const verb = isOverwrite ? '更新' : '新增'
await git($, wt, 'add', '-A')
const c = await git($, wt, 'commit', '-m', `feat: ${verb}技能 ${name}(v${next})`)
if (c.exitCode !== 0) return `commit 失敗:${c.stderr.trim()}`
const push = await git($, wt, 'push', '-u', 'origin', branch)
if (push.exitCode !== 0) return `推送失敗:${push.stderr.trim()}`
const url = await createPr($, wt, base, branch, `feat: ${verb}技能 ${name}`, `${description}\n\n指令:${commandOf(skill)}\n版本:${cur} → ${next}\n\n已通過驗證並以範例輸入實跑。`)
return `已${verb}技能 ${commandOf(skill)}(v${next}),PR:${url}`
} catch (e) {
return `建立 PR 失敗:${e instanceof Error ? e.message : String(e)}`
} finally {
await git($, top, 'worktree', 'remove', '--force', wt).catch(() => undefined)
}
}