@uzooai/anime-ai-cli 0.0.0-stage → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/.agents/skills/uzoo-anime-cli/SKILL.md +89 -0
  2. package/.agents/skills/uzoo-anime-cli/references/agreement-workflow.md +41 -0
  3. package/.agents/skills/uzoo-anime-cli/references/canvas-workflow.md +53 -0
  4. package/.agents/skills/uzoo-anime-cli/references/commands.md +242 -0
  5. package/.agents/skills/uzoo-anime-cli/references/generation-workflow.md +142 -0
  6. package/.agents/skills/uzoo-anime-cli/references/image-tools-workflow.md +153 -0
  7. package/.agents/skills/uzoo-anime-cli/references/interaction-workflow.md +67 -0
  8. package/.agents/skills/uzoo-anime-cli/references/material-graph-workflow.md +113 -0
  9. package/.agents/skills/uzoo-anime-cli/references/node-capabilities.md +64 -0
  10. package/.agents/skills/uzoo-anime-cli/references/node-workflow.md +90 -0
  11. package/.agents/skills/uzoo-anime-cli/references/remake-workflow.md +54 -0
  12. package/.agents/skills/uzoo-anime-cli/references/repaint-workflow.md +13 -0
  13. package/.agents/skills/uzoo-anime-cli/references/seedance-review-workflow.md +37 -0
  14. package/.agents/skills/uzoo-anime-cli/references/video-tools-workflow.md +67 -0
  15. package/CHANGELOG.md +13 -0
  16. package/LICENSE +21 -0
  17. package/README.md +46 -3
  18. package/assets/favicon-uzoo.png +0 -0
  19. package/assets/logo-uzoo.svg +7 -0
  20. package/docs/Agent/346/216/245/345/205/245/344/270/216/347/211/210/346/234/254/346/233/264/346/226/260.md +43 -0
  21. package/examples/matting.json +5 -0
  22. package/extensions/uzoo-cli-login/manifest.json +9 -0
  23. package/extensions/uzoo-cli-login/popup.css +1 -0
  24. package/extensions/uzoo-cli-login/popup.html +14 -0
  25. package/extensions/uzoo-cli-login/popup.js +65 -0
  26. package/package.json +53 -6
  27. package/src/agreement-page.js +62 -0
  28. package/src/agreement.js +419 -0
  29. package/src/annotation-media.js +386 -0
  30. package/src/api.js +168 -0
  31. package/src/browser-login.js +13 -0
  32. package/src/callback-page.js +44 -0
  33. package/src/canvas.js +105 -0
  34. package/src/cli-authorization.js +122 -0
  35. package/src/cli.js +464 -0
  36. package/src/download-name.js +17 -0
  37. package/src/draft-finalize.js +229 -0
  38. package/src/generation-graph.js +124 -0
  39. package/src/generation-input.js +472 -0
  40. package/src/graph-disconnect.js +89 -0
  41. package/src/graph.js +431 -0
  42. package/src/group-edit.js +53 -0
  43. package/src/group-layout.js +113 -0
  44. package/src/group-membership.js +125 -0
  45. package/src/history-add.js +119 -0
  46. package/src/image-annotation.js +246 -0
  47. package/src/image-crop.js +283 -0
  48. package/src/image-matting.js +82 -0
  49. package/src/image-media.js +89 -0
  50. package/src/image-output.js +385 -0
  51. package/src/install.js +237 -0
  52. package/src/local-output.js +23 -0
  53. package/src/material-download.js +55 -0
  54. package/src/material-save.js +254 -0
  55. package/src/materials.js +147 -0
  56. package/src/matting-background.js +73 -0
  57. package/src/matting-jobs.js +63 -0
  58. package/src/matting-worker.js +49 -0
  59. package/src/matting.js +170 -0
  60. package/src/media-copy.js +53 -0
  61. package/src/media.js +343 -0
  62. package/src/model-options.js +76 -0
  63. package/src/multipart.js +28 -0
  64. package/src/node-copy.js +95 -0
  65. package/src/node-delete.js +49 -0
  66. package/src/node-history.js +231 -0
  67. package/src/normal-login.js +118 -0
  68. package/src/package-info.js +8 -0
  69. package/src/png-alpha.js +60 -0
  70. package/src/protocol.js +83 -0
  71. package/src/remake.js +738 -0
  72. package/src/repaint-archive.js +53 -0
  73. package/src/repaint-delivery.js +783 -0
  74. package/src/repaint-export.js +63 -0
  75. package/src/repaint-graph.js +214 -0
  76. package/src/repaint-history.js +267 -0
  77. package/src/repaint-materials.js +601 -0
  78. package/src/repaint-reverse.js +614 -0
  79. package/src/repaint-sequence.js +132 -0
  80. package/src/repaint-settings.js +576 -0
  81. package/src/repaint-video.js +798 -0
  82. package/src/repaint.js +326 -0
  83. package/src/resource.js +110 -0
  84. package/src/review.js +435 -0
  85. package/src/service.js +85 -0
  86. package/src/store.js +100 -0
  87. package/src/video-clip.js +381 -0
  88. package/src/video-frame.js +296 -0
  89. package/src/workflows.js +214 -0
@@ -0,0 +1,385 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+ import { atomic } from './store.js'
5
+ import { scopedCanvas } from './materials.js'
6
+ import { segment, uploadParts } from './workflows.js'
7
+ import { bindUpload } from './resource.js'
8
+ import { hashFile } from './media.js'
9
+ const text = (value) => String(value ?? '').trim()
10
+ const digest = (value) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex')
11
+ const fail = (code, message, error) =>
12
+ Object.assign(new Error(message), {
13
+ code,
14
+ ...(error
15
+ ? {
16
+ cause: error,
17
+ diagnostic: {
18
+ ...(error.status ? { http_status: error.status } : {}),
19
+ ...(error.apiCode ? { api_code: error.apiCode } : {}),
20
+ ...(error.requestId ? { request_id: error.requestId } : {})
21
+ }
22
+ }
23
+ : {})
24
+ })
25
+
26
+ // Shared persistence/upload contract for locally processed image outputs.
27
+ // Callers own source validation, geometry, decoding and operation journals.
28
+ export function imageOutputWorkflow({
29
+ recordPath,
30
+ readContext,
31
+ nodeTitle,
32
+ nodePosition,
33
+ edgeRole,
34
+ edgePrefix,
35
+ codePrefix,
36
+ mime,
37
+ summarize,
38
+ nodeType = () => 'image_upload'
39
+ }) {
40
+ async function persistNodes(client, profile, record, current) {
41
+ const doc = current.doc
42
+ const existing = record.items.map((item) => doc.nodes.find((node) => node.id === item.nodeId))
43
+ if (existing.every(Boolean)) {
44
+ for (let i = 0; i < existing.length; i++) {
45
+ const node = existing[i],
46
+ item = record.items[i]
47
+ if (
48
+ node.type !== nodeType(record, item) ||
49
+ (node.data?.extra?.node_kind &&
50
+ node.data.extra.node_kind !==
51
+ (nodeType(record, item) === 'image' ? 'generation' : 'asset')) ||
52
+ (node.data?.file_name && node.data.file_name !== item.fileName) ||
53
+ !doc.edges.some(
54
+ (e) =>
55
+ e.source === record.input.node_id && e.target === node.id && e.data?.role === edgeRole
56
+ )
57
+ )
58
+ throw new Error('图片处理结果节点或来源连线已变化,停止保存')
59
+ }
60
+ record.nodesSaved = true
61
+ await atomic(recordPath(profile, record.operation), record)
62
+ return
63
+ }
64
+ if (record.nodesSaved || record.saveAttempted || existing.some(Boolean))
65
+ throw fail(
66
+ codePrefix + '_SYNC_PENDING',
67
+ '图片处理节点/连线缺失或保存结果不明;先核对用户删除/画布内容,不自动重建'
68
+ )
69
+ if (
70
+ !Number.isSafeInteger(doc.version) ||
71
+ !doc.viewport ||
72
+ !doc.meta ||
73
+ !Array.isArray(doc.edges)
74
+ )
75
+ throw new Error('图片处理画布快照不完整')
76
+ const nodes = structuredClone(doc.nodes),
77
+ edges = structuredClone(doc.edges)
78
+ for (const item of record.items) {
79
+ const node = {
80
+ id: item.nodeId,
81
+ type: nodeType(record, item),
82
+ position: nodePosition(nodes, current.node),
83
+ data: {
84
+ title: nodeTitle(item),
85
+ mediaType: 'image',
86
+ url: '',
87
+ file_name: item.fileName,
88
+ file_size: item.local.bytes,
89
+ width: item.local.width,
90
+ height: item.local.height,
91
+ uploading: true,
92
+ ...(nodeType(record, item) === 'image'
93
+ ? {
94
+ status: 'processing',
95
+ runtime: { status: 'processing', operation: 'upload', progress: 0, error: '' }
96
+ }
97
+ : {}),
98
+ extra: {
99
+ node_kind: nodeType(record, item) === 'image_upload' ? 'asset' : 'generation',
100
+ aspect_ratio: item.local.width + ':' + item.local.height
101
+ }
102
+ }
103
+ }
104
+ nodes.push(node)
105
+ edges.push({
106
+ id: edgePrefix + item.nodeId,
107
+ source: record.input.node_id,
108
+ target: item.nodeId,
109
+ sourceHandle: '',
110
+ targetHandle: '',
111
+ type: 'default',
112
+ data: { role: edgeRole }
113
+ })
114
+ }
115
+ record.saveAttempted = true
116
+ await atomic(recordPath(profile, record.operation), record)
117
+ try {
118
+ const saved = await client.raw(
119
+ '/canvas/docs/' + segment(doc.id),
120
+ {
121
+ method: 'PUT',
122
+ body: {
123
+ version: doc.version,
124
+ name: doc.name,
125
+ viewport: doc.viewport,
126
+ nodes,
127
+ edges,
128
+ meta: doc.meta
129
+ }
130
+ },
131
+ { retry: false }
132
+ )
133
+ if (
134
+ saved.id !== doc.id ||
135
+ saved.version <= doc.version ||
136
+ record.items.some(
137
+ (item) =>
138
+ !saved.nodes?.some((n) => n.id === item.nodeId) ||
139
+ !saved.edges?.some((e) => e.id === edgePrefix + item.nodeId)
140
+ )
141
+ )
142
+ throw new Error('节点保存响应不完整')
143
+ record.nodesSaved = true
144
+ await atomic(recordPath(profile, record.operation), record)
145
+ } catch (error) {
146
+ record.state = 'sync_pending'
147
+ if (error.status === 409 || error.apiCode === 3002) {
148
+ record.saveAttempted = false
149
+ }
150
+ await atomic(recordPath(profile, record.operation), record)
151
+ throw fail(
152
+ codePrefix + '_SYNC_PENDING',
153
+ '图片处理已在本地导出,节点保存未确认;查询原画布后恢复,不重复处理',
154
+ error
155
+ )
156
+ }
157
+ }
158
+ function validateUpload(response, record, item) {
159
+ if (
160
+ response?.source_doc_id !== record.input.doc_id ||
161
+ response.source_node_id !== item.nodeId ||
162
+ response.segment_type !== 'image' ||
163
+ !response.result?.file_ref ||
164
+ !response.result.url ||
165
+ !/^image\//.test(response.result.mime_type || '') ||
166
+ response.result.width !== item.local.width ||
167
+ response.result.height !== item.local.height ||
168
+ !/^[1-9]\d*$/.test(text(response.task_id))
169
+ )
170
+ throw fail(
171
+ codePrefix + '_UPLOAD_UNKNOWN',
172
+ '上传响应缺少真实图片/任务关联;查询原节点和任务,不重复上传'
173
+ )
174
+ return response
175
+ }
176
+ async function reconcile(client, record, item) {
177
+ const list = await client.raw(
178
+ '/canvas/generations?' +
179
+ new URLSearchParams({
180
+ doc_id: record.input.doc_id,
181
+ node_id: item.nodeId,
182
+ status: 'completed',
183
+ limit: '100'
184
+ })
185
+ )
186
+ const current = await readContext(client, record.input)
187
+ if (digest(current.binding) !== digest(record.binding))
188
+ throw new Error('恢复上传前源素材已变化,停止')
189
+ const node = current.doc.nodes.find((n) => n.id === item.nodeId)
190
+ const uploadTask = String(node?.data?.task_id || '')
191
+ const uploadRef = node?.data?.file_ref
192
+ const candidates = list.items?.filter(
193
+ (task) =>
194
+ task.doc_id === record.input.doc_id &&
195
+ task.node_id === item.nodeId &&
196
+ task.segment_type === 'image' &&
197
+ (task.model_name === 'local-upload' ||
198
+ (uploadTask &&
199
+ String(task.task_id ?? task.id) === uploadTask &&
200
+ uploadRef &&
201
+ task.result?.file_ref === uploadRef &&
202
+ task.credit_cost === 0 &&
203
+ task.billing_status === 'charged')) &&
204
+ task.status === 'completed' &&
205
+ task.result?.filename === item.fileName &&
206
+ task.result?.file_ref &&
207
+ task.result?.url
208
+ )
209
+ if (candidates?.length !== 1)
210
+ throw fail(
211
+ codePrefix + '_UPLOAD_UNKNOWN',
212
+ '原上传 complete 结果未知;原节点未查到唯一真实任务,不重新 complete 或重传'
213
+ )
214
+ const task = candidates[0]
215
+ item.response = validateUpload(
216
+ {
217
+ source_doc_id: task.doc_id,
218
+ source_node_id: task.node_id,
219
+ segment_type: 'image',
220
+ task_id: task.task_id ?? task.id,
221
+ result: task.result
222
+ },
223
+ record,
224
+ item
225
+ )
226
+ item.state = 'uploaded'
227
+ }
228
+ async function runUploads(client, profile, record) {
229
+ record.state = 'uploading'
230
+ await atomic(recordPath(profile, record.operation), record)
231
+ for (const item of record.items) {
232
+ const current = await readContext(client, record.input)
233
+ if (digest(record.binding) !== digest(current.binding))
234
+ throw new Error('上传前源素材已变化,停止')
235
+ const node = current.doc.nodes.find((node) => node.id === item.nodeId)
236
+ if (
237
+ !node ||
238
+ node.type !== nodeType(record, item) ||
239
+ !current.doc.edges.some(
240
+ (e) =>
241
+ e.source === record.input.node_id &&
242
+ e.target === item.nodeId &&
243
+ e.data?.role === edgeRole
244
+ )
245
+ )
246
+ throw new Error('图片处理节点或连线已被删除,不自动重建')
247
+ const currentRef =
248
+ node.data?.file_ref || node.data?.extra?.file_ref || node.data?.result?.file_ref
249
+ if (
250
+ currentRef &&
251
+ currentRef !== item.response?.result?.file_ref &&
252
+ !['completing', 'unknown'].includes(item.state)
253
+ )
254
+ throw new Error('图片处理节点已换成其他素材;保留用户编辑')
255
+ if (['completing', 'unknown'].includes(item.state)) {
256
+ record.state = 'upload_unknown'
257
+ await atomic(recordPath(profile, record.operation), record)
258
+ await reconcile(client, record, item)
259
+ record.state = 'uploading'
260
+ await atomic(recordPath(profile, record.operation), record)
261
+ }
262
+ if (item.state === 'session_unknown') {
263
+ record.state = 'upload_unknown'
264
+ await atomic(recordPath(profile, record.operation), record)
265
+ throw fail(
266
+ codePrefix + '_UPLOAD_UNKNOWN',
267
+ '上传会话创建结果未知,停止;不自动创建第二个会话'
268
+ )
269
+ }
270
+ if (!item.response) {
271
+ const image = path.join(record.directory, item.fileName)
272
+ const local = await hashFile(image)
273
+ if (local.sha256 !== item.local.sha256 || local.bytes !== item.local.bytes)
274
+ throw new Error('本地图片处理产物发生变化,不上传')
275
+ const endpoint =
276
+ '/canvas/docs/' + segment(record.input.doc_id) + '/nodes/' + segment(item.nodeId)
277
+ if (!item.session) {
278
+ item.state = 'session_unknown'
279
+ record.state = 'upload_unknown'
280
+ await atomic(recordPath(profile, record.operation), record)
281
+ try {
282
+ item.session = await client.raw(
283
+ endpoint + '/upload-session',
284
+ {
285
+ method: 'POST',
286
+ body: {
287
+ filename: item.fileName,
288
+ file_size: local.bytes,
289
+ expected_type: 'image',
290
+ mime_type: mime
291
+ }
292
+ },
293
+ { retry: false }
294
+ )
295
+ } catch (error) {
296
+ throw fail(
297
+ codePrefix + '_UPLOAD_UNKNOWN',
298
+ '创建图片处理上传会话未确认;停止并保留原操作',
299
+ error
300
+ )
301
+ }
302
+ item.state = 'uploading'
303
+ record.state = 'uploading'
304
+ await atomic(recordPath(profile, record.operation), record)
305
+ }
306
+ if (!item.parts) {
307
+ const handle = await fs.open(image, 'r')
308
+ try {
309
+ item.parts = await uploadParts(client, handle, await handle.stat(), item.session, mime)
310
+ } finally {
311
+ await handle.close()
312
+ }
313
+ await atomic(recordPath(profile, record.operation), record)
314
+ }
315
+ item.state = 'completing'
316
+ await atomic(recordPath(profile, record.operation), record)
317
+ try {
318
+ item.response = validateUpload(
319
+ await client.raw(
320
+ endpoint + '/upload-complete',
321
+ {
322
+ method: 'POST',
323
+ body: {
324
+ upload_id: item.session.upload_id,
325
+ object_key: item.session.object_key,
326
+ expected_parts: item.parts.length,
327
+ parts: item.parts
328
+ }
329
+ },
330
+ { retry: false }
331
+ ),
332
+ record,
333
+ item
334
+ )
335
+ } catch (error) {
336
+ item.state = 'unknown'
337
+ record.state = 'upload_unknown'
338
+ await atomic(recordPath(profile, record.operation), record)
339
+ throw fail(
340
+ codePrefix + '_UPLOAD_UNKNOWN',
341
+ '图片处理上传完成结果未确认;只查询原节点/任务恢复,不再次 complete',
342
+ error
343
+ )
344
+ }
345
+ item.state = 'uploaded'
346
+ await atomic(recordPath(profile, record.operation), record)
347
+ }
348
+ if (!item.bound) {
349
+ try {
350
+ await bindUpload(client, {
351
+ docId: record.input.doc_id,
352
+ nodeId: item.nodeId,
353
+ result: item.response,
354
+ imageNode: nodeType(record, item) === 'image'
355
+ })
356
+ // Clear the preparation flag only after real upload binding succeeds.
357
+ const doc = await scopedCanvas(
358
+ client,
359
+ record.input.doc_id,
360
+ record.input.space,
361
+ record.input.team_uid
362
+ )
363
+ const node = doc.nodes.find((n) => n.id === item.nodeId)
364
+ if (node?.data?.extra?.file_ref !== item.response.result.file_ref)
365
+ throw new Error('图片处理引用读回不一致')
366
+ item.bound = true
367
+ item.state = 'completed'
368
+ await atomic(recordPath(profile, record.operation), record)
369
+ } catch (error) {
370
+ record.state = 'sync_pending'
371
+ await atomic(recordPath(profile, record.operation), record)
372
+ throw fail(
373
+ codePrefix + '_SYNC_PENDING',
374
+ '图片已上传,画布绑定未确认;恢复原响应,不重传图片',
375
+ error
376
+ )
377
+ }
378
+ }
379
+ }
380
+ record.state = 'completed'
381
+ await atomic(recordPath(profile, record.operation), record)
382
+ return summarize(record)
383
+ }
384
+ return { persistNodes, runUploads }
385
+ }
package/src/install.js ADDED
@@ -0,0 +1,237 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { homedir } from 'node:os'
4
+ import { createHash } from 'node:crypto'
5
+ import { execFile } from 'node:child_process'
6
+ import { promisify } from 'node:util'
7
+ import { createInterface } from 'node:readline/promises'
8
+ import { setTimeout as delay } from 'node:timers/promises'
9
+ import { packageInfo } from './package-info.js'
10
+
11
+ const exec = promisify(execFile)
12
+ const skillName = 'uzoo-anime-cli'
13
+ const receipt = '.uzoo-install.json'
14
+ const bundle = path.join('.agents', 'skills', skillName)
15
+ const registry = 'https://registry.npmjs.org/'
16
+ const agents = {
17
+ codex: { label: 'Codex', config: '.agents', detect: '.codex' },
18
+ workbuddy: { label: 'WorkBuddy', config: '.workbuddy' },
19
+ 'claude-code': { label: 'Claude Code', config: '.claude' },
20
+ }
21
+ const help = `安装或更新同版本的 CLI 和 Skill(用户级)
22
+ install 检测并选择一个或多个 Agent
23
+ install --agent codex --agent workbuddy
24
+ install --agent codex --yes 非交互,须明确指定 Agent
25
+ install --agent workbuddy --dry-run 仅预览,不安装
26
+ Agent:codex、workbuddy、claude-code
27
+ 原 npm install -g 仍只安装 CLI 和随包文件,不写 Agent 技能目录。`
28
+
29
+ export function parseInstall(args) {
30
+ const selected = [], flags = new Set()
31
+ for (let i = 0; i < args.length; i++) {
32
+ const arg = args[i]
33
+ if (arg === '--agent') {
34
+ const id = args[++i]?.toLowerCase()
35
+ if (!Object.hasOwn(agents, id)) throw new Error('未知或缺少 --agent;可选 codex、workbuddy、claude-code')
36
+ if (!selected.includes(id)) selected.push(id)
37
+ } else if (['--yes', '--dry-run', '--help', '-h'].includes(arg) && !flags.has(arg)) {
38
+ flags.add(arg)
39
+ } else throw new Error(`未知或重复的安装参数:${arg}`)
40
+ }
41
+ if (flags.has('--yes') && !selected.length) throw new Error('--yes 需要明确指定至少一个 --agent')
42
+ return { selected, yes: flags.has('--yes'), dryRun: flags.has('--dry-run'), help: flags.has('--help') || flags.has('-h') }
43
+ }
44
+
45
+ async function stat(file) {
46
+ try { return await fs.lstat(file) } catch (error) { if (error.code !== 'ENOENT') throw error }
47
+ }
48
+
49
+ async function safeDirectories(...directories) {
50
+ for (const dir of directories) {
51
+ const s = await stat(dir)
52
+ if (s && (!s.isDirectory() || s.isSymbolicLink())) throw new Error(`不是普通目录,未修改:${dir}`)
53
+ }
54
+ }
55
+
56
+ export async function agentTargets(home) {
57
+ return Promise.all(Object.entries(agents).map(async ([id, agent]) => {
58
+ const config = path.join(home, agent.config)
59
+ const root = path.join(config, 'skills')
60
+ const detected = Boolean((await stat(path.join(home, agent.detect || agent.config)))?.isDirectory())
61
+ return { id, label: agent.label, config, root, target: path.join(root, skillName), detected }
62
+ }))
63
+ }
64
+
65
+ async function fingerprint(directory) {
66
+ const hash = createHash('sha256')
67
+ async function walk(dir, prefix = '') {
68
+ for (const entry of (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) => a.name < b.name ? -1 : a.name === b.name ? 0 : 1)) {
69
+ if (!prefix && entry.name === receipt) continue
70
+ const file = path.join(dir, entry.name), name = prefix + entry.name
71
+ if (entry.isDirectory() && !entry.isSymbolicLink()) {
72
+ hash.update(JSON.stringify(['directory', name]))
73
+ await walk(file, name + '/')
74
+ } else if (entry.isFile() && !entry.isSymbolicLink()) {
75
+ hash.update(JSON.stringify(['file', name, createHash('sha256').update(await fs.readFile(file)).digest('hex')]))
76
+ } else throw new Error(`技能中存在链接或特殊文件:${file}`)
77
+ }
78
+ }
79
+ await safeDirectories(directory)
80
+ await walk(directory)
81
+ return hash.digest('hex')
82
+ }
83
+
84
+ async function inspect(target) {
85
+ await safeDirectories(target.config, target.root, target.target)
86
+ if (!await stat(target.target)) return { kind: 'new' }
87
+ const marker = path.join(target.target, receipt)
88
+ if (!(await stat(marker))?.isFile()) return { kind: 'protected' }
89
+ let meta
90
+ try { meta = JSON.parse(await fs.readFile(marker, 'utf8')) } catch (error) {
91
+ if (!(error instanceof SyntaxError)) throw error
92
+ return { kind: 'protected' }
93
+ }
94
+ if (meta?.package !== packageInfo.name || meta?.schema !== 1) return { kind: 'protected' }
95
+ const digest = await fingerprint(target.target)
96
+ if (meta.digest !== digest) return { kind: 'protected' }
97
+ return { kind: 'managed', digest, version: meta.version }
98
+ }
99
+
100
+ async function verifyBundle(directory) {
101
+ if (!(await stat(path.join(directory, 'SKILL.md')))?.isFile() ||
102
+ !(await stat(path.join(directory, 'references')))?.isDirectory() ||
103
+ await stat(path.join(directory, receipt))) throw new Error('npm 包内 Skill 不完整或含安装记录')
104
+ return fingerprint(directory)
105
+ }
106
+
107
+ async function renameSkill(from, to) {
108
+ // Windows indexers can briefly hold a handle to freshly copied files.
109
+ for (const wait of [25, 50, 100, 200, 400, 0]) {
110
+ if (await stat(to)) throw new Error(`目标目录已存在,未覆盖:${to}`)
111
+ try { await fs.rename(from, to); return } catch (error) {
112
+ if (process.platform !== 'win32' || !wait || !['EPERM', 'EACCES', 'EBUSY'].includes(error.code)) throw error
113
+ await delay(wait)
114
+ }
115
+ }
116
+ }
117
+
118
+ async function installSkill(target, directory, digest, version, expected) {
119
+ const current = await inspect(target)
120
+ if (JSON.stringify(current) !== JSON.stringify(expected)) throw new Error(`${target.label} 的 Skill 在确认后有变化,请重新预览`)
121
+ if (current.digest === digest && current.version === version) return 'unchanged'
122
+ await fs.mkdir(target.root, { recursive: true })
123
+ const stage = await fs.mkdtemp(path.join(target.config, '.uzoo-install-'))
124
+ const next = path.join(stage, 'next'), previous = path.join(stage, 'previous')
125
+ let moved = false
126
+ try {
127
+ await fs.cp(directory, next, { recursive: true, errorOnExist: true, force: false })
128
+ if (await fingerprint(next) !== digest) throw new Error('Skill 暂存校验失败')
129
+ await fs.writeFile(path.join(next, receipt), JSON.stringify({ schema: 1, package: packageInfo.name, version, digest }) + '\n', { flag: 'wx' })
130
+ if (JSON.stringify(await inspect(target)) !== JSON.stringify(current)) throw new Error(`${target.label} 的 Skill 已变化,请重新预览`)
131
+ if (current.kind === 'managed') {
132
+ await renameSkill(target.target, previous); moved = true
133
+ // Preserve an edit made between the final check and the directory move.
134
+ if (await fingerprint(previous) !== current.digest) throw new Error(`${target.label} 的 Skill 在替换前有变化`)
135
+ }
136
+ await renameSkill(next, target.target)
137
+ moved = false
138
+ return current.kind === 'new' ? 'installed' : 'updated'
139
+ } catch (error) {
140
+ if (moved) {
141
+ try { await renameSkill(previous, target.target); moved = false } catch {
142
+ throw new Error(`${error.message};原 Skill 保留于 ${previous}`)
143
+ }
144
+ }
145
+ throw error
146
+ } finally {
147
+ if (!moved) await fs.rm(stage, { recursive: true, force: true }).catch(() => {})
148
+ }
149
+ }
150
+
151
+ export async function runNpm(args, signal) {
152
+ const npmCli = process.env.npm_execpath
153
+ const options = { timeout: 120_000, windowsHide: true, signal }
154
+ if (npmCli && path.isAbsolute(npmCli) && path.basename(npmCli) === 'npm-cli.js') {
155
+ return (await exec(process.execPath, [npmCli, ...args], options)).stdout.trim()
156
+ }
157
+ if (process.platform !== 'win32') return (await exec('npm', args, options)).stdout.trim()
158
+ // cmd is needed for npm.cmd. Only the fixed command and validated tokens may reach it.
159
+ if (args.some(arg => !/^[\w.@:/-]+$/.test(arg))) throw new Error('不支持的 npm 参数')
160
+ return (await exec('cmd.exe', ['/d', '/c', 'npm', ...args], options)).stdout.trim()
161
+ }
162
+
163
+ export async function runInstall(args, {
164
+ home = homedir(), npm = runNpm, input = process.stdin, output = process.stdout,
165
+ log = console.log, version = packageInfo.version, signal,
166
+ } = {}) {
167
+ const opts = parseInstall(args)
168
+ if (opts.help) { log(help); return }
169
+ if (!/^\d+\.\d+\.\d+(?:-rc\.\d+)?$/.test(version)) throw new Error('安装入口要求明确的包版本')
170
+ const pkg = `${packageInfo.name}@${version}`
171
+ const all = await agentTargets(home)
172
+ let selected = all.filter(agent => opts.selected.includes(agent.id))
173
+ const detected = all.filter(agent => agent.detected)
174
+ const interactive = input.isTTY && output.isTTY
175
+ let rl
176
+ const ask = async prompt => {
177
+ rl ??= createInterface({ input, output })
178
+ return (await rl.question(prompt, { signal })).trim()
179
+ }
180
+ try {
181
+ if (!selected.length && opts.dryRun) {
182
+ log(JSON.stringify({ package: pkg, dry_run: true, detected: detected.map(({ id, target }) => ({ id, target })) }, null, 2))
183
+ return
184
+ }
185
+ if (!interactive && !opts.dryRun && !opts.yes) throw new Error('非交互安装需指定 --agent NAME --yes;可先运行 install --dry-run')
186
+ if (!selected.length) {
187
+ const choices = detected.length ? detected : all
188
+ log(detected.length ? '检测到以下 Agent:' : '未检测到 Agent,可手动选择已安装的宿主:')
189
+ choices.forEach(({ label, target }, i) => log(` ${i + 1}. ${label} ${target}`))
190
+ if (detected.length && detected.length < all.length) log('其他宿主可通过 --agent 明确指定。')
191
+ const answer = await ask('输入一个或多个序号(空格或逗号分隔,回车取消):')
192
+ if (!answer) { log('已取消,未安装。'); return }
193
+ const indexes = [...new Set(answer.split(/[\s,,]+/))]
194
+ if (indexes.some(i => !/^[1-9]\d*$/.test(i) || Number(i) > choices.length)) throw new Error('Agent 序号无效')
195
+ selected = indexes.map(i => choices[Number(i) - 1])
196
+ }
197
+ const states = await Promise.all(selected.map(inspect))
198
+ const blocked = selected.filter((_, i) => states[i].kind === 'protected')
199
+ log(JSON.stringify({ package: pkg, dry_run: opts.dryRun, skills: selected.map(({ id, target }, i) => ({ id, target, state: states[i].kind })) }, null, 2))
200
+ if (opts.dryRun) return
201
+ if (blocked.length) throw new Error(`已有手动安装或改动过的 Skill:${blocked.map(x => x.target).join(';')}。未安装;请先备份至扫描目录之外,或只选择其它 Agent。`)
202
+ if (!opts.yes && !/^(?:y|yes)$/i.test(await ask('安装或更新以上 CLI 和 Skill?[y/N] '))) { log('已取消,未安装。'); return }
203
+
204
+ signal?.throwIfAborted()
205
+ const root = await npm(['root', '--global'], signal)
206
+ if (!path.isAbsolute(root)) throw new Error('无法定位 npm 全局目录')
207
+ const directory = path.join(root, ...packageInfo.name.split('/'))
208
+ const metadata = async () => {
209
+ try { return JSON.parse(await fs.readFile(path.join(directory, 'package.json'), 'utf8')) } catch (error) {
210
+ if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error
211
+ }
212
+ }
213
+ let installed = await metadata()
214
+ if (installed?.name !== packageInfo.name || installed?.version !== version) {
215
+ await npm(['install', '--global', pkg, '--registry=' + registry, '--ignore-scripts', '--no-audit', '--no-fund'], signal)
216
+ installed = await metadata()
217
+ }
218
+ if (installed?.name !== packageInfo.name || installed?.version !== version || installed?.bin?.['uzoo-anime'] !== 'src/cli.js') {
219
+ throw new Error('全局 CLI 包身份、版本或入口验证失败,未写入 Skill')
220
+ }
221
+ const entry = path.join(directory, 'src', 'cli.js')
222
+ const prefix = await npm(['prefix', '--global'], signal)
223
+ if (!path.isAbsolute(prefix)) throw new Error('无法定位 npm 全局命令目录,未写入 Skill')
224
+ const bin = process.platform === 'win32' ? path.join(prefix, 'uzoo-anime.cmd') : path.join(prefix, 'bin', 'uzoo-anime')
225
+ const result = await exec(process.execPath, [entry, '--version'], { timeout: 15_000, windowsHide: true, signal })
226
+ if (!await stat(bin) || result.stdout.trim() !== version) throw new Error('全局 CLI 命令或版本验证失败,未写入 Skill')
227
+ const source = path.join(directory, bundle)
228
+ const digest = await verifyBundle(source)
229
+ for (let i = 0; i < selected.length; i++) {
230
+ signal?.throwIfAborted()
231
+ const status = await installSkill(selected[i], source, digest, version, states[i])
232
+ log(`${selected[i].label}: ${status} — ${selected[i].target}`)
233
+ }
234
+ log(`CLI ${version}: ${bin}`)
235
+ log('请在目标 Agent 的新会话验证 uzoo-anime --version;若命令不可见,请检查 npm 全局命令目录是否在其 PATH。')
236
+ } finally { rl?.close() }
237
+ }
@@ -0,0 +1,23 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+
5
+ // A failed download/write never occupies the requested filename. Linking in the
6
+ // same directory publishes a complete file without replacing an existing file.
7
+ export async function exclusiveOutput(output, write) {
8
+ const target = path.resolve(output)
9
+ const temporary = path.join(path.dirname(target), '.' + crypto.randomUUID() + '.part')
10
+ try {
11
+ try {
12
+ await fs.lstat(target)
13
+ throw Object.assign(new Error('目标文件已存在;请指定新文件,不覆盖'), { code: 'EEXIST' })
14
+ } catch (error) {
15
+ if (error.code !== 'ENOENT') throw error
16
+ }
17
+ const result = await write(temporary)
18
+ await fs.link(temporary, target)
19
+ return { output: target, ...result }
20
+ } finally {
21
+ await fs.rm(temporary, { force: true })
22
+ }
23
+ }