@yottameta/yotta-skills 0.7.2 → 0.19.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.
- package/CHANGELOG.md +108 -1
- package/README.md +29 -9
- package/README.zh-CN.md +33 -12
- package/SKILL.md +86 -12
- package/bin/yotta-skills.js +681 -90
- package/lib/hook-adapter.js +385 -0
- package/lib/install-evidence.js +18 -0
- package/lib/install-health.js +151 -0
- package/lib/install-lifecycle.js +149 -0
- package/lib/install-pipeline.js +387 -0
- package/lib/install-snapshot.js +245 -0
- package/lib/manifest.js +136 -0
- package/lib/update-check.js +99 -0
- package/lib/verify-gate.js +81 -0
- package/package.json +2 -1
- package/references/faq.md +26 -3
- package/references/install-flow.md +95 -7
- package/references/skill-list.md +10 -10
- package/references/tutorial.md +40 -6
- package/references/walkthroughs.md +11 -3
- package/scripts/yotta-skills-mcp.py +1 -1
- package/skill-manifest.json +34 -0
- package/skills.json +10 -10
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const EVENTS = [
|
|
9
|
+
'before_start',
|
|
10
|
+
'before_tool',
|
|
11
|
+
'before_install',
|
|
12
|
+
'before_publish',
|
|
13
|
+
'after_milestone',
|
|
14
|
+
'before_send',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const CAPABILITY_LEVELS = [
|
|
18
|
+
'native-block',
|
|
19
|
+
'native-audit',
|
|
20
|
+
'wrapper-only',
|
|
21
|
+
'unsupported',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const EVIDENCE_KINDS = [
|
|
25
|
+
'tool_call_id',
|
|
26
|
+
'hook_event_id',
|
|
27
|
+
'host_transcript',
|
|
28
|
+
'file_path',
|
|
29
|
+
'exit_code',
|
|
30
|
+
'audit_log',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const FALLBACKS = ['wrapper', 'advisory', 'explicit-unverified'];
|
|
34
|
+
|
|
35
|
+
const CODEX_CAPABILITIES = {
|
|
36
|
+
before_start: 'native-audit',
|
|
37
|
+
before_tool: 'native-audit',
|
|
38
|
+
before_install: 'wrapper-only',
|
|
39
|
+
before_publish: 'wrapper-only',
|
|
40
|
+
after_milestone: 'native-audit',
|
|
41
|
+
before_send: 'unsupported',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function unsupportedCapabilities() {
|
|
45
|
+
return EVENTS.reduce((out, event) => {
|
|
46
|
+
out[event] = 'unsupported';
|
|
47
|
+
return out;
|
|
48
|
+
}, {});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function capabilitiesForHost(host) {
|
|
52
|
+
if (host === 'codex') return { ...CODEX_CAPABILITIES };
|
|
53
|
+
return unsupportedCapabilities();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validateHookRequirements(manifest) {
|
|
57
|
+
const errors = [];
|
|
58
|
+
const requirements = manifest && Array.isArray(manifest.hooks) ? manifest.hooks : [];
|
|
59
|
+
requirements.forEach((requirement, index) => {
|
|
60
|
+
const prefix = 'hooks[' + index + ']';
|
|
61
|
+
if (!requirement || typeof requirement !== 'object' || Array.isArray(requirement)) {
|
|
62
|
+
errors.push(prefix + ': 必须是对象');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!EVENTS.includes(requirement.event)) {
|
|
66
|
+
errors.push(prefix + ': event 非法');
|
|
67
|
+
}
|
|
68
|
+
if (!['block', 'warn'].includes(requirement.on_fail)) {
|
|
69
|
+
errors.push(prefix + ': on_fail 非法');
|
|
70
|
+
}
|
|
71
|
+
if (!requirement.require_tool && !requirement.require_action) {
|
|
72
|
+
errors.push(prefix + ': 必须声明 require_tool 或 require_action');
|
|
73
|
+
}
|
|
74
|
+
if (!Array.isArray(requirement.evidence) || requirement.evidence.length === 0) {
|
|
75
|
+
errors.push(prefix + ': evidence 不能为空');
|
|
76
|
+
} else if (requirement.evidence.some((kind) => !EVIDENCE_KINDS.includes(kind))) {
|
|
77
|
+
errors.push(prefix + ': evidence 含不支持的来源');
|
|
78
|
+
}
|
|
79
|
+
if (requirement.fallback !== undefined && !FALLBACKS.includes(requirement.fallback)) {
|
|
80
|
+
errors.push(prefix + ': fallback 非法');
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
return { errors, requirements };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function actionOf(requirement, index) {
|
|
87
|
+
return requirement.require_tool || requirement.require_action || ('hook-' + index);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function checkFor(context, requirement) {
|
|
91
|
+
const key = requirement.require_tool || requirement.require_action;
|
|
92
|
+
return context && context.checks && context.checks[key] ? context.checks[key] : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function evidenceFor(requirement, context, check) {
|
|
96
|
+
const out = {};
|
|
97
|
+
for (const kind of requirement.evidence || []) {
|
|
98
|
+
if (check && check.evidence && check.evidence[kind] !== undefined) out[kind] = check.evidence[kind];
|
|
99
|
+
else if (context && context.evidence && context.evidence[kind] !== undefined) out[kind] = context.evidence[kind];
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function evaluateOne(requirement, index, event, capability, context, skill) {
|
|
105
|
+
const action = actionOf(requirement, index);
|
|
106
|
+
const check = checkFor(context, requirement);
|
|
107
|
+
const evidence = evidenceFor(requirement, context, check);
|
|
108
|
+
const hasEvidence = Object.keys(evidence).length > 0;
|
|
109
|
+
const passed = !!check && check.ok === true;
|
|
110
|
+
const failed = !!check && check.ok === false;
|
|
111
|
+
const missing = !check;
|
|
112
|
+
let decision = 'allow';
|
|
113
|
+
let reason = '检查通过';
|
|
114
|
+
let correction = false;
|
|
115
|
+
|
|
116
|
+
if (passed) {
|
|
117
|
+
const canVerify = capability === 'native-block'
|
|
118
|
+
|| capability === 'native-audit'
|
|
119
|
+
|| (capability === 'wrapper-only' && context && context.wrapperRegistered === true);
|
|
120
|
+
if (!canVerify) {
|
|
121
|
+
decision = 'unverified';
|
|
122
|
+
reason = hasEvidence ? '宿主能力不足,不能声明为强制' : '缺少可验证证据';
|
|
123
|
+
} else if (!hasEvidence) {
|
|
124
|
+
decision = 'unverified';
|
|
125
|
+
reason = '缺少声明要求的可验证证据';
|
|
126
|
+
}
|
|
127
|
+
} else if (failed || missing) {
|
|
128
|
+
if (requirement.on_fail === 'warn') {
|
|
129
|
+
decision = 'warn';
|
|
130
|
+
reason = '检查未通过,按声明降级为警告';
|
|
131
|
+
} else if (capability === 'native-block') {
|
|
132
|
+
decision = 'block';
|
|
133
|
+
reason = '检查未通过,宿主支持动作前阻断';
|
|
134
|
+
} else if (capability === 'wrapper-only') {
|
|
135
|
+
if (context && context.wrapperRegistered === true) {
|
|
136
|
+
decision = 'block';
|
|
137
|
+
reason = '检查未通过,wrapper 路径可阻断';
|
|
138
|
+
} else {
|
|
139
|
+
decision = 'unverified';
|
|
140
|
+
reason = 'wrapper 未注册,无法保证拦截';
|
|
141
|
+
}
|
|
142
|
+
} else if (capability === 'native-audit') {
|
|
143
|
+
decision = 'unverified';
|
|
144
|
+
correction = true;
|
|
145
|
+
reason = '动作已发生,只能审计并触发一次纠偏';
|
|
146
|
+
} else {
|
|
147
|
+
decision = 'unverified';
|
|
148
|
+
reason = '宿主不支持该事件,按 explicit-unverified 降级';
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
event,
|
|
154
|
+
skill: skill || null,
|
|
155
|
+
action,
|
|
156
|
+
capability,
|
|
157
|
+
decision,
|
|
158
|
+
passed,
|
|
159
|
+
failed,
|
|
160
|
+
missing,
|
|
161
|
+
correction,
|
|
162
|
+
evidence,
|
|
163
|
+
reason,
|
|
164
|
+
requirement,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function evaluateHook(input) {
|
|
169
|
+
const opts = input || {};
|
|
170
|
+
const event = opts.event;
|
|
171
|
+
const context = opts.context || {};
|
|
172
|
+
const manifest = opts.manifest || {};
|
|
173
|
+
const capabilities = opts.capabilities || capabilitiesForHost(opts.host || 'generic');
|
|
174
|
+
if (!EVENTS.includes(event)) {
|
|
175
|
+
return {
|
|
176
|
+
decision: 'block',
|
|
177
|
+
verified: false,
|
|
178
|
+
correction: false,
|
|
179
|
+
results: [],
|
|
180
|
+
evidence: [],
|
|
181
|
+
user_message: '阻断:未知 hook 事件 ' + event,
|
|
182
|
+
errors: ['未知 hook 事件: ' + event],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const validation = validateHookRequirements(manifest);
|
|
187
|
+
if (validation.errors.length > 0) {
|
|
188
|
+
return {
|
|
189
|
+
decision: 'block',
|
|
190
|
+
verified: false,
|
|
191
|
+
correction: false,
|
|
192
|
+
results: [],
|
|
193
|
+
evidence: [],
|
|
194
|
+
user_message: '阻断:manifest hook 声明非法',
|
|
195
|
+
errors: validation.errors,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const requirements = validation.requirements.filter((requirement) => requirement.event === event);
|
|
200
|
+
const results = requirements.map((requirement, index) => evaluateOne(
|
|
201
|
+
requirement,
|
|
202
|
+
index,
|
|
203
|
+
event,
|
|
204
|
+
capabilities[event] || 'unsupported',
|
|
205
|
+
context,
|
|
206
|
+
manifest.slug,
|
|
207
|
+
));
|
|
208
|
+
const decision = results.some((result) => result.decision === 'block')
|
|
209
|
+
? 'block'
|
|
210
|
+
: results.some((result) => result.decision === 'warn')
|
|
211
|
+
? 'warn'
|
|
212
|
+
: results.some((result) => result.decision === 'unverified')
|
|
213
|
+
? 'unverified'
|
|
214
|
+
: 'allow';
|
|
215
|
+
const evidence = results.map((result) => ({
|
|
216
|
+
event: result.event,
|
|
217
|
+
skill: result.skill,
|
|
218
|
+
action: result.action,
|
|
219
|
+
result: result.decision,
|
|
220
|
+
capability: result.capability,
|
|
221
|
+
evidence: result.evidence,
|
|
222
|
+
reason: result.reason,
|
|
223
|
+
}));
|
|
224
|
+
const correction = results.some((result) => result.correction);
|
|
225
|
+
const verified = decision === 'allow' && results.every((result) => result.decision === 'allow');
|
|
226
|
+
let userMessage = '已通过';
|
|
227
|
+
if (decision === 'block') userMessage = '阻断:' + results.filter((result) => result.decision === 'block').map((result) => result.reason).join(';');
|
|
228
|
+
else if (decision === 'warn') userMessage = '警告:' + results.filter((result) => result.decision === 'warn').map((result) => result.reason).join(';');
|
|
229
|
+
else if (decision === 'unverified') userMessage = 'explicit-unverified:' + results.filter((result) => result.decision === 'unverified').map((result) => result.reason).join(';');
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
decision,
|
|
233
|
+
verified,
|
|
234
|
+
correction,
|
|
235
|
+
results,
|
|
236
|
+
evidence,
|
|
237
|
+
user_message: userMessage,
|
|
238
|
+
errors: [],
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function hookLogPath(homeDir) {
|
|
243
|
+
return path.join(homeDir || os.homedir(), '.yottaskills', 'hook-log.jsonl');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function bindingsPath(homeDir) {
|
|
247
|
+
return path.join(homeDir || os.homedir(), '.yottaskills', 'hook-bindings.json');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function appendHookEvidence(entry, options) {
|
|
251
|
+
const file = hookLogPath(options && options.homeDir);
|
|
252
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
253
|
+
const record = {
|
|
254
|
+
...entry,
|
|
255
|
+
timestamp: entry.timestamp || new Date().toISOString(),
|
|
256
|
+
};
|
|
257
|
+
fs.appendFileSync(file, JSON.stringify(record) + '\n', 'utf8');
|
|
258
|
+
return file;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function emptyBindings() {
|
|
262
|
+
return { version: 1, bindings: {} };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function readBindings(homeDir) {
|
|
266
|
+
try {
|
|
267
|
+
const parsed = JSON.parse(fs.readFileSync(bindingsPath(homeDir), 'utf8'));
|
|
268
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || !parsed.bindings) return emptyBindings();
|
|
269
|
+
return { version: 1, bindings: { ...parsed.bindings } };
|
|
270
|
+
} catch (_) {
|
|
271
|
+
return emptyBindings();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function writeBindings(homeDir, value) {
|
|
276
|
+
const file = bindingsPath(homeDir);
|
|
277
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
278
|
+
const tmp = file + '.tmp-' + process.pid + '-' + Date.now();
|
|
279
|
+
fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', 'utf8');
|
|
280
|
+
try {
|
|
281
|
+
fs.renameSync(tmp, file);
|
|
282
|
+
} catch (_) {
|
|
283
|
+
fs.rmSync(file, { force: true });
|
|
284
|
+
fs.renameSync(tmp, file);
|
|
285
|
+
}
|
|
286
|
+
return file;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function bindingId(host, skill, event, action) {
|
|
290
|
+
return crypto.createHash('sha256').update([host, skill, event, action].join('|')).digest('hex').slice(0, 24);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function bindHookRequirements(input) {
|
|
294
|
+
const opts = input || {};
|
|
295
|
+
const manifest = opts.manifest || {};
|
|
296
|
+
const validation = validateHookRequirements(manifest);
|
|
297
|
+
if (validation.errors.length > 0) {
|
|
298
|
+
const error = new Error(validation.errors.join('; '));
|
|
299
|
+
error.errors = validation.errors;
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
const capabilities = capabilitiesForHost(opts.host || 'generic');
|
|
303
|
+
const selected = opts.event
|
|
304
|
+
? validation.requirements.filter((requirement) => requirement.event === opts.event)
|
|
305
|
+
: validation.requirements;
|
|
306
|
+
const registry = readBindings(opts.homeDir);
|
|
307
|
+
const records = selected.map((requirement, index) => {
|
|
308
|
+
const action = actionOf(requirement, index);
|
|
309
|
+
const id = bindingId(opts.host || 'generic', manifest.slug, requirement.event, action);
|
|
310
|
+
const previous = registry.bindings[id];
|
|
311
|
+
const record = {
|
|
312
|
+
id,
|
|
313
|
+
host: opts.host || 'generic',
|
|
314
|
+
skill: manifest.slug,
|
|
315
|
+
event: requirement.event,
|
|
316
|
+
action,
|
|
317
|
+
on_fail: requirement.on_fail,
|
|
318
|
+
fallback: requirement.fallback || 'explicit-unverified',
|
|
319
|
+
capability: capabilities[requirement.event] || 'unsupported',
|
|
320
|
+
created_at: previous ? previous.created_at : new Date().toISOString(),
|
|
321
|
+
};
|
|
322
|
+
registry.bindings[id] = record;
|
|
323
|
+
return record;
|
|
324
|
+
});
|
|
325
|
+
writeBindings(opts.homeDir, registry);
|
|
326
|
+
return records;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function listHookBindings(homeDir) {
|
|
330
|
+
return Object.values(readBindings(homeDir).bindings)
|
|
331
|
+
.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at)) || a.id.localeCompare(b.id));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function unbindHookBinding(id, options) {
|
|
335
|
+
const registry = readBindings(options && options.homeDir);
|
|
336
|
+
if (!registry.bindings[id]) return false;
|
|
337
|
+
delete registry.bindings[id];
|
|
338
|
+
writeBindings(options && options.homeDir, registry);
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function createHookAdapter(options) {
|
|
343
|
+
const opts = options || {};
|
|
344
|
+
const host = opts.host || 'generic';
|
|
345
|
+
const capabilities = capabilitiesForHost(host);
|
|
346
|
+
return {
|
|
347
|
+
host,
|
|
348
|
+
capabilities: () => ({ ...capabilities }),
|
|
349
|
+
validate: (manifest) => validateHookRequirements(manifest),
|
|
350
|
+
evaluate: (event, manifest, context) => evaluateHook({
|
|
351
|
+
host,
|
|
352
|
+
event,
|
|
353
|
+
manifest,
|
|
354
|
+
context,
|
|
355
|
+
capabilities,
|
|
356
|
+
}),
|
|
357
|
+
bind: (manifest, event) => bindHookRequirements({
|
|
358
|
+
host,
|
|
359
|
+
homeDir: opts.homeDir,
|
|
360
|
+
manifest,
|
|
361
|
+
event,
|
|
362
|
+
}),
|
|
363
|
+
unbind: (id) => unbindHookBinding(id, { homeDir: opts.homeDir }),
|
|
364
|
+
listBindings: () => listHookBindings(opts.homeDir),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
module.exports = {
|
|
369
|
+
EVENTS,
|
|
370
|
+
CAPABILITY_LEVELS,
|
|
371
|
+
EVIDENCE_KINDS,
|
|
372
|
+
FALLBACKS,
|
|
373
|
+
CODEX_CAPABILITIES,
|
|
374
|
+
appendHookEvidence,
|
|
375
|
+
bindHookRequirements,
|
|
376
|
+
bindingId,
|
|
377
|
+
bindingsPath,
|
|
378
|
+
capabilitiesForHost,
|
|
379
|
+
createHookAdapter,
|
|
380
|
+
evaluateHook,
|
|
381
|
+
hookLogPath,
|
|
382
|
+
listHookBindings,
|
|
383
|
+
unbindHookBinding,
|
|
384
|
+
validateHookRequirements,
|
|
385
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
function evidencePath(options) {
|
|
7
|
+
const home = (options && options.homeDir) || os.homedir();
|
|
8
|
+
return path.join(home, '.yottaskills', 'install-log.jsonl');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function appendEvidence(entry, options) {
|
|
12
|
+
const file = evidencePath(options);
|
|
13
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
14
|
+
fs.appendFileSync(file, JSON.stringify(entry) + '\n', 'utf8');
|
|
15
|
+
return file;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = { evidencePath, appendEvidence };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const manifestLib = require('./manifest');
|
|
5
|
+
const scanLib = require('./skills-scan');
|
|
6
|
+
|
|
7
|
+
function addCheck(checks, id, ok, message, severity, hint) {
|
|
8
|
+
checks.push({
|
|
9
|
+
id,
|
|
10
|
+
ok: !!ok,
|
|
11
|
+
severity: severity || (ok ? 'info' : 'error'),
|
|
12
|
+
message,
|
|
13
|
+
hint: hint || null,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readManifest(target, slug) {
|
|
18
|
+
const file = path.join(target, manifestLib.MANIFEST_FILE);
|
|
19
|
+
if (!fs.existsSync(file)) return { value: null, error: null };
|
|
20
|
+
try {
|
|
21
|
+
return { value: JSON.parse(fs.readFileSync(file, 'utf8')), error: null };
|
|
22
|
+
} catch (error) {
|
|
23
|
+
return { value: null, error: '无法解析 skill-manifest.json: ' + error.message };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function checkInstalledSkill(context) {
|
|
28
|
+
const ctx = context || {};
|
|
29
|
+
const slug = String(ctx.slug || '');
|
|
30
|
+
const target = ctx.target ? path.resolve(ctx.target) : '';
|
|
31
|
+
const checks = [];
|
|
32
|
+
const errors = [];
|
|
33
|
+
const warnings = [];
|
|
34
|
+
const fixes = [];
|
|
35
|
+
let version = null;
|
|
36
|
+
|
|
37
|
+
if (!slug) {
|
|
38
|
+
addCheck(checks, 'slug', false, '缺少技能 slug');
|
|
39
|
+
errors.push('缺少技能 slug');
|
|
40
|
+
}
|
|
41
|
+
if (!target || !fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
|
|
42
|
+
addCheck(checks, 'target', false, '技能目录不存在' + (target ? ': ' + target : ''));
|
|
43
|
+
errors.push('技能目录不存在' + (target ? ': ' + target : ''));
|
|
44
|
+
return { ok: false, slug, version, checks, errors, warnings, fixes };
|
|
45
|
+
}
|
|
46
|
+
addCheck(checks, 'target', true, '技能目录存在');
|
|
47
|
+
|
|
48
|
+
const skillFile = path.join(target, 'SKILL.md');
|
|
49
|
+
let text = null;
|
|
50
|
+
try {
|
|
51
|
+
text = fs.readFileSync(skillFile, 'utf8');
|
|
52
|
+
addCheck(checks, 'skill_file', true, 'SKILL.md 可读');
|
|
53
|
+
} catch (error) {
|
|
54
|
+
addCheck(checks, 'skill_file', false, 'SKILL.md 不可读: ' + error.message);
|
|
55
|
+
errors.push('SKILL.md 不可读: ' + error.message);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let frontmatter = null;
|
|
59
|
+
if (text !== null) {
|
|
60
|
+
frontmatter = scanLib.parseFrontmatter(text);
|
|
61
|
+
if (!frontmatter) {
|
|
62
|
+
addCheck(checks, 'frontmatter', false, 'SKILL.md 缺少有效 frontmatter');
|
|
63
|
+
errors.push('SKILL.md 缺少有效 frontmatter');
|
|
64
|
+
} else {
|
|
65
|
+
addCheck(checks, 'frontmatter', true, 'SKILL.md frontmatter 有效');
|
|
66
|
+
if (frontmatter.name !== slug) {
|
|
67
|
+
addCheck(checks, 'slug_identity', false, 'SKILL.md name 与目标 slug 不一致');
|
|
68
|
+
errors.push('SKILL.md name 与目标 slug 不一致');
|
|
69
|
+
} else {
|
|
70
|
+
addCheck(checks, 'slug_identity', true, 'SKILL.md slug 一致');
|
|
71
|
+
}
|
|
72
|
+
version = frontmatter.version ? String(frontmatter.version).trim() : null;
|
|
73
|
+
if (!version || !/^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
|
|
74
|
+
addCheck(checks, 'version_format', false, 'SKILL.md 版本号非法');
|
|
75
|
+
errors.push('SKILL.md 版本号非法');
|
|
76
|
+
} else {
|
|
77
|
+
addCheck(checks, 'version_format', true, 'SKILL.md 版本号有效');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (ctx.expectedVersion && version && version !== ctx.expectedVersion) {
|
|
83
|
+
addCheck(checks, 'version_match', false, '版本不一致:已安装 v' + version + ',期望 v' + ctx.expectedVersion);
|
|
84
|
+
errors.push('版本不一致:已安装 v' + version + ',期望 v' + ctx.expectedVersion);
|
|
85
|
+
} else if (ctx.expectedVersion && version) {
|
|
86
|
+
addCheck(checks, 'version_match', true, '版本与期望一致');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const loadedManifest = readManifest(target, slug);
|
|
90
|
+
if (loadedManifest.error) {
|
|
91
|
+
addCheck(checks, 'manifest', false, loadedManifest.error);
|
|
92
|
+
errors.push(loadedManifest.error);
|
|
93
|
+
} else if (loadedManifest.value) {
|
|
94
|
+
if (loadedManifest.value.trust !== 'yottameta') {
|
|
95
|
+
addCheck(checks, 'manifest_trust', false, 'manifest trust 必须为 yottameta');
|
|
96
|
+
errors.push('manifest trust 必须为 yottameta');
|
|
97
|
+
} else {
|
|
98
|
+
addCheck(checks, 'manifest_trust', true, 'manifest trust 为 yottameta');
|
|
99
|
+
}
|
|
100
|
+
const validation = manifestLib.validateManifest(loadedManifest.value, {
|
|
101
|
+
skill: {
|
|
102
|
+
slug,
|
|
103
|
+
pkg: ctx.expectedPackage || loadedManifest.value.package,
|
|
104
|
+
version: version || loadedManifest.value.version,
|
|
105
|
+
},
|
|
106
|
+
pkgDir: target,
|
|
107
|
+
requirePackageJson: false,
|
|
108
|
+
});
|
|
109
|
+
if (!validation.ok) {
|
|
110
|
+
addCheck(checks, 'manifest', false, 'manifest 校验失败: ' + validation.errors.join('; '));
|
|
111
|
+
errors.push.apply(errors, validation.errors);
|
|
112
|
+
} else {
|
|
113
|
+
addCheck(checks, 'manifest', true, 'manifest 校验通过');
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
addCheck(checks, 'manifest', true, '未提供 skill-manifest.json,使用家族默认契约');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (ctx.registry) {
|
|
120
|
+
const record = ctx.registry.skills && ctx.registry.skills[slug];
|
|
121
|
+
if (!record || record.status === 'gone') {
|
|
122
|
+
addCheck(checks, 'registry', false, '注册表未记录当前技能', 'warning');
|
|
123
|
+
warnings.push('注册表未记录当前技能');
|
|
124
|
+
fixes.push('运行 yotta-skills --reindex 重扫注册表');
|
|
125
|
+
} else if (version && record.version && record.version !== version) {
|
|
126
|
+
addCheck(
|
|
127
|
+
checks,
|
|
128
|
+
'registry',
|
|
129
|
+
false,
|
|
130
|
+
'注册表版本 v' + record.version + ' 与已安装 v' + version + ' 不一致',
|
|
131
|
+
'warning',
|
|
132
|
+
);
|
|
133
|
+
warnings.push('注册表版本 v' + record.version + ' 与已安装 v' + version + ' 不一致');
|
|
134
|
+
fixes.push('运行 yotta-skills --reindex 重扫注册表');
|
|
135
|
+
} else {
|
|
136
|
+
addCheck(checks, 'registry', true, '注册表记录一致');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
ok: errors.length === 0,
|
|
142
|
+
slug,
|
|
143
|
+
version,
|
|
144
|
+
checks,
|
|
145
|
+
errors,
|
|
146
|
+
warnings,
|
|
147
|
+
fixes: Array.from(new Set(fixes)),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = { checkInstalledSkill };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { spawnSync } = require('child_process');
|
|
5
|
+
const manifestLib = require('./manifest');
|
|
6
|
+
|
|
7
|
+
const PHASES = ['setup', 'doctor', 'rollback'];
|
|
8
|
+
|
|
9
|
+
function parseJsonResult(stdout) {
|
|
10
|
+
const text = String(stdout || '').trim();
|
|
11
|
+
if (!text) return null;
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(text);
|
|
14
|
+
} catch (_) {
|
|
15
|
+
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
16
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
17
|
+
if (!lines[i].startsWith('{')) continue;
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(lines[i]);
|
|
20
|
+
} catch (_) {
|
|
21
|
+
// Keep looking for the structured result line.
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function runLifecycleScript(scriptPath, args, options) {
|
|
29
|
+
const opts = options || {};
|
|
30
|
+
if (!scriptPath || !fs.existsSync(scriptPath) || !fs.statSync(scriptPath).isFile()) {
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
skipped: false,
|
|
34
|
+
result: null,
|
|
35
|
+
error: '生命周期脚本不存在: ' + (scriptPath || '未提供'),
|
|
36
|
+
stdout: '',
|
|
37
|
+
stderr: '',
|
|
38
|
+
exitCode: null,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (!/\.(?:js|cjs)$/i.test(scriptPath)) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
skipped: false,
|
|
45
|
+
result: null,
|
|
46
|
+
error: '生命周期脚本必须为 Node.js .js/.cjs 文件',
|
|
47
|
+
stdout: '',
|
|
48
|
+
stderr: '',
|
|
49
|
+
exitCode: null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const spawn = opts.spawnSync || spawnSync;
|
|
54
|
+
const argv = [scriptPath].concat(Array.isArray(args) ? args : []).concat(['--json']);
|
|
55
|
+
let proc;
|
|
56
|
+
try {
|
|
57
|
+
proc = spawn(process.execPath, argv, {
|
|
58
|
+
cwd: opts.cwd || path.dirname(scriptPath),
|
|
59
|
+
encoding: 'utf8',
|
|
60
|
+
timeout: opts.timeout || 30000,
|
|
61
|
+
maxBuffer: opts.maxBuffer || 4 * 1024 * 1024,
|
|
62
|
+
});
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
skipped: false,
|
|
67
|
+
result: null,
|
|
68
|
+
error: error.message,
|
|
69
|
+
stdout: '',
|
|
70
|
+
stderr: '',
|
|
71
|
+
exitCode: null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const stdout = proc.stdout || '';
|
|
76
|
+
const stderr = proc.stderr || '';
|
|
77
|
+
const parsed = parseJsonResult(stdout);
|
|
78
|
+
const exitCode = proc.status;
|
|
79
|
+
if (proc.error) {
|
|
80
|
+
return { ok: false, skipped: false, result: parsed, error: proc.error.message, stdout, stderr, exitCode };
|
|
81
|
+
}
|
|
82
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
skipped: false,
|
|
86
|
+
result: null,
|
|
87
|
+
error: '生命周期脚本未返回合法 JSON' + (stderr ? ':' + stderr.trim() : ''),
|
|
88
|
+
stdout,
|
|
89
|
+
stderr,
|
|
90
|
+
exitCode,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (exitCode !== 0) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
skipped: false,
|
|
97
|
+
result: parsed,
|
|
98
|
+
error: parsed.error || stderr.trim() || ('生命周期脚本退出码 ' + exitCode),
|
|
99
|
+
stdout,
|
|
100
|
+
stderr,
|
|
101
|
+
exitCode,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (parsed.ok === false) {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
skipped: false,
|
|
108
|
+
result: parsed,
|
|
109
|
+
error: parsed.error || '生命周期脚本返回失败',
|
|
110
|
+
stdout,
|
|
111
|
+
stderr,
|
|
112
|
+
exitCode,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return { ok: true, skipped: false, result: parsed, error: null, stdout, stderr, exitCode };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function runPhase(packageDir, manifest, phase, context) {
|
|
119
|
+
if (!PHASES.includes(phase)) {
|
|
120
|
+
return { ok: false, skipped: false, result: null, error: '未知生命周期阶段: ' + phase };
|
|
121
|
+
}
|
|
122
|
+
const ctx = context || {};
|
|
123
|
+
const install = manifest && manifest.install ? manifest.install : {};
|
|
124
|
+
const rel = install[phase];
|
|
125
|
+
if (!rel) {
|
|
126
|
+
return { ok: true, skipped: true, result: null, error: null };
|
|
127
|
+
}
|
|
128
|
+
if (!manifestLib.isSafeRelativePath(rel)) {
|
|
129
|
+
return { ok: false, skipped: false, result: null, error: '生命周期脚本路径不安全: ' + rel };
|
|
130
|
+
}
|
|
131
|
+
const root = path.resolve(packageDir);
|
|
132
|
+
const scriptPath = path.resolve(root, rel);
|
|
133
|
+
const relative = path.relative(root, scriptPath);
|
|
134
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
135
|
+
return { ok: false, skipped: false, result: null, error: '生命周期脚本越出包目录: ' + rel };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const args = [];
|
|
139
|
+
if (ctx.skillDir) args.push('--skill-dir', ctx.skillDir);
|
|
140
|
+
if (ctx.packageDir || packageDir) args.push('--package-dir', ctx.packageDir || packageDir);
|
|
141
|
+
if (ctx.dest) args.push('--dest', ctx.dest);
|
|
142
|
+
if (ctx.snapshot) args.push('--snapshot', ctx.snapshot);
|
|
143
|
+
return runLifecycleScript(scriptPath, args, {
|
|
144
|
+
...ctx.options,
|
|
145
|
+
cwd: (ctx.options && ctx.options.cwd) || root,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { PHASES, parseJsonResult, runLifecycleScript, runPhase };
|