111 lines
5.8 KiB
TypeScript
111 lines
5.8 KiB
TypeScript
import type { Engine } from 'claude-code'
|
||
|
||
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 NEED_ACTIVE = '請先啟動角色(/jsc-anima:{作品代號}-{角色代號})再使用這個工具。'
|
||
|
||
export async function registerTools($: Engine): Promise<void> {
|
||
await $.command.register({ name: TOOL_COMMAND, description: '依描述產生助理技能並建立 PR:<技能名稱> <描述>' })
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|