redskillhub-upload 1.0.0-alpha.1
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/README.md +76 -0
- package/cli/auth-prompt.mjs +41 -0
- package/cli/auth.mjs +453 -0
- package/cli/config.mjs +51 -0
- package/cli/errors.mjs +30 -0
- package/cli/fetch.mjs +127 -0
- package/cli/index.mjs +311 -0
- package/cli/output.mjs +15 -0
- package/cli/pack.mjs +494 -0
- package/cli/prompt.mjs +115 -0
- package/cli/redact.mjs +8 -0
- package/cli/submit.mjs +253 -0
- package/cli/tags.mjs +149 -0
- package/cli/upload.mjs +214 -0
- package/package.json +29 -0
- package/skill/SKILL.md +115 -0
package/cli/pack.mjs
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import fsSync from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import AdmZip from 'adm-zip';
|
|
6
|
+
import { getTmpDir } from './config.mjs';
|
|
7
|
+
import { SkillhubUploadError, ExitCodes } from './errors.mjs';
|
|
8
|
+
|
|
9
|
+
const IGNORE_DIRS = new Set(['.git', 'node_modules', '__MACOSX']);
|
|
10
|
+
const IGNORE_FILES = new Set(['.DS_Store']);
|
|
11
|
+
const ROOT_SKILL_FILES = ['SKILL.md', 'skill.md'];
|
|
12
|
+
const SINGLE_FILE_MAX_SIZE = 10 * 1024 * 1024;
|
|
13
|
+
const TOTAL_MAX_SIZE = 30 * 1024 * 1024;
|
|
14
|
+
const ALLOWED_EXTENSIONS = new Set([
|
|
15
|
+
'.md',
|
|
16
|
+
'.txt',
|
|
17
|
+
'.html',
|
|
18
|
+
'.htm',
|
|
19
|
+
'.css',
|
|
20
|
+
'.js',
|
|
21
|
+
'.py',
|
|
22
|
+
'.java',
|
|
23
|
+
'.cpp',
|
|
24
|
+
'.c',
|
|
25
|
+
'.h',
|
|
26
|
+
'.php',
|
|
27
|
+
'.sh',
|
|
28
|
+
'.bat',
|
|
29
|
+
'.ps1',
|
|
30
|
+
'.json',
|
|
31
|
+
'.xml',
|
|
32
|
+
'.sql',
|
|
33
|
+
'.ini',
|
|
34
|
+
'.cfg',
|
|
35
|
+
'.log',
|
|
36
|
+
'.db',
|
|
37
|
+
'.sqlite',
|
|
38
|
+
'.sqlite3',
|
|
39
|
+
'.mdb',
|
|
40
|
+
'.accdb',
|
|
41
|
+
'.sys'
|
|
42
|
+
]);
|
|
43
|
+
const BINARY_EXTENSIONS = new Set([
|
|
44
|
+
'.exe',
|
|
45
|
+
'.dll',
|
|
46
|
+
'.so',
|
|
47
|
+
'.dylib',
|
|
48
|
+
'.bin',
|
|
49
|
+
'.obj',
|
|
50
|
+
'.o',
|
|
51
|
+
'.a',
|
|
52
|
+
'.lib',
|
|
53
|
+
'.class',
|
|
54
|
+
'.jar',
|
|
55
|
+
'.war',
|
|
56
|
+
'.ear',
|
|
57
|
+
'.zip',
|
|
58
|
+
'.tar',
|
|
59
|
+
'.gz',
|
|
60
|
+
'.rar',
|
|
61
|
+
'.7z',
|
|
62
|
+
'.png',
|
|
63
|
+
'.jpg',
|
|
64
|
+
'.jpeg',
|
|
65
|
+
'.gif',
|
|
66
|
+
'.bmp',
|
|
67
|
+
'.ico',
|
|
68
|
+
'.svg',
|
|
69
|
+
'.webp',
|
|
70
|
+
'.mp3',
|
|
71
|
+
'.mp4',
|
|
72
|
+
'.avi',
|
|
73
|
+
'.mov',
|
|
74
|
+
'.wav',
|
|
75
|
+
'.pdf',
|
|
76
|
+
'.doc',
|
|
77
|
+
'.docx',
|
|
78
|
+
'.xls',
|
|
79
|
+
'.xlsx',
|
|
80
|
+
'.ppt',
|
|
81
|
+
'.pptx'
|
|
82
|
+
]);
|
|
83
|
+
const ARCHIVE_EXTENSIONS = new Set(['.zip', '.tar', '.gz', '.rar', '.7z']);
|
|
84
|
+
|
|
85
|
+
export function parseSkillMarkdown(markdown) {
|
|
86
|
+
const match = markdown.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
87
|
+
if (!match) {
|
|
88
|
+
return { frontmatter: {}, detail: markdown.trim() };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const frontmatter = {};
|
|
92
|
+
for (const line of match[1].split('\n')) {
|
|
93
|
+
const idx = line.indexOf(':');
|
|
94
|
+
if (idx > 0) {
|
|
95
|
+
frontmatter[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { frontmatter, detail: match[2].trim() };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function deriveIdentifier(name = '') {
|
|
103
|
+
return name
|
|
104
|
+
.normalize('NFKD')
|
|
105
|
+
.replace(/[^\w\s-]/g, '')
|
|
106
|
+
.trim()
|
|
107
|
+
.toLowerCase()
|
|
108
|
+
.replace(/\s+/g, '-')
|
|
109
|
+
.replace(/-+/g, '-')
|
|
110
|
+
.replace(/^-|-$/g, '')
|
|
111
|
+
.slice(0, 64);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function walkDirectory(root, rel = '') {
|
|
115
|
+
const current = path.join(root, rel);
|
|
116
|
+
const entries = await fs.readdir(current, { withFileTypes: true });
|
|
117
|
+
const files = [];
|
|
118
|
+
|
|
119
|
+
for (const entry of entries) {
|
|
120
|
+
const entryRel = path.join(rel, entry.name);
|
|
121
|
+
const entryPath = path.join(root, entryRel);
|
|
122
|
+
const stat = await fs.lstat(entryPath);
|
|
123
|
+
|
|
124
|
+
if (stat.isSymbolicLink()) {
|
|
125
|
+
throw new SkillhubUploadError(
|
|
126
|
+
'LOCAL_VALIDATION_FAILED',
|
|
127
|
+
`目录中包含符号链接:${entryRel},请移除后重试`,
|
|
128
|
+
ExitCodes.LOCAL_VALIDATION
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
if (stat.isDirectory() && IGNORE_DIRS.has(entry.name)) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (stat.isFile() && IGNORE_FILES.has(entry.name)) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (stat.isDirectory()) {
|
|
139
|
+
files.push(...await walkDirectory(root, entryRel));
|
|
140
|
+
}
|
|
141
|
+
if (stat.isFile()) {
|
|
142
|
+
files.push(entryRel);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return files;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function sha256File(file) {
|
|
150
|
+
const hash = crypto.createHash('sha256');
|
|
151
|
+
await new Promise((resolve, reject) => {
|
|
152
|
+
fsSync.createReadStream(file)
|
|
153
|
+
.on('data', (chunk) => hash.update(chunk))
|
|
154
|
+
.on('error', reject)
|
|
155
|
+
.on('end', resolve);
|
|
156
|
+
});
|
|
157
|
+
return hash.digest('hex');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function bufferStartsWith(buffer, bytes, offset = 0) {
|
|
161
|
+
if (buffer.length < offset + bytes.length) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
return bytes.every((byte, index) => buffer[offset + index] === byte);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function bufferAscii(buffer, start, length) {
|
|
168
|
+
if (buffer.length < start + length) {
|
|
169
|
+
return '';
|
|
170
|
+
}
|
|
171
|
+
return buffer.subarray(start, start + length).toString('ascii');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function hasForbiddenSignature(buffer) {
|
|
175
|
+
const head = buffer.subarray(0, Math.min(buffer.length, 512));
|
|
176
|
+
return (
|
|
177
|
+
bufferStartsWith(head, [0x4d, 0x5a]) || // exe / dll
|
|
178
|
+
bufferStartsWith(head, [0x7f, 0x45, 0x4c, 0x46]) || // so / object
|
|
179
|
+
bufferStartsWith(head, [0xfe, 0xed, 0xfa, 0xce]) ||
|
|
180
|
+
bufferStartsWith(head, [0xfe, 0xed, 0xfa, 0xcf]) ||
|
|
181
|
+
bufferStartsWith(head, [0xce, 0xfa, 0xed, 0xfe]) ||
|
|
182
|
+
bufferStartsWith(head, [0xcf, 0xfa, 0xed, 0xfe]) || // Mach-O
|
|
183
|
+
bufferStartsWith(head, [0x21, 0x3c, 0x61, 0x72, 0x63, 0x68, 0x3e, 0x0a]) || // .a / .lib archive
|
|
184
|
+
bufferStartsWith(head, [0xca, 0xfe, 0xba, 0xbe]) || // class
|
|
185
|
+
bufferStartsWith(head, [0x50, 0x4b, 0x03, 0x04]) || // zip / jar / docx / xlsx / pptx
|
|
186
|
+
bufferStartsWith(head, [0x50, 0x4b, 0x05, 0x06]) ||
|
|
187
|
+
bufferStartsWith(head, [0x50, 0x4b, 0x07, 0x08]) ||
|
|
188
|
+
bufferStartsWith(head, [0x1f, 0x8b]) || // gzip
|
|
189
|
+
bufferStartsWith(head, [0x52, 0x61, 0x72, 0x21, 0x1a, 0x07]) || // rar
|
|
190
|
+
bufferStartsWith(head, [0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c]) || // 7z
|
|
191
|
+
bufferStartsWith(head, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) || // png
|
|
192
|
+
bufferStartsWith(head, [0xff, 0xd8, 0xff]) || // jpeg
|
|
193
|
+
bufferAscii(head, 0, 6) === 'GIF87a' ||
|
|
194
|
+
bufferAscii(head, 0, 6) === 'GIF89a' ||
|
|
195
|
+
bufferStartsWith(head, [0x42, 0x4d]) || // bmp
|
|
196
|
+
bufferStartsWith(head, [0x00, 0x00, 0x01, 0x00]) || // ico
|
|
197
|
+
bufferAscii(head, 0, 3) === 'ID3' ||
|
|
198
|
+
bufferStartsWith(head, [0xff, 0xfb]) ||
|
|
199
|
+
bufferStartsWith(head, [0xff, 0xf3]) ||
|
|
200
|
+
bufferStartsWith(head, [0xff, 0xf2]) || // mp3
|
|
201
|
+
bufferAscii(head, 4, 4) === 'ftyp' || // mp4 / mov
|
|
202
|
+
(bufferAscii(head, 0, 4) === 'RIFF' && bufferAscii(head, 8, 4) === 'AVI ') ||
|
|
203
|
+
bufferStartsWith(head, [0x25, 0x50, 0x44, 0x46]) || // pdf
|
|
204
|
+
bufferStartsWith(head, [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]) || // old office
|
|
205
|
+
bufferAscii(head, 257, 5) === 'ustar'
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function throwBinary(file) {
|
|
210
|
+
throw new SkillhubUploadError(
|
|
211
|
+
'LOCAL_VALIDATION_FAILED',
|
|
212
|
+
`目录中包含不支持上传的文件:${file},请移除后重试`,
|
|
213
|
+
ExitCodes.LOCAL_VALIDATION
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function throwUnsafeZipEntry(file) {
|
|
218
|
+
throw new SkillhubUploadError(
|
|
219
|
+
'LOCAL_VALIDATION_FAILED',
|
|
220
|
+
`zip 中包含不安全路径:${file},请解压检查后重试`,
|
|
221
|
+
ExitCodes.LOCAL_VALIDATION
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function throwOversized(file) {
|
|
226
|
+
throw new SkillhubUploadError(
|
|
227
|
+
'LOCAL_VALIDATION_FAILED',
|
|
228
|
+
`文件超过 10MB:${file},请压缩内容或移除后重试`,
|
|
229
|
+
ExitCodes.LOCAL_VALIDATION
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function hasForbiddenFileSignature(filePath) {
|
|
234
|
+
const handle = await fs.open(filePath, 'r');
|
|
235
|
+
try {
|
|
236
|
+
const buffer = Buffer.alloc(512);
|
|
237
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
238
|
+
return hasForbiddenSignature(buffer.subarray(0, bytesRead));
|
|
239
|
+
} finally {
|
|
240
|
+
await handle.close();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function validateSourceFile(root, file) {
|
|
245
|
+
const actual = path.join(root, file);
|
|
246
|
+
const stat = await fs.stat(actual);
|
|
247
|
+
if (stat.size > SINGLE_FILE_MAX_SIZE) {
|
|
248
|
+
throwOversized(file);
|
|
249
|
+
}
|
|
250
|
+
if (BINARY_EXTENSIONS.has(path.extname(file).toLowerCase())) {
|
|
251
|
+
throwBinary(file);
|
|
252
|
+
}
|
|
253
|
+
if (await hasForbiddenFileSignature(actual)) {
|
|
254
|
+
throwBinary(file);
|
|
255
|
+
}
|
|
256
|
+
return stat.size;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function validateAllowedExtension(file) {
|
|
260
|
+
const ext = path.extname(file).toLowerCase();
|
|
261
|
+
if (!ALLOWED_EXTENSIONS.has(ext) || BINARY_EXTENSIONS.has(ext)) {
|
|
262
|
+
throwBinary(file);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function isAllowedExtension(file) {
|
|
267
|
+
return ALLOWED_EXTENSIONS.has(path.extname(file).toLowerCase());
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function createTempZipPath(env) {
|
|
271
|
+
const dir = await fs.mkdtemp(path.join(getTmpDir(env), `bundle-${process.pid}-`));
|
|
272
|
+
return path.join(dir, `${crypto.randomUUID()}.zip`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function normalizeEntryPath(file) {
|
|
276
|
+
return file.split(path.sep).join('/');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function normalizeSkillEntryName(file) {
|
|
280
|
+
return file === 'skill.md' ? 'SKILL.md' : normalizeEntryPath(file);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function resolveRootSkillPath(inputPath) {
|
|
284
|
+
for (const name of ROOT_SKILL_FILES) {
|
|
285
|
+
const skillPath = path.join(inputPath, name);
|
|
286
|
+
const stat = await fs.lstat(skillPath).catch(() => null);
|
|
287
|
+
if (!stat) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (stat.isSymbolicLink()) {
|
|
291
|
+
throw new SkillhubUploadError('LOCAL_VALIDATION_FAILED', `${name} 是符号链接,请改为普通文件后重试`, ExitCodes.LOCAL_VALIDATION);
|
|
292
|
+
}
|
|
293
|
+
if (!stat.isFile()) {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
return skillPath;
|
|
297
|
+
}
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function prepareDirectorySource(inputPath) {
|
|
302
|
+
const skillPath = await resolveRootSkillPath(inputPath);
|
|
303
|
+
if (!skillPath) {
|
|
304
|
+
throw new SkillhubUploadError('LOCAL_VALIDATION_FAILED', '目录根目录缺少 SKILL.md,请确认输入的是 skill 目录', ExitCodes.LOCAL_VALIDATION);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const files = await walkDirectory(inputPath);
|
|
308
|
+
let totalSize = 0;
|
|
309
|
+
for (const file of files) {
|
|
310
|
+
validateAllowedExtension(file);
|
|
311
|
+
totalSize += await validateSourceFile(inputPath, file);
|
|
312
|
+
if (totalSize > TOTAL_MAX_SIZE) {
|
|
313
|
+
throw new SkillhubUploadError(
|
|
314
|
+
'LOCAL_VALIDATION_FAILED',
|
|
315
|
+
'skill 目录总大小不能超过 30MB,请精简后重试',
|
|
316
|
+
ExitCodes.LOCAL_VALIDATION
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const markdown = await fs.readFile(skillPath, 'utf8');
|
|
322
|
+
return {
|
|
323
|
+
files: await Promise.all(files.map(async (file) => ({
|
|
324
|
+
entryName: normalizeSkillEntryName(file),
|
|
325
|
+
data: await fs.readFile(path.join(inputPath, file))
|
|
326
|
+
}))),
|
|
327
|
+
markdown,
|
|
328
|
+
sourcePathBaseName: path.basename(inputPath)
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function isIgnoredZipEntry(rawName) {
|
|
333
|
+
const name = rawName.replace(/^\/+/, '');
|
|
334
|
+
const parts = name.split('/').filter(Boolean);
|
|
335
|
+
const baseName = parts.at(-1) || '';
|
|
336
|
+
return parts.includes('__MACOSX') || baseName.startsWith('.') || baseName === '.DS_Store';
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function assertSafeZipEntryName(rawName) {
|
|
340
|
+
const normalized = rawName.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
341
|
+
if (
|
|
342
|
+
!normalized
|
|
343
|
+
|| path.posix.isAbsolute(rawName.replace(/\\/g, '/'))
|
|
344
|
+
|| /^[A-Za-z]:/.test(rawName)
|
|
345
|
+
|| normalized.split('/').some((part) => part === '..')
|
|
346
|
+
) {
|
|
347
|
+
throwUnsafeZipEntry(rawName);
|
|
348
|
+
}
|
|
349
|
+
return normalized;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function stripCommonDirectoryPrefix(paths) {
|
|
353
|
+
if (paths.length === 0) {
|
|
354
|
+
return '';
|
|
355
|
+
}
|
|
356
|
+
const segmentsList = paths.map((item) => item.split('/'));
|
|
357
|
+
const first = segmentsList[0][0];
|
|
358
|
+
if (!first || segmentsList.some((segments) => segments.length < 2 || segments[0] !== first)) {
|
|
359
|
+
return '';
|
|
360
|
+
}
|
|
361
|
+
return `${first}/`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function findRootSkillEntry(files) {
|
|
365
|
+
return files.find((file) => ROOT_SKILL_FILES.includes(file.entryName));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function validateSize(file, size) {
|
|
369
|
+
if (size > SINGLE_FILE_MAX_SIZE) {
|
|
370
|
+
throwOversized(file);
|
|
371
|
+
}
|
|
372
|
+
return size;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function prepareZipSource(inputPath) {
|
|
376
|
+
const zip = new AdmZip(inputPath);
|
|
377
|
+
const rawFiles = [];
|
|
378
|
+
|
|
379
|
+
for (const entry of zip.getEntries()) {
|
|
380
|
+
if (entry.isDirectory) {
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
const safeName = assertSafeZipEntryName(entry.entryName);
|
|
384
|
+
if (isIgnoredZipEntry(safeName)) {
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
rawFiles.push({ entry, safeName });
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const commonPrefix = stripCommonDirectoryPrefix(rawFiles.map((file) => file.safeName));
|
|
391
|
+
const files = [];
|
|
392
|
+
const seen = new Set();
|
|
393
|
+
let totalSize = 0;
|
|
394
|
+
for (const file of rawFiles) {
|
|
395
|
+
const entryName = file.safeName.slice(commonPrefix.length);
|
|
396
|
+
if (!entryName || isIgnoredZipEntry(entryName)) {
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
const ext = path.extname(entryName).toLowerCase();
|
|
400
|
+
if (ARCHIVE_EXTENSIONS.has(ext)) {
|
|
401
|
+
throwBinary(entryName);
|
|
402
|
+
}
|
|
403
|
+
if (!isAllowedExtension(entryName)) {
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const data = file.entry.getData();
|
|
408
|
+
const size = Math.max(Number(file.entry.header?.size ?? 0), data.length);
|
|
409
|
+
totalSize += validateSize(entryName, size);
|
|
410
|
+
if (totalSize > TOTAL_MAX_SIZE) {
|
|
411
|
+
throw new SkillhubUploadError(
|
|
412
|
+
'LOCAL_VALIDATION_FAILED',
|
|
413
|
+
'skill 目录总大小不能超过 30MB,请精简后重试',
|
|
414
|
+
ExitCodes.LOCAL_VALIDATION
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (hasForbiddenSignature(data)) {
|
|
419
|
+
throwBinary(entryName);
|
|
420
|
+
}
|
|
421
|
+
const normalizedEntryName = entryName === 'skill.md' ? 'SKILL.md' : entryName;
|
|
422
|
+
if (seen.has(normalizedEntryName)) {
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
seen.add(normalizedEntryName);
|
|
426
|
+
files.push({
|
|
427
|
+
entryName: normalizedEntryName,
|
|
428
|
+
data
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const skillEntry = findRootSkillEntry(files);
|
|
433
|
+
if (!skillEntry) {
|
|
434
|
+
throw new SkillhubUploadError('LOCAL_VALIDATION_FAILED', 'zip 根目录缺少 SKILL.md,请确认输入的是 skill 包', ExitCodes.LOCAL_VALIDATION);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
files,
|
|
439
|
+
markdown: skillEntry.data.toString('utf8'),
|
|
440
|
+
sourcePathBaseName: path.basename(inputPath, path.extname(inputPath))
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function buildBundle(source, env) {
|
|
445
|
+
const parsed = parseSkillMarkdown(source.markdown);
|
|
446
|
+
const zip = new AdmZip();
|
|
447
|
+
for (const file of source.files) {
|
|
448
|
+
zip.addFile(file.entryName, file.data);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const zipPath = await createTempZipPath(env);
|
|
452
|
+
zip.writeZip(zipPath);
|
|
453
|
+
const zipStat = await fs.stat(zipPath);
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
zipPath,
|
|
457
|
+
bundleSha256: await sha256File(zipPath),
|
|
458
|
+
bundleSizeBytes: zipStat.size,
|
|
459
|
+
metadata: {
|
|
460
|
+
name: parsed.frontmatter.name || '',
|
|
461
|
+
description: parsed.frontmatter.description || '',
|
|
462
|
+
version: parsed.frontmatter.version || '',
|
|
463
|
+
detail: parsed.detail,
|
|
464
|
+
sourcePathBaseName: source.sourcePathBaseName
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export async function prepareBundle(inputPath, options = {}) {
|
|
470
|
+
const env = options.env || process.env;
|
|
471
|
+
const stat = await fs.lstat(inputPath).catch(() => null);
|
|
472
|
+
if (!stat) {
|
|
473
|
+
throw new SkillhubUploadError('LOCAL_VALIDATION_FAILED', `路径不存在:${inputPath},请输入正确的 skill 目录或 zip 文件`, ExitCodes.LOCAL_VALIDATION);
|
|
474
|
+
}
|
|
475
|
+
if (stat.isSymbolicLink()) {
|
|
476
|
+
throw new SkillhubUploadError('LOCAL_VALIDATION_FAILED', '输入路径是符号链接,请输入真实的 skill 目录或 zip 文件', ExitCodes.LOCAL_VALIDATION);
|
|
477
|
+
}
|
|
478
|
+
await fs.mkdir(getTmpDir(env), { recursive: true, mode: 0o700 });
|
|
479
|
+
|
|
480
|
+
let source;
|
|
481
|
+
if (stat.isDirectory()) {
|
|
482
|
+
source = await prepareDirectorySource(inputPath);
|
|
483
|
+
} else if (stat.isFile() && path.extname(inputPath).toLowerCase() === '.zip') {
|
|
484
|
+
source = await prepareZipSource(inputPath);
|
|
485
|
+
} else {
|
|
486
|
+
throw new SkillhubUploadError(
|
|
487
|
+
'LOCAL_VALIDATION_FAILED',
|
|
488
|
+
'请输入 skill 目录或 zip 文件',
|
|
489
|
+
ExitCodes.LOCAL_VALIDATION
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return await buildBundle(source, env);
|
|
494
|
+
}
|
package/cli/prompt.mjs
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
import { writePrompt } from './output.mjs';
|
|
3
|
+
|
|
4
|
+
function createRl(input) {
|
|
5
|
+
return readline.createInterface({ input, crlfDelay: Infinity });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
async function readLine(iterator) {
|
|
9
|
+
const next = await iterator.next();
|
|
10
|
+
return next.done ? '' : String(next.value).trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createPromptSession(io = {}) {
|
|
14
|
+
const input = io.input || process.stdin;
|
|
15
|
+
const out = io.out || process.stdout;
|
|
16
|
+
const rl = createRl(input);
|
|
17
|
+
const iterator = rl[Symbol.asyncIterator]();
|
|
18
|
+
return {
|
|
19
|
+
input,
|
|
20
|
+
out,
|
|
21
|
+
readLine: () => readLine(iterator),
|
|
22
|
+
close: () => rl.close()
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function askAgent(prompt, io = {}) {
|
|
27
|
+
const input = io.input || process.stdin;
|
|
28
|
+
const out = io.out || process.stdout;
|
|
29
|
+
writePrompt(prompt, out);
|
|
30
|
+
|
|
31
|
+
if (io.readLine) {
|
|
32
|
+
return io.readLine();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const rl = createRl(input);
|
|
36
|
+
try {
|
|
37
|
+
return await readLine(rl[Symbol.asyncIterator]());
|
|
38
|
+
} finally {
|
|
39
|
+
rl.close();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function confirmAgent(payload, io = {}) {
|
|
44
|
+
const input = io.input || process.stdin;
|
|
45
|
+
const out = io.out || process.stdout;
|
|
46
|
+
writePrompt({ type: 'confirm', payload }, out);
|
|
47
|
+
|
|
48
|
+
if (io.readLine) {
|
|
49
|
+
const first = await io.readLine();
|
|
50
|
+
if (first === 'submit') {
|
|
51
|
+
return { action: 'submit' };
|
|
52
|
+
}
|
|
53
|
+
if (first === 'cancel') {
|
|
54
|
+
return { action: 'cancel' };
|
|
55
|
+
}
|
|
56
|
+
if (first === 'edit') {
|
|
57
|
+
const values = {};
|
|
58
|
+
while (true) {
|
|
59
|
+
const trimmed = await io.readLine();
|
|
60
|
+
if (!trimmed) {
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const idx = trimmed.indexOf('=');
|
|
65
|
+
if (idx < 0) {
|
|
66
|
+
throw new Error(`确认信息修改格式不正确,请使用 key=value 格式:${trimmed}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const key = trimmed.slice(0, idx).trim();
|
|
70
|
+
if (!key) {
|
|
71
|
+
throw new Error(`确认信息修改格式不正确,请使用 key=value 格式:${trimmed}`);
|
|
72
|
+
}
|
|
73
|
+
values[key] = trimmed.slice(idx + 1).trim();
|
|
74
|
+
}
|
|
75
|
+
return { action: 'edit', values };
|
|
76
|
+
}
|
|
77
|
+
return { action: first || 'cancel' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const rl = createRl(input);
|
|
81
|
+
try {
|
|
82
|
+
const iterator = rl[Symbol.asyncIterator]();
|
|
83
|
+
const first = await readLine(iterator);
|
|
84
|
+
if (first === 'submit') {
|
|
85
|
+
return { action: 'submit' };
|
|
86
|
+
}
|
|
87
|
+
if (first === 'cancel') {
|
|
88
|
+
return { action: 'cancel' };
|
|
89
|
+
}
|
|
90
|
+
if (first === 'edit') {
|
|
91
|
+
const values = {};
|
|
92
|
+
for await (const line of iterator) {
|
|
93
|
+
const trimmed = String(line).trim();
|
|
94
|
+
if (!trimmed) {
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const idx = trimmed.indexOf('=');
|
|
99
|
+
if (idx < 0) {
|
|
100
|
+
throw new Error(`确认信息修改格式不正确,请使用 key=value 格式:${trimmed}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const key = trimmed.slice(0, idx).trim();
|
|
104
|
+
if (!key) {
|
|
105
|
+
throw new Error(`确认信息修改格式不正确,请使用 key=value 格式:${trimmed}`);
|
|
106
|
+
}
|
|
107
|
+
values[key] = trimmed.slice(idx + 1).trim();
|
|
108
|
+
}
|
|
109
|
+
return { action: 'edit', values };
|
|
110
|
+
}
|
|
111
|
+
return { action: first || 'cancel' };
|
|
112
|
+
} finally {
|
|
113
|
+
rl.close();
|
|
114
|
+
}
|
|
115
|
+
}
|
package/cli/redact.mjs
ADDED