| 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 |