@open-skills-hub/cli 1.0.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/dist/commands/cache.d.ts +6 -0
- package/dist/commands/cache.d.ts.map +1 -0
- package/dist/commands/cache.js +145 -0
- package/dist/commands/cache.js.map +1 -0
- package/dist/commands/config.d.ts +6 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +128 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/create.d.ts +7 -0
- package/dist/commands/create.d.ts.map +1 -0
- package/dist/commands/create.js +449 -0
- package/dist/commands/create.js.map +1 -0
- package/dist/commands/feedback.d.ts +6 -0
- package/dist/commands/feedback.d.ts.map +1 -0
- package/dist/commands/feedback.js +137 -0
- package/dist/commands/feedback.js.map +1 -0
- package/dist/commands/get.d.ts +6 -0
- package/dist/commands/get.d.ts.map +1 -0
- package/dist/commands/get.js +122 -0
- package/dist/commands/get.js.map +1 -0
- package/dist/commands/index.d.ts +13 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/index.js +13 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/commands/publish.d.ts +7 -0
- package/dist/commands/publish.d.ts.map +1 -0
- package/dist/commands/publish.js +593 -0
- package/dist/commands/publish.js.map +1 -0
- package/dist/commands/scan.d.ts +6 -0
- package/dist/commands/scan.d.ts.map +1 -0
- package/dist/commands/scan.js +165 -0
- package/dist/commands/scan.js.map +1 -0
- package/dist/commands/search.d.ts +6 -0
- package/dist/commands/search.d.ts.map +1 -0
- package/dist/commands/search.js +80 -0
- package/dist/commands/search.js.map +1 -0
- package/dist/commands/validate.d.ts +7 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/commands/validate.js +328 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +107 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
- package/src/commands/cache.ts +166 -0
- package/src/commands/config.ts +142 -0
- package/src/commands/create.ts +490 -0
- package/src/commands/feedback.ts +161 -0
- package/src/commands/get.ts +141 -0
- package/src/commands/index.ts +13 -0
- package/src/commands/publish.ts +688 -0
- package/src/commands/scan.ts +190 -0
- package/src/commands/search.ts +92 -0
- package/src/commands/validate.ts +391 -0
- package/src/index.ts +118 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open Skills Hub CLI - Publish Command
|
|
3
|
+
* Supports both single file and directory publishing
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Command } from 'commander';
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
import ora from 'ora';
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
import { parse as parseYaml } from 'yaml';
|
|
12
|
+
import inquirer from 'inquirer';
|
|
13
|
+
import {
|
|
14
|
+
getStorage,
|
|
15
|
+
getConfig,
|
|
16
|
+
initScanner,
|
|
17
|
+
getScanner,
|
|
18
|
+
generateUUID,
|
|
19
|
+
now,
|
|
20
|
+
sha256,
|
|
21
|
+
buildSkillFullName,
|
|
22
|
+
isValidSkillName,
|
|
23
|
+
isValidSemver,
|
|
24
|
+
} from '@open-skills-hub/core';
|
|
25
|
+
import type { Skill, Version, SkillContent, SkillFile, AuditLog } from '@open-skills-hub/core';
|
|
26
|
+
|
|
27
|
+
interface ParsedSkillFile {
|
|
28
|
+
frontmatter: Record<string, unknown>;
|
|
29
|
+
markdown: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// File extensions to include (text-based files)
|
|
33
|
+
const TEXT_EXTENSIONS = new Set([
|
|
34
|
+
'.md', '.txt', '.py', '.js', '.ts', '.sh', '.bash', '.zsh',
|
|
35
|
+
'.json', '.yaml', '.yml', '.xml', '.xsd', '.html', '.css',
|
|
36
|
+
'.sql', '.r', '.rb', '.pl', '.lua', '.go', '.rs', '.java',
|
|
37
|
+
'.c', '.cpp', '.h', '.hpp', '.swift', '.kt', '.scala',
|
|
38
|
+
'.template', '.tpl', '.cfg', '.conf', '.ini', '.env',
|
|
39
|
+
'.csv', '.tsv', '.log',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
// Binary extensions to include (will be base64 encoded)
|
|
43
|
+
const BINARY_EXTENSIONS = new Set([
|
|
44
|
+
'.ttf', '.otf', '.woff', '.woff2',
|
|
45
|
+
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.webp',
|
|
46
|
+
'.pdf', '.zip', '.tar', '.gz',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
// Directories to skip
|
|
50
|
+
const SKIP_DIRS = new Set([
|
|
51
|
+
'node_modules', '.git', '.svn', '__pycache__', '.DS_Store',
|
|
52
|
+
'dist', 'build', '.cache', '.vscode', '.idea',
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
// Files to skip
|
|
56
|
+
const SKIP_FILES = new Set([
|
|
57
|
+
'.DS_Store', 'Thumbs.db', '.gitignore', '.npmignore',
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
function parseSkillFile(content: string): ParsedSkillFile {
|
|
61
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
62
|
+
|
|
63
|
+
if (frontmatterMatch && frontmatterMatch[1] && frontmatterMatch[2] !== undefined) {
|
|
64
|
+
return {
|
|
65
|
+
frontmatter: parseYaml(frontmatterMatch[1]) as Record<string, unknown>,
|
|
66
|
+
markdown: frontmatterMatch[2].trim(),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
frontmatter: {},
|
|
72
|
+
markdown: content.trim(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isTextFile(filePath: string): boolean {
|
|
77
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
78
|
+
return TEXT_EXTENSIONS.has(ext);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isBinaryFile(filePath: string): boolean {
|
|
82
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
83
|
+
return BINARY_EXTENSIONS.has(ext);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function shouldIncludeFile(filePath: string): boolean {
|
|
87
|
+
const basename = path.basename(filePath);
|
|
88
|
+
if (SKIP_FILES.has(basename)) return false;
|
|
89
|
+
return isTextFile(filePath) || isBinaryFile(filePath);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function shouldSkipDir(dirName: string): boolean {
|
|
93
|
+
return SKIP_DIRS.has(dirName);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface CollectedFiles {
|
|
97
|
+
skillMd: { path: string; content: string } | null;
|
|
98
|
+
files: SkillFile[];
|
|
99
|
+
totalSize: number;
|
|
100
|
+
stats: {
|
|
101
|
+
scripts: number;
|
|
102
|
+
references: number;
|
|
103
|
+
assets: number;
|
|
104
|
+
other: number;
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function collectFiles(dirPath: string, basePath: string = dirPath): CollectedFiles {
|
|
109
|
+
const result: CollectedFiles = {
|
|
110
|
+
skillMd: null,
|
|
111
|
+
files: [],
|
|
112
|
+
totalSize: 0,
|
|
113
|
+
stats: { scripts: 0, references: 0, assets: 0, other: 0 },
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
117
|
+
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
120
|
+
const relativePath = path.relative(basePath, fullPath);
|
|
121
|
+
|
|
122
|
+
if (entry.isDirectory()) {
|
|
123
|
+
if (shouldSkipDir(entry.name)) continue;
|
|
124
|
+
|
|
125
|
+
// Recursively collect files
|
|
126
|
+
const subResult = collectFiles(fullPath, basePath);
|
|
127
|
+
result.files.push(...subResult.files);
|
|
128
|
+
result.totalSize += subResult.totalSize;
|
|
129
|
+
|
|
130
|
+
// Merge stats
|
|
131
|
+
result.stats.scripts += subResult.stats.scripts;
|
|
132
|
+
result.stats.references += subResult.stats.references;
|
|
133
|
+
result.stats.assets += subResult.stats.assets;
|
|
134
|
+
result.stats.other += subResult.stats.other;
|
|
135
|
+
|
|
136
|
+
if (subResult.skillMd && !result.skillMd) {
|
|
137
|
+
result.skillMd = subResult.skillMd;
|
|
138
|
+
}
|
|
139
|
+
} else if (entry.isFile()) {
|
|
140
|
+
// Handle SKILL.md specially
|
|
141
|
+
if (entry.name.toUpperCase() === 'SKILL.MD') {
|
|
142
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
143
|
+
result.skillMd = { path: relativePath, content };
|
|
144
|
+
result.totalSize += content.length;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Skip files we don't want to include
|
|
149
|
+
if (!shouldIncludeFile(entry.name)) continue;
|
|
150
|
+
|
|
151
|
+
const stat = fs.statSync(fullPath);
|
|
152
|
+
|
|
153
|
+
// Skip files larger than 10MB
|
|
154
|
+
if (stat.size > 10 * 1024 * 1024) {
|
|
155
|
+
console.log(chalk.yellow(` ⚠ Skipping large file: ${relativePath} (${(stat.size / 1024 / 1024).toFixed(2)} MB)`));
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let content: string;
|
|
160
|
+
if (isTextFile(fullPath)) {
|
|
161
|
+
content = fs.readFileSync(fullPath, 'utf-8');
|
|
162
|
+
} else if (isBinaryFile(fullPath)) {
|
|
163
|
+
// Base64 encode binary files
|
|
164
|
+
const buffer = fs.readFileSync(fullPath);
|
|
165
|
+
content = `data:${getMimeType(fullPath)};base64,${buffer.toString('base64')}`;
|
|
166
|
+
} else {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
result.files.push({
|
|
171
|
+
path: relativePath,
|
|
172
|
+
content,
|
|
173
|
+
size: stat.size,
|
|
174
|
+
});
|
|
175
|
+
result.totalSize += stat.size;
|
|
176
|
+
|
|
177
|
+
// Update stats
|
|
178
|
+
if (relativePath.startsWith('scripts/') || relativePath.startsWith('scripts\\')) {
|
|
179
|
+
result.stats.scripts++;
|
|
180
|
+
} else if (relativePath.startsWith('references/') || relativePath.startsWith('references\\') ||
|
|
181
|
+
relativePath.startsWith('reference/') || relativePath.startsWith('reference\\')) {
|
|
182
|
+
result.stats.references++;
|
|
183
|
+
} else if (relativePath.startsWith('assets/') || relativePath.startsWith('assets\\')) {
|
|
184
|
+
result.stats.assets++;
|
|
185
|
+
} else {
|
|
186
|
+
result.stats.other++;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function getMimeType(filePath: string): string {
|
|
195
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
196
|
+
const mimeTypes: Record<string, string> = {
|
|
197
|
+
'.ttf': 'font/ttf',
|
|
198
|
+
'.otf': 'font/otf',
|
|
199
|
+
'.woff': 'font/woff',
|
|
200
|
+
'.woff2': 'font/woff2',
|
|
201
|
+
'.png': 'image/png',
|
|
202
|
+
'.jpg': 'image/jpeg',
|
|
203
|
+
'.jpeg': 'image/jpeg',
|
|
204
|
+
'.gif': 'image/gif',
|
|
205
|
+
'.svg': 'image/svg+xml',
|
|
206
|
+
'.webp': 'image/webp',
|
|
207
|
+
'.ico': 'image/x-icon',
|
|
208
|
+
'.pdf': 'application/pdf',
|
|
209
|
+
'.zip': 'application/zip',
|
|
210
|
+
};
|
|
211
|
+
return mimeTypes[ext] || 'application/octet-stream';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function formatSize(bytes: number): string {
|
|
215
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
216
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
217
|
+
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export const publishCommand = new Command('publish')
|
|
221
|
+
.description('Publish a skill to the hub (supports both file and directory)')
|
|
222
|
+
.argument('<path>', 'Path to skill file (.md) or directory containing SKILL.md')
|
|
223
|
+
.option('-n, --name <name>', 'Skill name (overrides frontmatter)')
|
|
224
|
+
.option('-v, --version <version>', 'Version (default: 1.0.0 for new, auto-increment for updates)')
|
|
225
|
+
.option('-s, --scope <scope>', 'Scope/namespace (e.g., @myorg)')
|
|
226
|
+
.option('-a, --author <author>', 'Author name or organization (overrides frontmatter)')
|
|
227
|
+
.option('--category <category>', 'Skill category')
|
|
228
|
+
.option('--keywords <keywords>', 'Comma-separated keywords')
|
|
229
|
+
.option('--visibility <visibility>', 'Visibility: public, private, unlisted', 'public')
|
|
230
|
+
.option('--skip-scan', 'Skip security scan')
|
|
231
|
+
.option('--dry-run', 'Validate without publishing')
|
|
232
|
+
.option('-y, --yes', 'Skip confirmation prompts')
|
|
233
|
+
.action(async (inputPath, options) => {
|
|
234
|
+
const spinner = ora('Reading skill...').start();
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const resolvedPath = path.resolve(inputPath);
|
|
238
|
+
|
|
239
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
240
|
+
spinner.fail(`Path not found: ${resolvedPath}`);
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const stat = fs.statSync(resolvedPath);
|
|
245
|
+
const isDirectory = stat.isDirectory();
|
|
246
|
+
|
|
247
|
+
let skillMdContent: string;
|
|
248
|
+
let collectedFiles: SkillFile[] = [];
|
|
249
|
+
let totalSize = 0;
|
|
250
|
+
let fileStats = { scripts: 0, references: 0, assets: 0, other: 0 };
|
|
251
|
+
let skillDirName: string;
|
|
252
|
+
|
|
253
|
+
if (isDirectory) {
|
|
254
|
+
// Directory mode: collect all files
|
|
255
|
+
spinner.text = 'Reading directory...';
|
|
256
|
+
skillDirName = path.basename(resolvedPath);
|
|
257
|
+
|
|
258
|
+
const collected = collectFiles(resolvedPath);
|
|
259
|
+
|
|
260
|
+
if (!collected.skillMd) {
|
|
261
|
+
spinner.fail('SKILL.md not found in directory');
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
skillMdContent = collected.skillMd.content;
|
|
266
|
+
collectedFiles = collected.files;
|
|
267
|
+
totalSize = collected.totalSize;
|
|
268
|
+
fileStats = collected.stats;
|
|
269
|
+
|
|
270
|
+
spinner.succeed(`Found SKILL.md and ${collected.files.length} additional files`);
|
|
271
|
+
|
|
272
|
+
if (collected.files.length > 0) {
|
|
273
|
+
console.log(chalk.gray(` ├── scripts: ${fileStats.scripts} files`));
|
|
274
|
+
console.log(chalk.gray(` ├── references: ${fileStats.references} files`));
|
|
275
|
+
console.log(chalk.gray(` ├── assets: ${fileStats.assets} files`));
|
|
276
|
+
if (fileStats.other > 0) {
|
|
277
|
+
console.log(chalk.gray(` └── other: ${fileStats.other} files`));
|
|
278
|
+
}
|
|
279
|
+
console.log(chalk.gray(` Total size: ${formatSize(totalSize)}`));
|
|
280
|
+
}
|
|
281
|
+
} else {
|
|
282
|
+
// Single file mode
|
|
283
|
+
skillDirName = path.basename(path.dirname(resolvedPath));
|
|
284
|
+
skillMdContent = fs.readFileSync(resolvedPath, 'utf-8');
|
|
285
|
+
totalSize = skillMdContent.length;
|
|
286
|
+
spinner.succeed('Read skill file');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Parse SKILL.md
|
|
290
|
+
const parsed = parseSkillFile(skillMdContent);
|
|
291
|
+
|
|
292
|
+
// Extract metadata
|
|
293
|
+
const skillName = options.name ?? (parsed.frontmatter['name'] as string) ?? skillDirName;
|
|
294
|
+
|
|
295
|
+
if (!skillName) {
|
|
296
|
+
spinner.fail('Skill name is required. Use --name or add "name" to frontmatter.');
|
|
297
|
+
process.exit(1);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (!isValidSkillName(skillName)) {
|
|
301
|
+
spinner.fail(`Invalid skill name: ${skillName}. Use lowercase letters, numbers, and hyphens.`);
|
|
302
|
+
process.exit(1);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Validate name matches directory
|
|
306
|
+
if (isDirectory && skillName !== skillDirName) {
|
|
307
|
+
console.log(chalk.yellow(` ⚠ Warning: name "${skillName}" doesn't match directory "${skillDirName}"`));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Extract and validate author
|
|
311
|
+
const author = options.author
|
|
312
|
+
?? (parsed.frontmatter['author'] as string)
|
|
313
|
+
?? (parsed.frontmatter['metadata'] as Record<string, unknown>)?.[' author'] as string;
|
|
314
|
+
|
|
315
|
+
if (!author) {
|
|
316
|
+
spinner.fail(chalk.red('Author is required!'));
|
|
317
|
+
console.log(chalk.yellow('\n📝 Please provide author information:'));
|
|
318
|
+
console.log(chalk.gray(' Option 1: Add to SKILL.md frontmatter:'));
|
|
319
|
+
console.log(chalk.cyan(' ---'));
|
|
320
|
+
console.log(chalk.cyan(' name: your-skill'));
|
|
321
|
+
console.log(chalk.cyan(' description: ...'));
|
|
322
|
+
console.log(chalk.cyan(' author: Your Name'));
|
|
323
|
+
console.log(chalk.cyan(' ---'));
|
|
324
|
+
console.log(chalk.gray('\n Option 2: Use command line flag:'));
|
|
325
|
+
console.log(chalk.cyan(' skills publish ./your-skill --author "Your Name"'));
|
|
326
|
+
process.exit(1);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const scope = options.scope?.replace(/^@/, '');
|
|
330
|
+
const fullName = buildSkillFullName(skillName, scope);
|
|
331
|
+
|
|
332
|
+
const prepareSpinner = ora(`Preparing ${fullName}...`).start();
|
|
333
|
+
|
|
334
|
+
// Initialize storage
|
|
335
|
+
const storage = await getStorage();
|
|
336
|
+
await storage.initialize();
|
|
337
|
+
const config = getConfig();
|
|
338
|
+
|
|
339
|
+
// Check if skill exists
|
|
340
|
+
const existingSkill = await storage.getSkillByName(fullName);
|
|
341
|
+
const isUpdate = !!existingSkill;
|
|
342
|
+
|
|
343
|
+
// Determine version
|
|
344
|
+
let version = options.version ?? (parsed.frontmatter['version'] as string);
|
|
345
|
+
if (!version) {
|
|
346
|
+
if (isUpdate) {
|
|
347
|
+
// Auto-increment patch version
|
|
348
|
+
const [major, minor, patch] = existingSkill.latestVersion.split('.').map(Number);
|
|
349
|
+
version = `${major}.${minor}.${(patch ?? 0) + 1}`;
|
|
350
|
+
} else {
|
|
351
|
+
version = '1.0.0';
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (!isValidSemver(version)) {
|
|
356
|
+
prepareSpinner.fail(`Invalid version: ${version}. Use semantic versioning (e.g., 1.0.0).`);
|
|
357
|
+
process.exit(1);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Check version doesn't exist
|
|
361
|
+
if (isUpdate) {
|
|
362
|
+
const existingVersion = await storage.getVersion(existingSkill.id, version);
|
|
363
|
+
if (existingVersion) {
|
|
364
|
+
prepareSpinner.fail(`Version ${version} already exists for ${fullName}.`);
|
|
365
|
+
process.exit(1);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
prepareSpinner.succeed(`Prepared ${fullName}@${version} (${isUpdate ? 'new version' : 'new skill'})`);
|
|
370
|
+
|
|
371
|
+
// Security scan
|
|
372
|
+
let scanResult: Awaited<ReturnType<ReturnType<typeof getScanner>['scan']>> | undefined;
|
|
373
|
+
|
|
374
|
+
if (!options.skipScan) {
|
|
375
|
+
const scanSpinner = ora('Running security scan...').start();
|
|
376
|
+
|
|
377
|
+
initScanner({
|
|
378
|
+
enabled: config.get().scanner.enabled,
|
|
379
|
+
timeout: config.get().scanner.timeout,
|
|
380
|
+
maxFileSize: config.get().scanner.maxFileSize,
|
|
381
|
+
});
|
|
382
|
+
const scanner = getScanner();
|
|
383
|
+
|
|
384
|
+
// Scan SKILL.md content
|
|
385
|
+
scanResult = await scanner.scan(skillMdContent);
|
|
386
|
+
|
|
387
|
+
// Also scan script files if any
|
|
388
|
+
for (const file of collectedFiles) {
|
|
389
|
+
if (file.path.startsWith('scripts/') && (file.path.endsWith('.py') || file.path.endsWith('.sh') || file.path.endsWith('.js'))) {
|
|
390
|
+
const fileScanResult = await scanner.scan(file.content);
|
|
391
|
+
scanResult.issues.push(...fileScanResult.issues);
|
|
392
|
+
scanResult.summary.score = Math.min(scanResult.summary.score, fileScanResult.summary.score);
|
|
393
|
+
if (fileScanResult.summary.level === 'high') {
|
|
394
|
+
scanResult.summary.level = 'high';
|
|
395
|
+
} else if (fileScanResult.summary.level === 'medium' && scanResult.summary.level !== 'high') {
|
|
396
|
+
scanResult.summary.level = 'medium';
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Determine level color
|
|
402
|
+
const levelColor = {
|
|
403
|
+
safe: chalk.green,
|
|
404
|
+
low: chalk.blue,
|
|
405
|
+
medium: chalk.yellow,
|
|
406
|
+
high: chalk.red,
|
|
407
|
+
}[scanResult.summary.level] ?? chalk.white;
|
|
408
|
+
|
|
409
|
+
// Show scan result
|
|
410
|
+
if (scanResult.summary.level === 'high') {
|
|
411
|
+
scanSpinner.fail(`Security scan: ${levelColor(scanResult.summary.level)} (score: ${scanResult.summary.score})`);
|
|
412
|
+
} else {
|
|
413
|
+
scanSpinner.succeed(`Security scan: ${levelColor(scanResult.summary.level)} (score: ${scanResult.summary.score})`);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Show issue details if any issues found
|
|
417
|
+
if (scanResult.issues.length > 0) {
|
|
418
|
+
// Count issues by severity
|
|
419
|
+
const highCount = scanResult.issues.filter(i => i.severity === 'high').length;
|
|
420
|
+
const mediumCount = scanResult.issues.filter(i => i.severity === 'medium').length;
|
|
421
|
+
const lowCount = scanResult.issues.filter(i => i.severity === 'low').length;
|
|
422
|
+
|
|
423
|
+
const parts: string[] = [];
|
|
424
|
+
if (highCount > 0) parts.push(`${highCount} high`);
|
|
425
|
+
if (mediumCount > 0) parts.push(`${mediumCount} medium`);
|
|
426
|
+
if (lowCount > 0) parts.push(`${lowCount} low`);
|
|
427
|
+
|
|
428
|
+
const severityText = parts.length > 0 ? parts.join(', ') : 'none critical';
|
|
429
|
+
console.log(chalk.gray(` ${scanResult.issues.length} issue(s) found - ${severityText}\n`));
|
|
430
|
+
|
|
431
|
+
// Show detailed issues grouped by severity
|
|
432
|
+
if (highCount > 0) {
|
|
433
|
+
console.log(chalk.red.bold('High-Risk Issues:'));
|
|
434
|
+
for (const issue of scanResult.issues.filter(i => i.severity === 'high')) {
|
|
435
|
+
console.log(chalk.red(` • [${issue.ruleId}] ${issue.message}`));
|
|
436
|
+
console.log(chalk.gray(` Line ${issue.line}: ${issue.content}`));
|
|
437
|
+
if (issue.suggestion) {
|
|
438
|
+
console.log(chalk.yellow(` Suggestion: ${issue.suggestion}`));
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
console.log('');
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (mediumCount > 0) {
|
|
445
|
+
console.log(chalk.yellow.bold('Medium-Risk Issues:'));
|
|
446
|
+
for (const issue of scanResult.issues.filter(i => i.severity === 'medium')) {
|
|
447
|
+
console.log(chalk.yellow(` • [${issue.ruleId}] ${issue.message}`));
|
|
448
|
+
console.log(chalk.gray(` Line ${issue.line}: ${issue.content}`));
|
|
449
|
+
if (issue.suggestion) {
|
|
450
|
+
console.log(chalk.blue(` Suggestion: ${issue.suggestion}`));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
console.log('');
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (lowCount > 0) {
|
|
457
|
+
console.log(chalk.blue.bold('Low-Risk Issues:'));
|
|
458
|
+
for (const issue of scanResult.issues.filter(i => i.severity === 'low')) {
|
|
459
|
+
console.log(chalk.blue(` • [${issue.ruleId}] ${issue.message}`));
|
|
460
|
+
console.log(chalk.gray(` Line ${issue.line}: ${issue.content}`));
|
|
461
|
+
if (issue.suggestion) {
|
|
462
|
+
console.log(chalk.cyan(` Suggestion: ${issue.suggestion}`));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
console.log('');
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Ask for confirmation if high-risk issues found
|
|
470
|
+
if (scanResult.summary.level === 'high' && !options.yes) {
|
|
471
|
+
const { proceed } = await inquirer.prompt([{
|
|
472
|
+
type: 'confirm',
|
|
473
|
+
name: 'proceed',
|
|
474
|
+
message: 'Do you want to proceed despite high-risk security warnings?',
|
|
475
|
+
default: false,
|
|
476
|
+
}]);
|
|
477
|
+
|
|
478
|
+
if (!proceed) {
|
|
479
|
+
console.log(chalk.yellow('\nPublish cancelled.'));
|
|
480
|
+
process.exit(1);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Build skill content
|
|
486
|
+
const skillContent: SkillContent = {
|
|
487
|
+
frontmatter: {
|
|
488
|
+
name: skillName,
|
|
489
|
+
description: (parsed.frontmatter['description'] as string) ?? '',
|
|
490
|
+
allowedTools: parsed.frontmatter['allowedTools'] as string[] | undefined,
|
|
491
|
+
argumentHint: parsed.frontmatter['argumentHint'] as string | undefined,
|
|
492
|
+
disableModelInvocation: parsed.frontmatter['disableModelInvocation'] as boolean | undefined,
|
|
493
|
+
userInvocable: parsed.frontmatter['userInvocable'] as boolean | undefined,
|
|
494
|
+
model: parsed.frontmatter['model'] as string | undefined,
|
|
495
|
+
license: parsed.frontmatter['license'] as string | undefined,
|
|
496
|
+
compatibility: parsed.frontmatter['compatibility'] as string | undefined,
|
|
497
|
+
metadata: parsed.frontmatter['metadata'] as Record<string, string> | undefined,
|
|
498
|
+
},
|
|
499
|
+
markdown: parsed.markdown,
|
|
500
|
+
files: collectedFiles.length > 0 ? collectedFiles : undefined,
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
// Dry run check
|
|
504
|
+
if (options.dryRun) {
|
|
505
|
+
console.log(chalk.cyan('\n[Dry Run] Would publish:'));
|
|
506
|
+
console.log(` Name: ${fullName}`);
|
|
507
|
+
console.log(` Version: ${version}`);
|
|
508
|
+
console.log(` Author: ${author}`);
|
|
509
|
+
console.log(` Visibility: ${options.visibility}`);
|
|
510
|
+
console.log(` Category: ${options.category ?? 'none'}`);
|
|
511
|
+
console.log(` Keywords: ${options.keywords ?? 'none'}`);
|
|
512
|
+
console.log(` Files: ${collectedFiles.length + 1}`); // +1 for SKILL.md
|
|
513
|
+
console.log(` Size: ${formatSize(totalSize)}`);
|
|
514
|
+
|
|
515
|
+
if (collectedFiles.length > 0) {
|
|
516
|
+
console.log('\n File breakdown:');
|
|
517
|
+
console.log(` SKILL.md: ${formatSize(skillMdContent.length)}`);
|
|
518
|
+
if (fileStats.scripts > 0) console.log(` scripts/: ${fileStats.scripts} files`);
|
|
519
|
+
if (fileStats.references > 0) console.log(` references/: ${fileStats.references} files`);
|
|
520
|
+
if (fileStats.assets > 0) console.log(` assets/: ${fileStats.assets} files`);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
console.log(chalk.green('\n✓ Validation passed'));
|
|
524
|
+
process.exit(0);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Confirmation prompt
|
|
528
|
+
if (!options.yes) {
|
|
529
|
+
console.log(chalk.cyan('\nPublish Summary:'));
|
|
530
|
+
console.log(` Name: ${chalk.bold(fullName)}`);
|
|
531
|
+
console.log(` Version: ${chalk.bold(version)}`);
|
|
532
|
+
console.log(` Author: ${chalk.bold(author)}`);
|
|
533
|
+
console.log(` Visibility: ${options.visibility}`);
|
|
534
|
+
console.log(` Action: ${isUpdate ? 'New version' : 'New skill'}`);
|
|
535
|
+
console.log(` Files: ${collectedFiles.length + 1}`);
|
|
536
|
+
console.log(` Size: ${formatSize(totalSize)}`);
|
|
537
|
+
|
|
538
|
+
const { confirm } = await inquirer.prompt([{
|
|
539
|
+
type: 'confirm',
|
|
540
|
+
name: 'confirm',
|
|
541
|
+
message: 'Proceed with publish?',
|
|
542
|
+
default: true,
|
|
543
|
+
}]);
|
|
544
|
+
|
|
545
|
+
if (!confirm) {
|
|
546
|
+
console.log(chalk.yellow('Publish cancelled.'));
|
|
547
|
+
process.exit(0);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Publish
|
|
552
|
+
const publishSpinner = ora('Publishing...').start();
|
|
553
|
+
|
|
554
|
+
const contentString = JSON.stringify(skillContent);
|
|
555
|
+
const contentHash = sha256(contentString);
|
|
556
|
+
const timestamp = now();
|
|
557
|
+
const versionId = generateUUID();
|
|
558
|
+
|
|
559
|
+
if (isUpdate) {
|
|
560
|
+
// Create new version
|
|
561
|
+
const versionRecord: Version = {
|
|
562
|
+
id: versionId,
|
|
563
|
+
skillId: existingSkill.id,
|
|
564
|
+
version,
|
|
565
|
+
tag: 'latest',
|
|
566
|
+
content: skillContent,
|
|
567
|
+
packageUrl: `local://${fullName}/${version}`,
|
|
568
|
+
packageSize: contentString.length,
|
|
569
|
+
packageHash: contentHash,
|
|
570
|
+
status: 'published',
|
|
571
|
+
uses: 0,
|
|
572
|
+
createdAt: timestamp,
|
|
573
|
+
publishedAt: timestamp,
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
await storage.createVersion(versionRecord);
|
|
577
|
+
|
|
578
|
+
// Update previous latest tag
|
|
579
|
+
const versions = await storage.getVersions(existingSkill.id, { limit: 100 });
|
|
580
|
+
for (const v of versions.items) {
|
|
581
|
+
if (v.id !== versionId && v.tag === 'latest') {
|
|
582
|
+
await storage.updateVersion(v.id, { tag: undefined });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Update skill
|
|
587
|
+
await storage.updateSkill(existingSkill.id, {
|
|
588
|
+
latestVersion: version,
|
|
589
|
+
latestVersionId: versionId,
|
|
590
|
+
securityLevel: scanResult ? scanResult.summary.level : existingSkill.securityLevel,
|
|
591
|
+
securityScore: scanResult ? scanResult.summary.score : existingSkill.securityScore,
|
|
592
|
+
stats: {
|
|
593
|
+
...existingSkill.stats,
|
|
594
|
+
versionCount: existingSkill.stats.versionCount + 1,
|
|
595
|
+
},
|
|
596
|
+
});
|
|
597
|
+
} else {
|
|
598
|
+
// Create new skill
|
|
599
|
+
const skillId = generateUUID();
|
|
600
|
+
const keywords = options.keywords?.split(',').map((k: string) => k.trim()) ?? [];
|
|
601
|
+
|
|
602
|
+
const skillRecord: Skill = {
|
|
603
|
+
id: skillId,
|
|
604
|
+
name: skillName,
|
|
605
|
+
scope,
|
|
606
|
+
fullName,
|
|
607
|
+
ownerId: author, // Save author to ownerId
|
|
608
|
+
ownerType: author.startsWith('@') || scope ? 'organization' : 'user',
|
|
609
|
+
displayName: parsed.frontmatter['displayName'] as string | undefined,
|
|
610
|
+
description: (parsed.frontmatter['description'] as string) ?? '',
|
|
611
|
+
category: options.category,
|
|
612
|
+
keywords,
|
|
613
|
+
visibility: options.visibility,
|
|
614
|
+
status: 'active',
|
|
615
|
+
latestVersion: version,
|
|
616
|
+
latestVersionId: versionId,
|
|
617
|
+
securityLevel: scanResult?.summary.level,
|
|
618
|
+
securityScore: scanResult?.summary.score,
|
|
619
|
+
stats: { totalUses: 0, weeklyUses: 0, monthlyUses: 0, versionCount: 1, derivationCount: 0 },
|
|
620
|
+
rating: { average: 0, count: 0 },
|
|
621
|
+
createdAt: timestamp,
|
|
622
|
+
updatedAt: timestamp,
|
|
623
|
+
publishedAt: timestamp,
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
await storage.createSkill(skillRecord);
|
|
627
|
+
|
|
628
|
+
// Create version
|
|
629
|
+
const versionRecord: Version = {
|
|
630
|
+
id: versionId,
|
|
631
|
+
skillId,
|
|
632
|
+
version,
|
|
633
|
+
tag: 'latest',
|
|
634
|
+
content: skillContent,
|
|
635
|
+
packageUrl: `local://${fullName}/${version}`,
|
|
636
|
+
packageSize: contentString.length,
|
|
637
|
+
packageHash: contentHash,
|
|
638
|
+
status: 'published',
|
|
639
|
+
uses: 0,
|
|
640
|
+
createdAt: timestamp,
|
|
641
|
+
publishedAt: timestamp,
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
await storage.createVersion(versionRecord);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Create audit log
|
|
648
|
+
const auditLog: AuditLog = {
|
|
649
|
+
id: generateUUID(),
|
|
650
|
+
timestamp,
|
|
651
|
+
eventType: isUpdate ? 'version.published' : 'skill.published',
|
|
652
|
+
actor: {
|
|
653
|
+
type: 'user',
|
|
654
|
+
id: 'cli-user',
|
|
655
|
+
},
|
|
656
|
+
resource: {
|
|
657
|
+
type: 'skill',
|
|
658
|
+
name: fullName,
|
|
659
|
+
version,
|
|
660
|
+
},
|
|
661
|
+
action: 'publish',
|
|
662
|
+
result: 'success',
|
|
663
|
+
details: {
|
|
664
|
+
filesCount: collectedFiles.length + 1,
|
|
665
|
+
totalSize: totalSize,
|
|
666
|
+
},
|
|
667
|
+
};
|
|
668
|
+
await storage.createAuditLog(auditLog);
|
|
669
|
+
|
|
670
|
+
publishSpinner.succeed(chalk.green(`Published ${fullName}@${version}`));
|
|
671
|
+
|
|
672
|
+
console.log('\n' + chalk.gray('─'.repeat(50)));
|
|
673
|
+
console.log(chalk.bold.green('✓ Published successfully!\n'));
|
|
674
|
+
console.log(` ${chalk.cyan('Name:')} ${fullName}`);
|
|
675
|
+
console.log(` ${chalk.cyan('Version:')} ${version}`);
|
|
676
|
+
console.log(` ${chalk.cyan('Author:')} ${author}`);
|
|
677
|
+
console.log(` ${chalk.cyan('Files:')} ${collectedFiles.length + 1}`);
|
|
678
|
+
console.log(` ${chalk.cyan('Size:')} ${formatSize(totalSize)}`);
|
|
679
|
+
console.log(` ${chalk.cyan('Action:')} ${isUpdate ? 'New version' : 'New skill'}`);
|
|
680
|
+
console.log('\n' + chalk.gray(' Use: ') + chalk.white(`skills use ${fullName}`));
|
|
681
|
+
console.log(chalk.gray('─'.repeat(50)) + '\n');
|
|
682
|
+
|
|
683
|
+
await storage.close();
|
|
684
|
+
} catch (error) {
|
|
685
|
+
spinner.fail('Publish failed');
|
|
686
|
+
throw error;
|
|
687
|
+
}
|
|
688
|
+
});
|