@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,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open Skills Hub CLI - Scan Command
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Command } from 'commander';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import ora from 'ora';
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import Table from 'cli-table3';
|
|
11
|
+
import {
|
|
12
|
+
getConfig,
|
|
13
|
+
initScanner,
|
|
14
|
+
getScanner,
|
|
15
|
+
} from '@open-skills-hub/core';
|
|
16
|
+
|
|
17
|
+
export const scanCommand = new Command('scan')
|
|
18
|
+
.description('Scan content for security issues')
|
|
19
|
+
.argument('<file>', 'Path to file to scan')
|
|
20
|
+
.option('--json', 'Output as JSON')
|
|
21
|
+
.option('-q, --quick', 'Quick check (high severity only)')
|
|
22
|
+
.option('--rules', 'Show available rules')
|
|
23
|
+
.action(async (file, options) => {
|
|
24
|
+
// Show rules if requested
|
|
25
|
+
if (options.rules) {
|
|
26
|
+
const config = getConfig();
|
|
27
|
+
initScanner({
|
|
28
|
+
enabled: true,
|
|
29
|
+
timeout: 30000,
|
|
30
|
+
maxFileSize: 10485760,
|
|
31
|
+
});
|
|
32
|
+
const scanner = getScanner();
|
|
33
|
+
const rules = scanner.getEnabledRules();
|
|
34
|
+
|
|
35
|
+
console.log(chalk.bold('\nAvailable Security Rules:\n'));
|
|
36
|
+
|
|
37
|
+
const table = new Table({
|
|
38
|
+
head: [
|
|
39
|
+
chalk.cyan('ID'),
|
|
40
|
+
chalk.cyan('Name'),
|
|
41
|
+
chalk.cyan('Category'),
|
|
42
|
+
chalk.cyan('Severity'),
|
|
43
|
+
],
|
|
44
|
+
colWidths: [10, 30, 12, 10],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
for (const rule of rules) {
|
|
48
|
+
const severityColor = {
|
|
49
|
+
high: chalk.red,
|
|
50
|
+
medium: chalk.yellow,
|
|
51
|
+
low: chalk.blue,
|
|
52
|
+
}[rule.severity] ?? chalk.white;
|
|
53
|
+
|
|
54
|
+
table.push([
|
|
55
|
+
rule.id,
|
|
56
|
+
rule.name,
|
|
57
|
+
rule.category,
|
|
58
|
+
severityColor(rule.severity),
|
|
59
|
+
]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.log(table.toString());
|
|
63
|
+
console.log(chalk.gray(`\nTotal: ${rules.length} rules`));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const spinner = ora('Scanning file...').start();
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
// Read file
|
|
71
|
+
const filePath = path.resolve(file);
|
|
72
|
+
if (!fs.existsSync(filePath)) {
|
|
73
|
+
spinner.fail(`File not found: ${filePath}`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
78
|
+
|
|
79
|
+
// Initialize scanner
|
|
80
|
+
const config = getConfig();
|
|
81
|
+
initScanner({
|
|
82
|
+
enabled: true,
|
|
83
|
+
timeout: config.get().scanner.timeout,
|
|
84
|
+
maxFileSize: config.get().scanner.maxFileSize,
|
|
85
|
+
});
|
|
86
|
+
const scanner = getScanner();
|
|
87
|
+
|
|
88
|
+
let result;
|
|
89
|
+
if (options.quick) {
|
|
90
|
+
// Quick check
|
|
91
|
+
const quickResult = await scanner.quickCheck(content);
|
|
92
|
+
spinner.stop();
|
|
93
|
+
|
|
94
|
+
if (quickResult.safe) {
|
|
95
|
+
console.log(chalk.green('\n✓ No high-severity issues found'));
|
|
96
|
+
} else {
|
|
97
|
+
console.log(chalk.red(`\n✗ Found ${quickResult.highSeverityCount} high-severity issue(s)`));
|
|
98
|
+
console.log(chalk.yellow(` Security level: ${quickResult.level}`));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
process.exit(quickResult.safe ? 0 : 1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Full scan
|
|
105
|
+
result = await scanner.scan(content);
|
|
106
|
+
spinner.stop();
|
|
107
|
+
|
|
108
|
+
// JSON output
|
|
109
|
+
if (options.json) {
|
|
110
|
+
console.log(JSON.stringify({
|
|
111
|
+
score: result.summary.score,
|
|
112
|
+
level: result.summary.level,
|
|
113
|
+
issueCount: result.summary.issueCount,
|
|
114
|
+
issues: result.issues,
|
|
115
|
+
recommendations: result.summary.recommendations,
|
|
116
|
+
}, null, 2));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Display results
|
|
121
|
+
console.log('\n' + chalk.bold.blue('═'.repeat(60)));
|
|
122
|
+
console.log(chalk.bold.white(' Security Scan Results'));
|
|
123
|
+
console.log(chalk.bold.blue('═'.repeat(60)) + '\n');
|
|
124
|
+
|
|
125
|
+
// Score and level
|
|
126
|
+
const levelColor = {
|
|
127
|
+
safe: chalk.green,
|
|
128
|
+
low: chalk.blue,
|
|
129
|
+
medium: chalk.yellow,
|
|
130
|
+
high: chalk.red,
|
|
131
|
+
}[result.summary.level] ?? chalk.white;
|
|
132
|
+
|
|
133
|
+
console.log(`${chalk.bold('Score:')} ${result.summary.score}/100`);
|
|
134
|
+
console.log(`${chalk.bold('Level:')} ${levelColor(result.summary.level.toUpperCase())}`);
|
|
135
|
+
console.log(`${chalk.bold('File:')} ${filePath}`);
|
|
136
|
+
console.log();
|
|
137
|
+
|
|
138
|
+
// Issue counts
|
|
139
|
+
console.log(chalk.bold('Issues:'));
|
|
140
|
+
console.log(` ${chalk.red('●')} High: ${result.summary.issueCount.high}`);
|
|
141
|
+
console.log(` ${chalk.yellow('●')} Medium: ${result.summary.issueCount.medium}`);
|
|
142
|
+
console.log(` ${chalk.blue('●')} Low: ${result.summary.issueCount.low}`);
|
|
143
|
+
console.log();
|
|
144
|
+
|
|
145
|
+
// Issue details
|
|
146
|
+
if (result.issues.length > 0) {
|
|
147
|
+
console.log(chalk.bold('Issue Details:\n'));
|
|
148
|
+
|
|
149
|
+
for (const issue of result.issues) {
|
|
150
|
+
const severityColor = {
|
|
151
|
+
high: chalk.red,
|
|
152
|
+
medium: chalk.yellow,
|
|
153
|
+
low: chalk.blue,
|
|
154
|
+
}[issue.severity] ?? chalk.white;
|
|
155
|
+
|
|
156
|
+
console.log(severityColor(`[${issue.severity.toUpperCase()}]`) + ` ${chalk.bold(issue.ruleName)} (${issue.ruleId})`);
|
|
157
|
+
console.log(chalk.gray(` Line ${issue.line}: `) + issue.content);
|
|
158
|
+
console.log(chalk.gray(` ${issue.message}`));
|
|
159
|
+
if (issue.suggestion) {
|
|
160
|
+
console.log(chalk.cyan(` 💡 ${issue.suggestion}`));
|
|
161
|
+
}
|
|
162
|
+
console.log();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Recommendations
|
|
167
|
+
if (result.summary.recommendations.length > 0) {
|
|
168
|
+
console.log(chalk.bold('Recommendations:\n'));
|
|
169
|
+
for (const rec of result.summary.recommendations) {
|
|
170
|
+
console.log(` • ${rec}`);
|
|
171
|
+
}
|
|
172
|
+
console.log();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Summary
|
|
176
|
+
console.log(chalk.bold.blue('─'.repeat(60)));
|
|
177
|
+
if (result.summary.level === 'safe') {
|
|
178
|
+
console.log(chalk.green.bold('✓ Content passed security scan'));
|
|
179
|
+
} else if (result.summary.level === 'high') {
|
|
180
|
+
console.log(chalk.red.bold('✗ Content has high-risk security issues'));
|
|
181
|
+
process.exit(1);
|
|
182
|
+
} else {
|
|
183
|
+
console.log(chalk.yellow.bold('⚠ Content has some security concerns'));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
} catch (error) {
|
|
187
|
+
spinner.fail('Scan failed');
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open Skills Hub CLI - Search Command
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Command } from 'commander';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import ora from 'ora';
|
|
8
|
+
import Table from 'cli-table3';
|
|
9
|
+
import { getStorage, getConfig } from '@open-skills-hub/core';
|
|
10
|
+
|
|
11
|
+
export const searchCommand = new Command('search')
|
|
12
|
+
.description('Search for skills')
|
|
13
|
+
.argument('[query]', 'Search query')
|
|
14
|
+
.option('-c, --category <category>', 'Filter by category')
|
|
15
|
+
.option('-a, --author <author>', 'Filter by author')
|
|
16
|
+
.option('-s, --sort <sort>', 'Sort by: relevance, uses, updated, created, name', 'relevance')
|
|
17
|
+
.option('-o, --order <order>', 'Sort order: asc, desc', 'desc')
|
|
18
|
+
.option('-l, --limit <limit>', 'Number of results', '20')
|
|
19
|
+
.option('--json', 'Output as JSON')
|
|
20
|
+
.action(async (query, options) => {
|
|
21
|
+
const spinner = ora('Searching skills...').start();
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const storage = await getStorage();
|
|
25
|
+
await storage.initialize();
|
|
26
|
+
|
|
27
|
+
const result = await storage.searchSkills({
|
|
28
|
+
query,
|
|
29
|
+
category: options.category,
|
|
30
|
+
author: options.author,
|
|
31
|
+
sort: options.sort,
|
|
32
|
+
order: options.order,
|
|
33
|
+
limit: parseInt(options.limit, 10),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
spinner.stop();
|
|
37
|
+
|
|
38
|
+
if (options.json) {
|
|
39
|
+
console.log(JSON.stringify(result, null, 2));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (result.items.length === 0) {
|
|
44
|
+
console.log(chalk.yellow('\nNo skills found matching your query.'));
|
|
45
|
+
console.log(chalk.gray('Try a different search term or browse by category.'));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.log(chalk.bold(`\nFound ${result.pagination.total} skills:\n`));
|
|
50
|
+
|
|
51
|
+
const table = new Table({
|
|
52
|
+
head: [
|
|
53
|
+
chalk.cyan('Name'),
|
|
54
|
+
chalk.cyan('Description'),
|
|
55
|
+
chalk.cyan('Version'),
|
|
56
|
+
chalk.cyan('Security'),
|
|
57
|
+
chalk.cyan('Uses'),
|
|
58
|
+
],
|
|
59
|
+
colWidths: [25, 40, 10, 10, 10],
|
|
60
|
+
wordWrap: true,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
for (const skill of result.items) {
|
|
64
|
+
const securityColor = {
|
|
65
|
+
safe: chalk.green,
|
|
66
|
+
low: chalk.blue,
|
|
67
|
+
medium: chalk.yellow,
|
|
68
|
+
high: chalk.red,
|
|
69
|
+
}[skill.securityLevel ?? 'safe'] ?? chalk.white;
|
|
70
|
+
|
|
71
|
+
table.push([
|
|
72
|
+
chalk.bold(skill.fullName),
|
|
73
|
+
skill.description.substring(0, 80) + (skill.description.length > 80 ? '...' : ''),
|
|
74
|
+
skill.latestVersion,
|
|
75
|
+
securityColor(skill.securityLevel ?? 'unknown'),
|
|
76
|
+
skill.stats.totalUses.toString(),
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.log(table.toString());
|
|
81
|
+
|
|
82
|
+
if (result.pagination.hasMore) {
|
|
83
|
+
console.log(chalk.gray(`\nShowing ${result.items.length} of ${result.pagination.total} results.`));
|
|
84
|
+
console.log(chalk.gray('Use --limit to see more results.'));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await storage.close();
|
|
88
|
+
} catch (error) {
|
|
89
|
+
spinner.fail('Search failed');
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
});
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open Skills Hub CLI - Validate Command
|
|
3
|
+
* Validates skill file or directory structure
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Command } from 'commander';
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import { parse as parseYaml } from 'yaml';
|
|
11
|
+
import { isValidSkillName } from '@open-skills-hub/core';
|
|
12
|
+
|
|
13
|
+
interface ValidationResult {
|
|
14
|
+
valid: boolean;
|
|
15
|
+
errors: string[];
|
|
16
|
+
warnings: string[];
|
|
17
|
+
info: {
|
|
18
|
+
name?: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
license?: string;
|
|
21
|
+
hasScripts: boolean;
|
|
22
|
+
hasReferences: boolean;
|
|
23
|
+
hasAssets: boolean;
|
|
24
|
+
fileCount: number;
|
|
25
|
+
totalSize: number;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ParsedFrontmatter {
|
|
30
|
+
name?: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
license?: string;
|
|
33
|
+
compatibility?: string;
|
|
34
|
+
metadata?: Record<string, string>;
|
|
35
|
+
'allowed-tools'?: string;
|
|
36
|
+
allowedTools?: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseFrontmatter(content: string): { frontmatter: ParsedFrontmatter; markdown: string } | null {
|
|
40
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
41
|
+
if (!match || !match[1]) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const frontmatter = parseYaml(match[1]) as ParsedFrontmatter;
|
|
47
|
+
return {
|
|
48
|
+
frontmatter,
|
|
49
|
+
markdown: match[2]?.trim() || '',
|
|
50
|
+
};
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function formatSize(bytes: number): string {
|
|
57
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
58
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
59
|
+
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function countFiles(dirPath: string): { count: number; size: number } {
|
|
63
|
+
let count = 0;
|
|
64
|
+
let size = 0;
|
|
65
|
+
|
|
66
|
+
if (!fs.existsSync(dirPath)) {
|
|
67
|
+
return { count: 0, size: 0 };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
73
|
+
if (entry.isDirectory()) {
|
|
74
|
+
const sub = countFiles(fullPath);
|
|
75
|
+
count += sub.count;
|
|
76
|
+
size += sub.size;
|
|
77
|
+
} else if (entry.isFile()) {
|
|
78
|
+
count++;
|
|
79
|
+
size += fs.statSync(fullPath).size;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { count, size };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function validateSkill(skillPath: string): ValidationResult {
|
|
87
|
+
const result: ValidationResult = {
|
|
88
|
+
valid: true,
|
|
89
|
+
errors: [],
|
|
90
|
+
warnings: [],
|
|
91
|
+
info: {
|
|
92
|
+
hasScripts: false,
|
|
93
|
+
hasReferences: false,
|
|
94
|
+
hasAssets: false,
|
|
95
|
+
fileCount: 0,
|
|
96
|
+
totalSize: 0,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const resolvedPath = path.resolve(skillPath);
|
|
101
|
+
|
|
102
|
+
// Check if path exists
|
|
103
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
104
|
+
result.errors.push(`Path not found: ${resolvedPath}`);
|
|
105
|
+
result.valid = false;
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const stat = fs.statSync(resolvedPath);
|
|
110
|
+
const isDirectory = stat.isDirectory();
|
|
111
|
+
|
|
112
|
+
let skillMdPath: string;
|
|
113
|
+
let skillDir: string;
|
|
114
|
+
|
|
115
|
+
if (isDirectory) {
|
|
116
|
+
skillDir = resolvedPath;
|
|
117
|
+
skillMdPath = path.join(resolvedPath, 'SKILL.md');
|
|
118
|
+
|
|
119
|
+
// Also check for lowercase
|
|
120
|
+
if (!fs.existsSync(skillMdPath)) {
|
|
121
|
+
const altPath = path.join(resolvedPath, 'skill.md');
|
|
122
|
+
if (fs.existsSync(altPath)) {
|
|
123
|
+
skillMdPath = altPath;
|
|
124
|
+
result.warnings.push('SKILL.md should be uppercase (found skill.md)');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
skillDir = path.dirname(resolvedPath);
|
|
129
|
+
skillMdPath = resolvedPath;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Check SKILL.md exists
|
|
133
|
+
if (!fs.existsSync(skillMdPath)) {
|
|
134
|
+
result.errors.push('SKILL.md not found');
|
|
135
|
+
result.valid = false;
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Read and parse SKILL.md
|
|
140
|
+
const content = fs.readFileSync(skillMdPath, 'utf-8');
|
|
141
|
+
result.info.totalSize = content.length;
|
|
142
|
+
result.info.fileCount = 1;
|
|
143
|
+
|
|
144
|
+
// Check for frontmatter
|
|
145
|
+
const parsed = parseFrontmatter(content);
|
|
146
|
+
if (!parsed) {
|
|
147
|
+
result.errors.push('Invalid or missing YAML frontmatter (must start with ---)');
|
|
148
|
+
result.valid = false;
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const { frontmatter, markdown } = parsed;
|
|
153
|
+
|
|
154
|
+
// Validate name field
|
|
155
|
+
if (!frontmatter.name) {
|
|
156
|
+
result.errors.push('Missing required field: name');
|
|
157
|
+
result.valid = false;
|
|
158
|
+
} else {
|
|
159
|
+
result.info.name = frontmatter.name;
|
|
160
|
+
|
|
161
|
+
// Check name format
|
|
162
|
+
if (!isValidSkillName(frontmatter.name)) {
|
|
163
|
+
result.errors.push(`Invalid name format: "${frontmatter.name}". Must be lowercase letters, numbers, and hyphens only.`);
|
|
164
|
+
result.valid = false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Check name matches directory
|
|
168
|
+
if (isDirectory) {
|
|
169
|
+
const dirName = path.basename(skillDir);
|
|
170
|
+
if (frontmatter.name !== dirName) {
|
|
171
|
+
result.warnings.push(`Name "${frontmatter.name}" doesn't match directory name "${dirName}"`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Check name length
|
|
176
|
+
if (frontmatter.name.length > 64) {
|
|
177
|
+
result.errors.push(`Name too long: ${frontmatter.name.length} chars (max 64)`);
|
|
178
|
+
result.valid = false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Check for invalid patterns
|
|
182
|
+
if (frontmatter.name.startsWith('-')) {
|
|
183
|
+
result.errors.push('Name cannot start with a hyphen');
|
|
184
|
+
result.valid = false;
|
|
185
|
+
}
|
|
186
|
+
if (frontmatter.name.endsWith('-')) {
|
|
187
|
+
result.errors.push('Name cannot end with a hyphen');
|
|
188
|
+
result.valid = false;
|
|
189
|
+
}
|
|
190
|
+
if (frontmatter.name.includes('--')) {
|
|
191
|
+
result.errors.push('Name cannot contain consecutive hyphens');
|
|
192
|
+
result.valid = false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Validate description field
|
|
197
|
+
if (!frontmatter.description) {
|
|
198
|
+
result.errors.push('Missing required field: description');
|
|
199
|
+
result.valid = false;
|
|
200
|
+
} else {
|
|
201
|
+
result.info.description = frontmatter.description;
|
|
202
|
+
|
|
203
|
+
if (frontmatter.description.length > 1024) {
|
|
204
|
+
result.errors.push(`Description too long: ${frontmatter.description.length} chars (max 1024)`);
|
|
205
|
+
result.valid = false;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (frontmatter.description.length < 10) {
|
|
209
|
+
result.warnings.push('Description is very short. Consider adding more detail.');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Validate optional fields
|
|
214
|
+
if (frontmatter.license) {
|
|
215
|
+
result.info.license = frontmatter.license;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (frontmatter.compatibility && frontmatter.compatibility.length > 500) {
|
|
219
|
+
result.errors.push(`Compatibility too long: ${frontmatter.compatibility.length} chars (max 500)`);
|
|
220
|
+
result.valid = false;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Check markdown content
|
|
224
|
+
if (!markdown || markdown.length === 0) {
|
|
225
|
+
result.warnings.push('SKILL.md has no body content after frontmatter');
|
|
226
|
+
} else if (markdown.length > 50000) {
|
|
227
|
+
result.warnings.push(`SKILL.md body is very large (${(markdown.length / 1000).toFixed(1)}K chars). Consider splitting into reference files.`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Check directory structure (only for directories)
|
|
231
|
+
if (isDirectory) {
|
|
232
|
+
const scriptsDir = path.join(skillDir, 'scripts');
|
|
233
|
+
const referencesDir = path.join(skillDir, 'references');
|
|
234
|
+
const referenceDir = path.join(skillDir, 'reference'); // Alternative name
|
|
235
|
+
const assetsDir = path.join(skillDir, 'assets');
|
|
236
|
+
|
|
237
|
+
if (fs.existsSync(scriptsDir)) {
|
|
238
|
+
result.info.hasScripts = true;
|
|
239
|
+
const stats = countFiles(scriptsDir);
|
|
240
|
+
result.info.fileCount += stats.count;
|
|
241
|
+
result.info.totalSize += stats.size;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (fs.existsSync(referencesDir)) {
|
|
245
|
+
result.info.hasReferences = true;
|
|
246
|
+
const stats = countFiles(referencesDir);
|
|
247
|
+
result.info.fileCount += stats.count;
|
|
248
|
+
result.info.totalSize += stats.size;
|
|
249
|
+
} else if (fs.existsSync(referenceDir)) {
|
|
250
|
+
result.info.hasReferences = true;
|
|
251
|
+
const stats = countFiles(referenceDir);
|
|
252
|
+
result.info.fileCount += stats.count;
|
|
253
|
+
result.info.totalSize += stats.size;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (fs.existsSync(assetsDir)) {
|
|
257
|
+
result.info.hasAssets = true;
|
|
258
|
+
const stats = countFiles(assetsDir);
|
|
259
|
+
result.info.fileCount += stats.count;
|
|
260
|
+
result.info.totalSize += stats.size;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Check for LICENSE file
|
|
264
|
+
const licensePath = path.join(skillDir, 'LICENSE.txt');
|
|
265
|
+
const licensePathAlt = path.join(skillDir, 'LICENSE');
|
|
266
|
+
if (!fs.existsSync(licensePath) && !fs.existsSync(licensePathAlt)) {
|
|
267
|
+
if (frontmatter.license) {
|
|
268
|
+
result.warnings.push('License specified in frontmatter but no LICENSE.txt file found');
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Check total size
|
|
273
|
+
if (result.info.totalSize > 150 * 1024 * 1024) {
|
|
274
|
+
result.errors.push(`Total size exceeds 150MB limit: ${formatSize(result.info.totalSize)}`);
|
|
275
|
+
result.valid = false;
|
|
276
|
+
} else if (result.info.totalSize > 50 * 1024 * 1024) {
|
|
277
|
+
result.warnings.push(`Large skill size: ${formatSize(result.info.totalSize)}. Consider optimizing.`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export const validateCommand = new Command('validate')
|
|
285
|
+
.description('Validate a skill file or directory')
|
|
286
|
+
.argument('<path>', 'Path to skill file (.md) or directory containing SKILL.md')
|
|
287
|
+
.option('--json', 'Output result as JSON')
|
|
288
|
+
.action((skillPath, options) => {
|
|
289
|
+
const result = validateSkill(skillPath);
|
|
290
|
+
|
|
291
|
+
if (options.json) {
|
|
292
|
+
console.log(JSON.stringify(result, null, 2));
|
|
293
|
+
process.exit(result.valid ? 0 : 1);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Pretty output
|
|
297
|
+
const resolvedPath = path.resolve(skillPath);
|
|
298
|
+
console.log(`\nValidating ${chalk.cyan(resolvedPath)}...\n`);
|
|
299
|
+
|
|
300
|
+
// SKILL.md check
|
|
301
|
+
if (result.errors.some(e => e.includes('SKILL.md not found'))) {
|
|
302
|
+
console.log(chalk.red('✗ SKILL.md not found'));
|
|
303
|
+
process.exit(1);
|
|
304
|
+
} else {
|
|
305
|
+
console.log(chalk.green('✓ SKILL.md exists'));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Frontmatter check
|
|
309
|
+
if (result.errors.some(e => e.includes('frontmatter'))) {
|
|
310
|
+
console.log(chalk.red('✗ Frontmatter is invalid'));
|
|
311
|
+
for (const error of result.errors.filter(e => e.includes('frontmatter'))) {
|
|
312
|
+
console.log(chalk.red(` └── ${error}`));
|
|
313
|
+
}
|
|
314
|
+
} else {
|
|
315
|
+
console.log(chalk.green('✓ Frontmatter is valid'));
|
|
316
|
+
|
|
317
|
+
// Show field details
|
|
318
|
+
if (result.info.name) {
|
|
319
|
+
const nameValid = !result.errors.some(e => e.toLowerCase().includes('name'));
|
|
320
|
+
const icon = nameValid ? chalk.green('✓') : chalk.red('✗');
|
|
321
|
+
console.log(` ├── name: ${result.info.name} ${icon}`);
|
|
322
|
+
|
|
323
|
+
// Show name errors
|
|
324
|
+
for (const error of result.errors.filter(e => e.toLowerCase().includes('name') && !e.includes('frontmatter'))) {
|
|
325
|
+
console.log(chalk.red(` │ └── ${error}`));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (result.info.description) {
|
|
330
|
+
const descValid = !result.errors.some(e => e.toLowerCase().includes('description'));
|
|
331
|
+
const icon = descValid ? chalk.green('✓') : chalk.red('✗');
|
|
332
|
+
const preview = result.info.description.length > 40
|
|
333
|
+
? result.info.description.substring(0, 40) + '...'
|
|
334
|
+
: result.info.description;
|
|
335
|
+
console.log(` ├── description: ${preview} ${icon}`);
|
|
336
|
+
|
|
337
|
+
// Show description errors
|
|
338
|
+
for (const error of result.errors.filter(e => e.toLowerCase().includes('description'))) {
|
|
339
|
+
console.log(chalk.red(` │ └── ${error}`));
|
|
340
|
+
}
|
|
341
|
+
} else {
|
|
342
|
+
console.log(chalk.red(` ├── description: (missing)`));
|
|
343
|
+
console.log(chalk.red(` │ └── Error: Description is required`));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (result.info.license) {
|
|
347
|
+
console.log(` └── license: ${result.info.license} ${chalk.green('✓')}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Directory structure check
|
|
352
|
+
const stat = fs.statSync(resolvedPath);
|
|
353
|
+
if (stat.isDirectory()) {
|
|
354
|
+
const hasAnyDirs = result.info.hasScripts || result.info.hasReferences || result.info.hasAssets;
|
|
355
|
+
|
|
356
|
+
if (hasAnyDirs || result.info.fileCount > 1) {
|
|
357
|
+
console.log(chalk.green('✓ Directory structure is valid'));
|
|
358
|
+
if (result.info.hasScripts) {
|
|
359
|
+
console.log(chalk.gray(' ├── scripts/ ✓'));
|
|
360
|
+
}
|
|
361
|
+
if (result.info.hasReferences) {
|
|
362
|
+
console.log(chalk.gray(' ├── references/ ✓'));
|
|
363
|
+
}
|
|
364
|
+
if (result.info.hasAssets) {
|
|
365
|
+
console.log(chalk.gray(' ├── assets/ ✓'));
|
|
366
|
+
}
|
|
367
|
+
console.log(chalk.gray(` └── ${result.info.fileCount} files, ${formatSize(result.info.totalSize)}`));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Warnings
|
|
372
|
+
if (result.warnings.length > 0) {
|
|
373
|
+
console.log('');
|
|
374
|
+
for (const warning of result.warnings) {
|
|
375
|
+
console.log(chalk.yellow(`⚠ ${warning}`));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Summary
|
|
380
|
+
console.log('');
|
|
381
|
+
if (result.valid) {
|
|
382
|
+
console.log(chalk.green.bold('✓ Skill is valid and ready to publish!'));
|
|
383
|
+
console.log(chalk.gray(`\nRun: skills publish ${skillPath}`));
|
|
384
|
+
} else {
|
|
385
|
+
const errorCount = result.errors.length;
|
|
386
|
+
console.log(chalk.red.bold(`Found ${errorCount} error${errorCount > 1 ? 's' : ''}. Please fix before publishing.`));
|
|
387
|
+
}
|
|
388
|
+
console.log('');
|
|
389
|
+
|
|
390
|
+
process.exit(result.valid ? 0 : 1);
|
|
391
|
+
});
|