@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.
Files changed (57) hide show
  1. package/dist/commands/cache.d.ts +6 -0
  2. package/dist/commands/cache.d.ts.map +1 -0
  3. package/dist/commands/cache.js +145 -0
  4. package/dist/commands/cache.js.map +1 -0
  5. package/dist/commands/config.d.ts +6 -0
  6. package/dist/commands/config.d.ts.map +1 -0
  7. package/dist/commands/config.js +128 -0
  8. package/dist/commands/config.js.map +1 -0
  9. package/dist/commands/create.d.ts +7 -0
  10. package/dist/commands/create.d.ts.map +1 -0
  11. package/dist/commands/create.js +449 -0
  12. package/dist/commands/create.js.map +1 -0
  13. package/dist/commands/feedback.d.ts +6 -0
  14. package/dist/commands/feedback.d.ts.map +1 -0
  15. package/dist/commands/feedback.js +137 -0
  16. package/dist/commands/feedback.js.map +1 -0
  17. package/dist/commands/get.d.ts +6 -0
  18. package/dist/commands/get.d.ts.map +1 -0
  19. package/dist/commands/get.js +122 -0
  20. package/dist/commands/get.js.map +1 -0
  21. package/dist/commands/index.d.ts +13 -0
  22. package/dist/commands/index.d.ts.map +1 -0
  23. package/dist/commands/index.js +13 -0
  24. package/dist/commands/index.js.map +1 -0
  25. package/dist/commands/publish.d.ts +7 -0
  26. package/dist/commands/publish.d.ts.map +1 -0
  27. package/dist/commands/publish.js +593 -0
  28. package/dist/commands/publish.js.map +1 -0
  29. package/dist/commands/scan.d.ts +6 -0
  30. package/dist/commands/scan.d.ts.map +1 -0
  31. package/dist/commands/scan.js +165 -0
  32. package/dist/commands/scan.js.map +1 -0
  33. package/dist/commands/search.d.ts +6 -0
  34. package/dist/commands/search.d.ts.map +1 -0
  35. package/dist/commands/search.js +80 -0
  36. package/dist/commands/search.js.map +1 -0
  37. package/dist/commands/validate.d.ts +7 -0
  38. package/dist/commands/validate.d.ts.map +1 -0
  39. package/dist/commands/validate.js +328 -0
  40. package/dist/commands/validate.js.map +1 -0
  41. package/dist/index.d.ts +6 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +107 -0
  44. package/dist/index.js.map +1 -0
  45. package/package.json +51 -0
  46. package/src/commands/cache.ts +166 -0
  47. package/src/commands/config.ts +142 -0
  48. package/src/commands/create.ts +490 -0
  49. package/src/commands/feedback.ts +161 -0
  50. package/src/commands/get.ts +141 -0
  51. package/src/commands/index.ts +13 -0
  52. package/src/commands/publish.ts +688 -0
  53. package/src/commands/scan.ts +190 -0
  54. package/src/commands/search.ts +92 -0
  55. package/src/commands/validate.ts +391 -0
  56. package/src/index.ts +118 -0
  57. 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
+ });