141 lines
6.4 KiB
TypeScript
141 lines
6.4 KiB
TypeScript
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 時要保留,不隨技能列表被覆蓋。
|
||
const USAGE_SECTIONS = [
|
||
'## 角色與萃取',
|
||
'',
|
||
'- `/jsc-anima:anima-extract <章節檔案路徑>`:從輕小說章節萃取作品與角色,依序確認代號與變更摘要後,寫入 `characters/<作品代號>/` 並對預設分支建立 PR(不自動合併)。',
|
||
'- `/jsc-anima:anima-extract paste <章節名稱>`(下一行起貼上章節內容):不想存檔時直接貼上,之後流程相同。',
|
||
'',
|
||
'## 待辦事項',
|
||
'',
|
||
'啟動角色後可用 `/jsc-anima:anima-todo`(全域一份,記錄類型、時間、任務與所屬專案):',
|
||
'',
|
||
'- `add <類型> <YYYY-MM-DD> <HH:MM> <任務>`、`update <編號> …`、`list`、`done <編號>`',
|
||
'- 類型預設:提醒、開發、研究、維護;`type <名稱>` 新增自訂類型。',
|
||
'- 範例:`/jsc-anima:anima-todo add 提醒 2026-10-16 09:00 開會`',
|
||
'- session 開著時,到點由角色提醒(可在 /config 關閉);輸入 `run <編號>` 確認後才執行,結果由角色告知。',
|
||
'',
|
||
'## 知識庫(wiki)',
|
||
'',
|
||
'啟動角色後可用:',
|
||
'',
|
||
'- `/jsc-anima:anima-wiki <領域> <主題> [出處]`(下一行起為內容):寫入 Gitea wiki,每次一個中文 commit,並更新 `Home` 目錄與 `_Sidebar`。領域與主題只能用英數與底線。',
|
||
'- `/jsc-anima:<領域>-<主題>`:讀取知識,指令在啟動時依 wiki 目錄註冊。',
|
||
'',
|
||
'讀寫都在 wiki,本機只做 10 分鐘快取,離線時沿用快取。認證優先讀 tea 登入,其次 `GITEA_TOKEN`,token 不寫入任何檔案。',
|
||
'',
|
||
]
|
||
|
||
// 技能 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。',
|
||
'',
|
||
...USAGE_SECTIONS,
|
||
].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
|
||
}
|