返回 oh-my-ppt
initializer.ts
根目录 / src / main / product-skills / initializer.ts
1 // Built-in product skill installation belongs to the product domain, not Agent Runtime.
2 import { cp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
3 import path from 'node:path'
4
5 export interface SkillInitializerLogger {
6 info?: (message: string, meta?: Record<string, unknown>) => void
7 warn?: (message: string, meta?: Record<string, unknown>) => void
8 error?: (message: string, meta?: Record<string, unknown>) => void
9 }
10
11 interface BuiltinSkillJson {
12 name: string
13 version: string
14 source: 'builtin'
15 }
16
17 interface SystemSkillManifestEntry {
18 version: string
19 source: 'builtin'
20 installedAt: string
21 updatedAt: string
22 missingFromBundle?: boolean
23 }
24
25 export interface SystemSkillsManifest {
26 schemaVersion: 1
27 updatedAt: string
28 skills: Record<string, SystemSkillManifestEntry>
29 }
30
31 export interface InitializeSkillsResult {
32 builtinCount: number
33 copiedCount: number
34 skippedCount: number
35 failedCount: number
36 manifest: SystemSkillsManifest
37 }
38
39 export function compareVersion(a: string, b: string): number {
40 const aa = a.split('.').map((part) => Number(part) || 0)
41 const bb = b.split('.').map((part) => Number(part) || 0)
42 const len = Math.max(aa.length, bb.length)
43 for (let i = 0; i < len; i += 1) {
44 const diff = (aa[i] || 0) - (bb[i] || 0)
45 if (diff !== 0) return diff
46 }
47 return 0
48 }
49
50 export async function initializeSkills(options: {
51 builtinSourcePath: string
52 installedRootPath: string
53 logger?: SkillInitializerLogger
54 }): Promise<InitializeSkillsResult> {
55 const logger = options.logger
56 const systemPath = path.join(options.installedRootPath, 'system')
57 await mkdir(systemPath, { recursive: true })
58
59 const manifest = await readSystemManifest(systemPath, logger)
60 const nextManifest: SystemSkillsManifest = {
61 schemaVersion: 1,
62 updatedAt: new Date().toISOString(),
63 skills: { ...manifest.skills },
64 }
65
66 const bundledSkills = await readBundledSkills(options.builtinSourcePath, logger)
67 const bundledNames = new Set(bundledSkills.map((skill) => skill.json.name))
68 let copiedCount = 0
69 let skippedCount = 0
70 let failedCount = 0
71
72 for (const skill of bundledSkills) {
73 try {
74 const destinationPath = path.join(systemPath, skill.json.name)
75 const existing = nextManifest.skills[skill.json.name]
76 const installedJson = await readSkillJson(destinationPath).catch(() => null)
77 const shouldCopy =
78 !existing ||
79 existing.missingFromBundle ||
80 compareVersion(skill.json.version, existing.version) > 0 ||
81 !installedJson ||
82 installedJson.name !== skill.json.name ||
83 compareVersion(skill.json.version, installedJson.version) > 0
84
85 if (shouldCopy) {
86 await rm(destinationPath, { recursive: true, force: true })
87 await cp(skill.path, destinationPath, { recursive: true })
88 copiedCount += 1
89 const now = new Date().toISOString()
90 nextManifest.skills[skill.json.name] = {
91 version: skill.json.version,
92 source: 'builtin',
93 installedAt: existing?.installedAt || now,
94 updatedAt: now,
95 }
96 logger?.info?.('[skills] installed builtin skill', {
97 name: skill.json.name,
98 version: skill.json.version,
99 })
100 } else {
101 skippedCount += 1
102 nextManifest.skills[skill.json.name] = {
103 version: skill.json.version,
104 source: 'builtin',
105 installedAt: existing.installedAt,
106 updatedAt: existing.updatedAt,
107 }
108 }
109 } catch (error) {
110 failedCount += 1
111 logger?.error?.('[skills] failed to sync builtin skill', {
112 name: skill.json.name,
113 message: error instanceof Error ? error.message : String(error),
114 })
115 }
116 }
117
118 for (const [name, entry] of Object.entries(nextManifest.skills)) {
119 if (bundledNames.has(name)) continue
120 nextManifest.skills[name] = {
121 ...entry,
122 missingFromBundle: true,
123 updatedAt: new Date().toISOString(),
124 }
125 }
126
127 nextManifest.updatedAt = new Date().toISOString()
128 await writeSystemManifest(systemPath, nextManifest)
129
130 return {
131 builtinCount: bundledSkills.length,
132 copiedCount,
133 skippedCount,
134 failedCount,
135 manifest: nextManifest,
136 }
137 }
138
139 async function readBundledSkills(
140 builtinSourcePath: string,
141 logger?: SkillInitializerLogger
142 ): Promise<Array<{ path: string; json: BuiltinSkillJson }>> {
143 let entries: Array<{ name: string; isDirectory: () => boolean }>
144 try {
145 entries = await readdir(builtinSourcePath, { withFileTypes: true })
146 } catch (error) {
147 logger?.warn?.('[skills] bundled skills source missing or unreadable', {
148 path: builtinSourcePath,
149 message: error instanceof Error ? error.message : String(error),
150 })
151 return []
152 }
153
154 const skills: Array<{ path: string; json: BuiltinSkillJson }> = []
155 for (const entry of entries) {
156 if (!entry.isDirectory()) continue
157 const skillPath = path.join(builtinSourcePath, entry.name)
158 try {
159 const json = await readSkillJson(skillPath)
160 if (json.name !== entry.name) {
161 logger?.warn?.('[skills] skill name does not match directory', {
162 directory: entry.name,
163 name: json.name,
164 })
165 continue
166 }
167 skills.push({ path: skillPath, json })
168 } catch (error) {
169 logger?.warn?.('[skills] invalid bundled skill metadata', {
170 path: skillPath,
171 message: error instanceof Error ? error.message : String(error),
172 })
173 }
174 }
175 return skills
176 }
177
178 async function readSkillJson(skillPath: string): Promise<BuiltinSkillJson> {
179 const filePath = path.join(skillPath, 'skill.json')
180 const raw = await readFile(filePath, 'utf8')
181 const parsed = JSON.parse(raw) as Partial<BuiltinSkillJson>
182 if (
183 typeof parsed.name !== 'string' ||
184 typeof parsed.version !== 'string' ||
185 parsed.source !== 'builtin'
186 ) {
187 throw new Error(`Invalid skill.json at ${filePath}`)
188 }
189 return {
190 name: parsed.name,
191 version: parsed.version,
192 source: parsed.source,
193 }
194 }
195
196 async function readSystemManifest(
197 systemPath: string,
198 logger?: SkillInitializerLogger
199 ): Promise<SystemSkillsManifest> {
200 const filePath = path.join(systemPath, '.manifest.json')
201 try {
202 const raw = await readFile(filePath, 'utf8')
203 const parsed = JSON.parse(raw) as Partial<SystemSkillsManifest>
204 if (parsed.schemaVersion !== 1 || !parsed.skills || typeof parsed.skills !== 'object') {
205 throw new Error('Invalid manifest shape')
206 }
207 return {
208 schemaVersion: 1,
209 updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date().toISOString(),
210 skills: parsed.skills as Record<string, SystemSkillManifestEntry>,
211 }
212 } catch (error) {
213 const code = (error as NodeJS.ErrnoException).code
214 if (code !== 'ENOENT') {
215 logger?.warn?.('[skills] system manifest unreadable; recreating', {
216 path: filePath,
217 message: error instanceof Error ? error.message : String(error),
218 })
219 }
220 return {
221 schemaVersion: 1,
222 updatedAt: new Date().toISOString(),
223 skills: {},
224 }
225 }
226 }
227
228 async function writeSystemManifest(
229 systemPath: string,
230 manifest: SystemSkillsManifest
231 ): Promise<void> {
232 await writeFile(
233 path.join(systemPath, '.manifest.json'),
234 `${JSON.stringify(manifest, null, 2)}\n`,
235 'utf8'
236 )
237 }
238
238 lines TYPESCRIPT