feat: 階段 4 工具二:助理技能 #12
@@ -2,6 +2,8 @@
|
||||
|
||||
# jsc-anima
|
||||
|
||||
目前版本:0.1.0
|
||||
|
||||
動漫助理:用喜愛角色的口吻和你互動,並在輸入框上方顯示單行狀態列(角色、模型、目錄、分支、5h/7d 剩餘額度、ctx、系統資源)。
|
||||
|
||||
## 安裝
|
||||
@@ -29,6 +31,10 @@ claude plugin update jsc-anima
|
||||
claude plugin uninstall jsc-anima
|
||||
```
|
||||
|
||||
## 使用說明
|
||||
|
||||
技能列表與範例見 [skills/jsc-anima/README.md](skills/jsc-anima/README.md)。
|
||||
|
||||
## 授權
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Effort, ModelName, Route } from './router'
|
||||
import { REWRITE_SYSTEM, needsSonnet, protect, restore, withPrefix } from './rewrite'
|
||||
import { layout, meter, parseFields, remainColor, SEPARATOR, usedColor } from './statusline'
|
||||
import type { Segment } from './statusline'
|
||||
import { NEED_ACTIVE, TOOL_COMMAND, registerTools, runSkillTool } from './skilltool'
|
||||
|
||||
const ACTIVE_KEY = 'active'
|
||||
const OFF_COMMAND = 'anima-off'
|
||||
@@ -84,6 +85,7 @@ export const register: Register = (on, options) => {
|
||||
|
||||
const isEnabled = flag('enabled', true)
|
||||
let active: string | undefined
|
||||
let toolRegistered = false
|
||||
let route: Route | undefined
|
||||
let prefixed = false
|
||||
const holder: { sys?: Sys } = {}
|
||||
@@ -114,6 +116,10 @@ export const register: Register = (on, options) => {
|
||||
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
|
||||
if (active && !toolRegistered) {
|
||||
toolRegistered = true
|
||||
await registerTools($)
|
||||
}
|
||||
}
|
||||
return next(e)
|
||||
})
|
||||
@@ -125,6 +131,9 @@ export const register: Register = (on, options) => {
|
||||
await $.store.delete(ACTIVE_KEY)
|
||||
return { text: '已離開角色,回到一般模式。' }
|
||||
}
|
||||
if (e.command === TOOL_COMMAND) {
|
||||
return { text: findCharacter(active) ? await runSkillTool($, e.args) : NEED_ACTIVE }
|
||||
}
|
||||
if (e.command === AFFINITY_COMMAND) {
|
||||
const c = findCharacter(active)
|
||||
if (!c) return { text: '請先啟動角色。' }
|
||||
@@ -144,6 +153,11 @@ export const register: Register = (on, options) => {
|
||||
active = c.slug
|
||||
route = undefined
|
||||
await $.store.set(ACTIVE_KEY, c.slug)
|
||||
// 工具二到四只在啟動角色後才註冊。
|
||||
if (!toolRegistered) {
|
||||
toolRegistered = true
|
||||
await registerTools($)
|
||||
}
|
||||
return { text: `已啟動:${c.name}(${c.work})。用 /jsc-anima:${OFF_COMMAND} 離開。` }
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
export const PLUGIN = 'jsc-anima'
|
||||
export const SKILL_PREFIX = 'anima-'
|
||||
export const SKILL_DIR = 'skills'
|
||||
export const GUIDE_SKILL = 'jsc-anima'
|
||||
export const LIST_START = '<!-- skills:start -->'
|
||||
export const LIST_END = '<!-- skills:end -->'
|
||||
|
||||
export type Parsed = { name: string; description: string; body: string }
|
||||
|
||||
export const slugify = (s: string): string =>
|
||||
s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
|
||||
export const commandOf = (skill: string): string => `/${PLUGIN}:${SKILL_PREFIX}${skill}`
|
||||
|
||||
// 指令第一個字是技能名稱,其餘是描述。
|
||||
export const parseArgs = (args: string): { skill: string; description: string } | undefined => {
|
||||
const m = args.trim().match(/^(\S+)\s+([\s\S]+)$/)
|
||||
if (!m) return undefined
|
||||
const skill = slugify(m[1]!)
|
||||
return skill ? { skill, description: m[2]!.trim() } : undefined
|
||||
}
|
||||
|
||||
export const parseSkillMd = (md: string): Parsed | undefined => {
|
||||
const m = md.trim().match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
|
||||
if (!m) return undefined
|
||||
const field = (k: string) => m[1]!.match(new RegExp(`^${k}:\\s*(.+)$`, 'm'))?.[1]?.trim().replace(/^["']|["']$/g, '')
|
||||
return { name: field('name') ?? '', description: field('description') ?? '', body: m[2]!.trim() }
|
||||
}
|
||||
|
||||
// 回傳錯誤訊息清單,空陣列代表通過。
|
||||
export const validateSkill = (md: string, skill: string): string[] => {
|
||||
const p = parseSkillMd(md)
|
||||
if (!p) return ['缺少 frontmatter(--- 包住的 name 與 description)']
|
||||
const errs: string[] = []
|
||||
if (p.name !== `${SKILL_PREFIX}${skill}`) errs.push(`name 必須是 ${SKILL_PREFIX}${skill},實際為「${p.name}」`)
|
||||
if (!p.description) errs.push('description 不可為空')
|
||||
if (p.body.length < 20) errs.push('內文過短')
|
||||
return errs
|
||||
}
|
||||
|
||||
export const bumpVersion = (v: string, kind: 'minor' | 'patch'): string => {
|
||||
const [a = 0, b = 0, c = 0] = v.split('.').map(n => Number(n) || 0)
|
||||
return kind === 'minor' ? `${a}.${b + 1}.0` : `${a}.${b}.${c + 1}`
|
||||
}
|
||||
|
||||
export type SkillEntry = { skill: string; description: string }
|
||||
|
||||
export const parseEntries = (readme: string): SkillEntry[] => {
|
||||
const block = readme.split(LIST_START)[1]?.split(LIST_END)[0] ?? ''
|
||||
const out: SkillEntry[] = []
|
||||
for (const line of block.split('\n')) {
|
||||
const m = line.match(/^- `\/jsc-anima:anima-([^`\s]+)[^`]*` — (.*)$/)
|
||||
if (m) out.push({ skill: m[1]!, description: m[2]! })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export const upsertEntry = (list: SkillEntry[], e: SkillEntry): SkillEntry[] =>
|
||||
[...list.filter(x => x.skill !== e.skill), e].sort((a, b) => a.skill.localeCompare(b.skill))
|
||||
|
||||
// 技能 README:名稱 /jsc-anima:jsc-anima、技能列表(完整可複製指令)與範例。
|
||||
export const renderGuide = (list: SkillEntry[], example?: { skill: string; input: string }): string => {
|
||||
const ex = example ?? { skill: list[0]?.skill ?? 'example', input: '' }
|
||||
return [
|
||||
`# /${PLUGIN}:${GUIDE_SKILL}`,
|
||||
'',
|
||||
'動漫助理的使用說明。先用 `/jsc-anima:{作品代號}-{角色代號}` 啟動角色,再使用下列技能。',
|
||||
'',
|
||||
'## 技能列表',
|
||||
'',
|
||||
LIST_START,
|
||||
...list.map(e => `- \`${commandOf(e.skill)}\` — ${e.description}`),
|
||||
LIST_END,
|
||||
'',
|
||||
'## 範例',
|
||||
'',
|
||||
'```',
|
||||
`${commandOf(ex.skill)}${ex.input ? ' ' + ex.input : ''}`,
|
||||
'```',
|
||||
'',
|
||||
'產生新技能:`/jsc-anima:anima-skill <技能名稱> <描述>`,通過驗證與實跑後自動對預設分支建立 PR。',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export const guideSkillMd = (): string =>
|
||||
`---\nname: ${GUIDE_SKILL}\ndescription: jsc-anima 動漫助理的使用說明與技能列表\n---\n\n請讀取同目錄的 README.md 並依內容回答使用者。\n`
|
||||
|
||||
export const setReadmeVersion = (readme: string, version: string): string => {
|
||||
const line = `目前版本:${version}`
|
||||
return /^目前版本:.*$/m.test(readme)
|
||||
? readme.replace(/^目前版本:.*$/m, line)
|
||||
: readme.replace(/^(# jsc-anima\n)/m, `$1\n${line}\n`)
|
||||
}
|
||||
|
||||
export const setPluginVersion = (json: string, version: string): string =>
|
||||
json.replace(/("version":\s*")[^"]*(")/, `$1${version}$2`)
|
||||
|
||||
export const generatePrompt = (skill: string, description: string): string =>
|
||||
[
|
||||
`請撰寫一個 Claude 技能,技能名稱 ${SKILL_PREFIX}${skill},用途:${description}`,
|
||||
'只輸出 JSON(不要程式碼圍欄),格式:{"skill":"<SKILL.md 全文,含 frontmatter name 與 description,內文用繁體中文>","example":"<一筆範例輸入>"}',
|
||||
`frontmatter 的 name 必須是 ${SKILL_PREFIX}${skill}。`,
|
||||
].join('\n')
|
||||
|
||||
export const parseGenerated = (text: string): { skill: string; example: string } | undefined => {
|
||||
try {
|
||||
const j = JSON.parse(text.trim().replace(/^```(?:json)?\n?|\n?```$/g, ''))
|
||||
if (typeof j.skill === 'string' && typeof j.example === 'string') return { skill: j.skill, example: j.example }
|
||||
} catch {}
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { expect, mock, test } from 'claude-code/testing'
|
||||
|
||||
import { bumpVersion, parseEntries, renderGuide, upsertEntry, validateSkill } from './skillgen'
|
||||
|
||||
const SKILL = '---\nname: anima-greet\ndescription: 打招呼\n---\n\n用角色口吻向使用者打招呼,並簡短說明今天的待辦。\n'
|
||||
const GENERATED = JSON.stringify({ skill: SKILL, example: '早安' })
|
||||
|
||||
type Calls = { argv: string[] }[]
|
||||
|
||||
// 啟動角色並替身所有外部:模型、檔案系統、git/tea。
|
||||
async function setup($: any, on: any, opts: { reply?: (prompt: string) => string | undefined; existing?: boolean } = {}) {
|
||||
const writes: Record<string, string> = {}
|
||||
const procs: Calls = []
|
||||
mock.store(on)
|
||||
on('command.register', () => ({ value: { name: 'x' } }) as never)
|
||||
on('session.start', () => ({ cwd: '/repo' }) as never)
|
||||
on('session.cwd', () => ({ value: '/repo' }))
|
||||
on('command.run', () => ({ text: '' }))
|
||||
on('prompt.compose', () => ({ sections: [] }))
|
||||
on('model.complete', (_: any, e: any) => {
|
||||
const t = opts.reply ? opts.reply(String(e.prompt)) : String(e.prompt).startsWith('請撰寫') ? GENERATED : '嗨!'
|
||||
return { value: t === undefined ? { isAnswered: false, reason: 'empty-reply' } : { isAnswered: true, text: t, usage: {} } }
|
||||
})
|
||||
on('process.run', (_: any, e: any) => {
|
||||
procs.push({ argv: [...e.argv] })
|
||||
const s = e.argv.join(' ')
|
||||
if (s.startsWith('git rev-parse')) return { value: { exitCode: 0, stdout: '/repo\n', stderr: '' } }
|
||||
if (s.startsWith('git symbolic-ref')) return { value: { exitCode: 0, stdout: 'origin/main\n', stderr: '' } }
|
||||
if (s.startsWith('tea logins')) return { value: { exitCode: 0, stdout: 'admin\n', stderr: '' } }
|
||||
if (s.startsWith('tea pulls')) return { value: { exitCode: 0, stdout: 'https://g/pulls/1\n', stderr: '' } }
|
||||
return { value: { exitCode: 0, stdout: '', stderr: '' } }
|
||||
})
|
||||
on('fs.exists', (_: any, e: any) => ({ value: Boolean(opts.existing && String(e.path).endsWith('anima-greet/SKILL.md')) }))
|
||||
on('fs.read', (_: any, e: any) => ({
|
||||
value: String(e.path).endsWith('plugin.json') ? '{\n "version": "0.3.0"\n}' : String(e.path).endsWith('README.md') ? '# jsc-anima\n' : '',
|
||||
}))
|
||||
on('fs.write', (_: any, e: any) => {
|
||||
writes[e.path] = e.text
|
||||
return { value: undefined }
|
||||
})
|
||||
await $.session.start({ source: 'startup', cwd: '/repo' } as never)
|
||||
return { writes, procs }
|
||||
}
|
||||
|
||||
const run = ($: any, args: string) => $.command.run({ command: 'anima-skill', args } as never)
|
||||
|
||||
test('未啟動角色時工具二被拒絕並提示先啟動', async ($, on) => {
|
||||
const { procs } = await setup($, on)
|
||||
const r = await run($, 'greet 打招呼')
|
||||
expect(r.text.includes('請先啟動角色')).toBe(true)
|
||||
expect(procs.length).toBe(0)
|
||||
})
|
||||
|
||||
test('新增技能:寫檔、升 minor、更新 README、建立 PR', async ($, on) => {
|
||||
const { writes, procs } = await setup($, on)
|
||||
await $.command.run({ command: 'sao-asuna', args: '' } as never)
|
||||
const r = await run($, 'greet 打招呼')
|
||||
const key = (suffix: string) => Object.keys(writes).find(k => k.endsWith(suffix))!
|
||||
expect(writes[key('skills/anima-greet/SKILL.md')]!.includes('name: anima-greet')).toBe(true)
|
||||
expect(writes[key('.claude-plugin/plugin.json')]!.includes('"0.4.0"')).toBe(true)
|
||||
expect(writes[key('/README.md')]!.includes('目前版本:0.4.0') || Object.values(writes).some(v => v.includes('目前版本:0.4.0'))).toBe(true)
|
||||
expect(writes[key('skills/jsc-anima/README.md')]!.includes('/jsc-anima:anima-greet')).toBe(true)
|
||||
expect(procs.some(p => p.argv.join(' ').startsWith('git commit') && p.argv.join(' ').includes('feat: 新增技能'))).toBe(true)
|
||||
const pr = procs.find(p => p.argv[0] === 'tea' && p.argv[1] === 'pulls')!
|
||||
expect(pr.argv.includes('main')).toBe(true)
|
||||
expect(pr.argv.includes('feat/anima-greet')).toBe(true)
|
||||
expect(r.text.includes('https://g/pulls/1')).toBe(true)
|
||||
})
|
||||
|
||||
test('技能已存在則覆蓋並升 patch', async ($, on) => {
|
||||
const { writes } = await setup($, on, { existing: true })
|
||||
await $.command.run({ command: 'sao-asuna', args: '' } as never)
|
||||
await run($, 'greet 打招呼')
|
||||
expect(Object.entries(writes).some(([k, v]) => k.endsWith('plugin.json') && v.includes('"0.3.1"'))).toBe(true)
|
||||
})
|
||||
|
||||
test('驗證失敗或實跑失敗就不建立 PR', async ($, on) => {
|
||||
const bad = await setup($, on, { reply: () => JSON.stringify({ skill: 'no frontmatter', example: 'x' }) })
|
||||
await $.command.run({ command: 'sao-asuna', args: '' } as never)
|
||||
const r1 = await run($, 'greet 打招呼')
|
||||
expect(r1.text.includes('驗證失敗')).toBe(true)
|
||||
expect(bad.procs.some(p => p.argv[0] === 'tea')).toBe(false)
|
||||
expect(Object.keys(bad.writes).length).toBe(0)
|
||||
})
|
||||
|
||||
test('實跑無回應就不建立 PR', async ($, on) => {
|
||||
const s = await setup($, on, { reply: p => (p.startsWith('請撰寫') ? GENERATED : undefined) })
|
||||
await $.command.run({ command: 'sao-asuna', args: '' } as never)
|
||||
const r = await run($, 'greet 打招呼')
|
||||
expect(r.text.includes('實跑失敗')).toBe(true)
|
||||
expect(s.procs.some(p => p.argv[0] === 'tea')).toBe(false)
|
||||
})
|
||||
|
||||
test('技能驗證與版本規則', () => {
|
||||
expect(validateSkill(SKILL, 'greet').length).toBe(0)
|
||||
expect(validateSkill(SKILL, 'other').length).toBe(1)
|
||||
expect(bumpVersion('0.3.4', 'minor')).toBe('0.4.0')
|
||||
expect(bumpVersion('0.3.4', 'patch')).toBe('0.3.5')
|
||||
const list = upsertEntry(parseEntries(renderGuide([{ skill: 'a', description: 'A' }])), { skill: 'b', description: 'B' })
|
||||
expect(list.length).toBe(2)
|
||||
expect(renderGuide(list).includes('# /jsc-anima:jsc-anima')).toBe(true)
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# /jsc-anima:jsc-anima
|
||||
|
||||
動漫助理的使用說明。先用 `/jsc-anima:{作品代號}-{角色代號}` 啟動角色,再使用下列技能。
|
||||
|
||||
## 技能列表
|
||||
|
||||
<!-- skills:start -->
|
||||
<!-- skills:end -->
|
||||
|
||||
## 範例
|
||||
|
||||
```
|
||||
/jsc-anima:anima-skill <技能名稱> <描述>
|
||||
```
|
||||
|
||||
產生新技能:`/jsc-anima:anima-skill <技能名稱> <描述>`,通過驗證與實跑後自動對預設分支建立 PR。
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: jsc-anima
|
||||
description: jsc-anima 動漫助理的使用說明與技能列表
|
||||
---
|
||||
|
||||
請讀取同目錄的 README.md 並依內容回答使用者。
|
||||
Reference in New Issue
Block a user