@sporesec/arcana 3.0.0 → 3.0.2
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/cli.js +4 -0
- package/dist/commands/audit.d.ts +2 -2
- package/dist/commands/audit.js +31 -8
- package/dist/commands/uninstall.js +4 -1
- package/dist/commands/validate.d.ts +3 -0
- package/dist/commands/validate.js +78 -11
- package/dist/interactive/browse.js +2 -2
- package/dist/interactive/health.js +42 -35
- package/dist/interactive/helpers.d.ts +14 -0
- package/dist/interactive/helpers.js +33 -6
- package/dist/interactive/manage.js +3 -3
- package/dist/interactive/menu.js +4 -0
- package/dist/interactive/optimize-flow.d.ts +1 -0
- package/dist/interactive/optimize-flow.js +36 -0
- package/dist/interactive/search.js +1 -1
- package/dist/interactive/skill-detail.d.ts +4 -1
- package/dist/interactive/skill-detail.js +69 -35
- package/dist/utils/backup.d.ts +3 -0
- package/dist/utils/backup.js +32 -0
- package/dist/utils/frontmatter.js +8 -5
- package/dist/utils/quality.d.ts +27 -0
- package/dist/utils/quality.js +174 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -127,6 +127,9 @@ export function createCli() {
|
|
|
127
127
|
.option("-a, --all", "Validate all installed skills")
|
|
128
128
|
.option("-f, --fix", "Auto-fix common issues")
|
|
129
129
|
.option("-j, --json", "Output as JSON")
|
|
130
|
+
.option("--source <dir>", "Validate from source directory instead of install dir")
|
|
131
|
+
.option("--cross", "Run cross-validation (marketplace sync, companions, orphans)")
|
|
132
|
+
.option("--min-score <n>", "Minimum quality score (0-100), fail if any skill scores below", parseInt)
|
|
130
133
|
.action(async (skill, opts) => {
|
|
131
134
|
const { validateCommand } = await import("./commands/validate.js");
|
|
132
135
|
return validateCommand(skill, opts);
|
|
@@ -213,6 +216,7 @@ export function createCli() {
|
|
|
213
216
|
.description("Audit skill quality (code examples, BAD/GOOD pairs, structure)")
|
|
214
217
|
.option("-a, --all", "Audit all installed skills")
|
|
215
218
|
.option("-j, --json", "Output as JSON")
|
|
219
|
+
.option("--source <dir>", "Audit from source directory instead of install dir")
|
|
216
220
|
.action(async (skill, opts) => {
|
|
217
221
|
const { auditCommand } = await import("./commands/audit.js");
|
|
218
222
|
return auditCommand(skill, opts);
|
package/dist/commands/audit.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
interface AuditResult {
|
|
1
|
+
export interface AuditResult {
|
|
2
2
|
skill: string;
|
|
3
3
|
rating: "PERFECT" | "STRONG" | "ADEQUATE" | "WEAK";
|
|
4
4
|
score: number;
|
|
@@ -12,5 +12,5 @@ export declare function auditSkill(skillDir: string, skillName: string): AuditRe
|
|
|
12
12
|
export declare function auditCommand(skill: string | undefined, opts: {
|
|
13
13
|
all?: boolean;
|
|
14
14
|
json?: boolean;
|
|
15
|
+
source?: string;
|
|
15
16
|
}): Promise<void>;
|
|
16
|
-
export {};
|
package/dist/commands/audit.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
3
|
import { getInstallDir } from "../utils/fs.js";
|
|
4
4
|
import { extractFrontmatter, parseFrontmatter } from "../utils/frontmatter.js";
|
|
5
5
|
import { ui, banner } from "../utils/ui.js";
|
|
@@ -76,9 +76,25 @@ export function auditSkill(skillDir, skillName) {
|
|
|
76
76
|
});
|
|
77
77
|
if (hasExtras)
|
|
78
78
|
score += 10;
|
|
79
|
-
//
|
|
79
|
+
// 9. Section diversity (3+ unique ## headings)
|
|
80
|
+
const uniqueHeadings = new Set((body.match(/^## .+$/gm) || []).map((h) => h.toLowerCase()));
|
|
81
|
+
const goodDiversity = uniqueHeadings.size >= 3;
|
|
82
|
+
checks.push({
|
|
83
|
+
name: "Section diversity (3+ unique headings)",
|
|
84
|
+
passed: goodDiversity,
|
|
85
|
+
detail: `${uniqueHeadings.size} unique sections`,
|
|
86
|
+
});
|
|
87
|
+
if (goodDiversity)
|
|
88
|
+
score += 5;
|
|
89
|
+
// 10. Numbered steps (task decomposition signal)
|
|
90
|
+
const numberedSteps = (body.match(/^\d+\.\s/gm) || []).length;
|
|
91
|
+
const hasSteps = numberedSteps >= 3;
|
|
92
|
+
checks.push({ name: "Has numbered steps (3+)", passed: hasSteps, detail: `${numberedSteps} steps` });
|
|
93
|
+
if (hasSteps)
|
|
94
|
+
score += 5;
|
|
95
|
+
// Rating (max possible: 110)
|
|
80
96
|
let rating;
|
|
81
|
-
if (score >=
|
|
97
|
+
if (score >= 90)
|
|
82
98
|
rating = "PERFECT";
|
|
83
99
|
else if (score >= 65)
|
|
84
100
|
rating = "STRONG";
|
|
@@ -91,8 +107,8 @@ export function auditSkill(skillDir, skillName) {
|
|
|
91
107
|
export async function auditCommand(skill, opts) {
|
|
92
108
|
if (!opts.json)
|
|
93
109
|
banner();
|
|
94
|
-
const
|
|
95
|
-
if (!existsSync(
|
|
110
|
+
const baseDir = opts.source ? resolve(opts.source) : getInstallDir();
|
|
111
|
+
if (!existsSync(baseDir)) {
|
|
96
112
|
if (opts.json) {
|
|
97
113
|
console.log(JSON.stringify({ results: [] }));
|
|
98
114
|
}
|
|
@@ -104,7 +120,14 @@ export async function auditCommand(skill, opts) {
|
|
|
104
120
|
}
|
|
105
121
|
let skills;
|
|
106
122
|
if (opts.all) {
|
|
107
|
-
skills = readdirSync(
|
|
123
|
+
skills = readdirSync(baseDir).filter((d) => {
|
|
124
|
+
try {
|
|
125
|
+
return statSync(join(baseDir, d)).isDirectory();
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
});
|
|
108
131
|
}
|
|
109
132
|
else if (skill) {
|
|
110
133
|
skills = [skill];
|
|
@@ -123,7 +146,7 @@ export async function auditCommand(skill, opts) {
|
|
|
123
146
|
}
|
|
124
147
|
const results = [];
|
|
125
148
|
for (const name of skills.sort()) {
|
|
126
|
-
const skillDir = join(
|
|
149
|
+
const skillDir = join(baseDir, name);
|
|
127
150
|
if (!existsSync(skillDir)) {
|
|
128
151
|
results.push({ skill: name, rating: "WEAK", score: 0, checks: [{ name: "Exists", passed: false }] });
|
|
129
152
|
continue;
|
|
@@ -144,7 +167,7 @@ export async function auditCommand(skill, opts) {
|
|
|
144
167
|
: r.rating === "ADEQUATE"
|
|
145
168
|
? ui.warn
|
|
146
169
|
: ui.error;
|
|
147
|
-
console.log(` ${ratingColor(`[${r.rating}]`)} ${ui.bold(r.skill)} ${ui.dim(`(${r.score}/
|
|
170
|
+
console.log(` ${ratingColor(`[${r.rating}]`)} ${ui.bold(r.skill)} ${ui.dim(`(${r.score}/110)`)}`);
|
|
148
171
|
for (const check of r.checks) {
|
|
149
172
|
if (!check.passed) {
|
|
150
173
|
const detail = check.detail ? ` ${ui.dim(`(${check.detail})`)}` : "";
|
|
@@ -5,6 +5,7 @@ import chalk from "chalk";
|
|
|
5
5
|
import { getSkillDir, listSymlinks, readSkillMeta } from "../utils/fs.js";
|
|
6
6
|
import { renderBanner } from "../utils/help.js";
|
|
7
7
|
import { validateSlug } from "../utils/validate.js";
|
|
8
|
+
import { backupSkill } from "../utils/backup.js";
|
|
8
9
|
export async function uninstallCommand(skillNames, opts = {}) {
|
|
9
10
|
if (opts.json) {
|
|
10
11
|
return uninstallJson(skillNames);
|
|
@@ -49,7 +50,8 @@ async function uninstallOneInteractive(skillName, skipConfirm) {
|
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
const spin = p.spinner();
|
|
52
|
-
spin.start(`
|
|
53
|
+
spin.start(`Backing up and removing ${skillName}...`);
|
|
54
|
+
backupSkill(skillName);
|
|
53
55
|
rmSync(skillDir, { recursive: true, force: true });
|
|
54
56
|
const symlinksRemoved = removeSymlinksFor(skillName);
|
|
55
57
|
spin.stop(`Removed ${chalk.bold(skillName)}`);
|
|
@@ -88,6 +90,7 @@ async function uninstallMultipleInteractive(skillNames, skipConfirm) {
|
|
|
88
90
|
for (let i = 0; i < toRemove.length; i++) {
|
|
89
91
|
const skillName = toRemove[i];
|
|
90
92
|
spin.start(`Removing ${chalk.bold(skillName)} (${i + 1}/${toRemove.length})...`);
|
|
93
|
+
backupSkill(skillName);
|
|
91
94
|
rmSync(getSkillDir(skillName), { recursive: true, force: true });
|
|
92
95
|
totalSymlinks += removeSymlinksFor(skillName);
|
|
93
96
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
3
|
import { getInstallDir } from "../utils/fs.js";
|
|
4
4
|
import { validateSkillDir, fixSkillFrontmatter } from "../utils/frontmatter.js";
|
|
5
5
|
import { atomicWriteSync } from "../utils/atomic.js";
|
|
@@ -8,8 +8,8 @@ import { scanSkillContent } from "../utils/scanner.js";
|
|
|
8
8
|
export async function validateCommand(skill, opts) {
|
|
9
9
|
if (!opts.json)
|
|
10
10
|
banner();
|
|
11
|
-
const
|
|
12
|
-
if (!existsSync(
|
|
11
|
+
const baseDir = opts.source ? resolve(opts.source) : getInstallDir();
|
|
12
|
+
if (!existsSync(baseDir)) {
|
|
13
13
|
if (opts.json) {
|
|
14
14
|
console.log(JSON.stringify({ results: [] }));
|
|
15
15
|
}
|
|
@@ -21,7 +21,14 @@ export async function validateCommand(skill, opts) {
|
|
|
21
21
|
}
|
|
22
22
|
let skills;
|
|
23
23
|
if (opts.all) {
|
|
24
|
-
skills = readdirSync(
|
|
24
|
+
skills = readdirSync(baseDir).filter((d) => {
|
|
25
|
+
try {
|
|
26
|
+
return statSync(join(baseDir, d)).isDirectory();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
});
|
|
25
32
|
}
|
|
26
33
|
else if (skill) {
|
|
27
34
|
skills = [skill];
|
|
@@ -40,7 +47,7 @@ export async function validateCommand(skill, opts) {
|
|
|
40
47
|
}
|
|
41
48
|
const results = [];
|
|
42
49
|
for (const name of skills) {
|
|
43
|
-
const skillDir = join(
|
|
50
|
+
const skillDir = join(baseDir, name);
|
|
44
51
|
if (!existsSync(skillDir)) {
|
|
45
52
|
results.push({ skill: name, valid: false, errors: ["Not installed"], warnings: [], infos: [] });
|
|
46
53
|
continue;
|
|
@@ -85,10 +92,42 @@ export async function validateCommand(skill, opts) {
|
|
|
85
92
|
/* skip if unreadable */
|
|
86
93
|
}
|
|
87
94
|
}
|
|
88
|
-
|
|
95
|
+
// Quality scoring (when --min-score is set)
|
|
96
|
+
const entry = { ...result };
|
|
97
|
+
if (opts.minScore !== undefined) {
|
|
98
|
+
const { auditSkill } = await import("./audit.js");
|
|
99
|
+
const audit = auditSkill(skillDir, name);
|
|
100
|
+
entry.qualityScore = audit.score;
|
|
101
|
+
entry.qualityRating = audit.rating;
|
|
102
|
+
if (audit.score < opts.minScore) {
|
|
103
|
+
entry.valid = false;
|
|
104
|
+
entry.errors.push(`Quality score ${audit.score} below minimum ${opts.minScore} (${audit.rating})`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
results.push(entry);
|
|
108
|
+
}
|
|
109
|
+
// Cross-validation (when --cross is set)
|
|
110
|
+
let crossIssues = [];
|
|
111
|
+
if (opts.cross) {
|
|
112
|
+
const { crossValidate } = await import("../utils/quality.js");
|
|
113
|
+
const marketplacePaths = opts.source
|
|
114
|
+
? [
|
|
115
|
+
resolve(opts.source, "..", ".claude-plugin", "marketplace.json"),
|
|
116
|
+
resolve(opts.source, ".claude-plugin", "marketplace.json"),
|
|
117
|
+
]
|
|
118
|
+
: [resolve(baseDir, "..", ".claude-plugin", "marketplace.json")];
|
|
119
|
+
const marketplacePath = marketplacePaths.find((p) => existsSync(p));
|
|
120
|
+
if (marketplacePath) {
|
|
121
|
+
crossIssues = crossValidate(baseDir, marketplacePath);
|
|
122
|
+
}
|
|
123
|
+
else if (!opts.json) {
|
|
124
|
+
console.log(ui.warn(" Could not find marketplace.json for cross-validation"));
|
|
125
|
+
}
|
|
89
126
|
}
|
|
127
|
+
const hasErrors = results.some((r) => !r.valid);
|
|
128
|
+
const hasCrossErrors = crossIssues.some((i) => i.level === "error");
|
|
90
129
|
if (opts.json) {
|
|
91
|
-
|
|
130
|
+
const output = {
|
|
92
131
|
results: results.map((r) => ({
|
|
93
132
|
skill: r.skill,
|
|
94
133
|
valid: r.valid,
|
|
@@ -96,12 +135,28 @@ export async function validateCommand(skill, opts) {
|
|
|
96
135
|
warnings: r.warnings,
|
|
97
136
|
infos: r.infos,
|
|
98
137
|
fixed: r.fixed ?? false,
|
|
138
|
+
...(r.qualityScore !== undefined && { qualityScore: r.qualityScore, qualityRating: r.qualityRating }),
|
|
99
139
|
})),
|
|
100
|
-
}
|
|
101
|
-
if (
|
|
140
|
+
};
|
|
141
|
+
if (opts.cross && crossIssues.length > 0) {
|
|
142
|
+
output.crossValidation = crossIssues;
|
|
143
|
+
}
|
|
144
|
+
const scores = results.filter((r) => r.qualityScore !== undefined).map((r) => r.qualityScore);
|
|
145
|
+
if (scores.length > 0) {
|
|
146
|
+
output.summary = {
|
|
147
|
+
total: results.length,
|
|
148
|
+
passed: results.filter((r) => r.valid).length,
|
|
149
|
+
failed: results.filter((r) => !r.valid).length,
|
|
150
|
+
averageScore: Math.round(scores.reduce((a, b) => a + b, 0) / scores.length),
|
|
151
|
+
belowThreshold: opts.minScore ? results.filter((r) => (r.qualityScore ?? 0) < opts.minScore).length : 0,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
console.log(JSON.stringify(output, null, 2));
|
|
155
|
+
if (hasErrors || hasCrossErrors)
|
|
102
156
|
process.exit(1);
|
|
103
157
|
return;
|
|
104
158
|
}
|
|
159
|
+
// Human-readable output
|
|
105
160
|
let passed = 0;
|
|
106
161
|
let warned = 0;
|
|
107
162
|
let failed = 0;
|
|
@@ -109,7 +164,8 @@ export async function validateCommand(skill, opts) {
|
|
|
109
164
|
for (const r of results) {
|
|
110
165
|
const icon = r.valid ? (r.warnings.length > 0 ? ui.warn("[!!]") : ui.success("[OK]")) : ui.error("[XX]");
|
|
111
166
|
const fixTag = r.fixed ? ui.cyan(" [fixed]") : "";
|
|
112
|
-
|
|
167
|
+
const scoreTag = r.qualityScore !== undefined ? ui.dim(` (${r.qualityScore}/100 ${r.qualityRating})`) : "";
|
|
168
|
+
console.log(` ${icon} ${ui.bold(r.skill)}${fixTag}${scoreTag}`);
|
|
113
169
|
for (const err of r.errors) {
|
|
114
170
|
console.log(ui.error(` Error: ${err}`));
|
|
115
171
|
}
|
|
@@ -131,6 +187,15 @@ export async function validateCommand(skill, opts) {
|
|
|
131
187
|
if (r.fixed)
|
|
132
188
|
fixed++;
|
|
133
189
|
}
|
|
190
|
+
// Cross-validation output
|
|
191
|
+
if (opts.cross && crossIssues.length > 0) {
|
|
192
|
+
console.log();
|
|
193
|
+
console.log(ui.bold(" Cross-validation:"));
|
|
194
|
+
for (const issue of crossIssues) {
|
|
195
|
+
const icon = issue.level === "error" ? ui.error("[XX]") : ui.warn("[!!]");
|
|
196
|
+
console.log(` ${icon} ${issue.skill}: ${issue.detail}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
134
199
|
console.log();
|
|
135
200
|
const parts = [];
|
|
136
201
|
if (passed > 0)
|
|
@@ -141,8 +206,10 @@ export async function validateCommand(skill, opts) {
|
|
|
141
206
|
parts.push(ui.error(`${failed} failed`));
|
|
142
207
|
if (fixed > 0)
|
|
143
208
|
parts.push(ui.cyan(`${fixed} fixed`));
|
|
209
|
+
if (hasCrossErrors)
|
|
210
|
+
parts.push(ui.error(`${crossIssues.filter((i) => i.level === "error").length} cross-validation errors`));
|
|
144
211
|
console.log(` ${parts.join(ui.dim(" | "))}`);
|
|
145
212
|
console.log();
|
|
146
|
-
if (failed > 0)
|
|
213
|
+
if (failed > 0 || hasCrossErrors)
|
|
147
214
|
process.exit(1);
|
|
148
215
|
}
|
|
@@ -48,7 +48,7 @@ export async function browseByCategory(allSkills, providerName) {
|
|
|
48
48
|
};
|
|
49
49
|
});
|
|
50
50
|
const category = await p.select({
|
|
51
|
-
message: "Browse
|
|
51
|
+
message: "Browse > Select category",
|
|
52
52
|
options: [...categoryOptions, { value: "__back", label: "Back" }],
|
|
53
53
|
});
|
|
54
54
|
handleCancel(category);
|
|
@@ -85,7 +85,7 @@ async function categorySkillList(categoryName, skillNames, allSkills, providerNa
|
|
|
85
85
|
}
|
|
86
86
|
extraOptions.push({ value: "__back", label: "Back to categories" });
|
|
87
87
|
const picked = await p.select({
|
|
88
|
-
message:
|
|
88
|
+
message: `Browse > ${categoryName}`,
|
|
89
89
|
options: [...options, ...extraOptions],
|
|
90
90
|
});
|
|
91
91
|
handleCancel(picked);
|
|
@@ -3,40 +3,47 @@ import chalk from "chalk";
|
|
|
3
3
|
import { runDoctorChecks } from "../commands/doctor.js";
|
|
4
4
|
import { handleCancel } from "./helpers.js";
|
|
5
5
|
export async function checkHealth() {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
6
|
+
while (true) {
|
|
7
|
+
const checks = runDoctorChecks();
|
|
8
|
+
p.log.step(chalk.bold("Environment Health Check"));
|
|
9
|
+
for (const check of checks) {
|
|
10
|
+
const icon = check.status === "pass" ? chalk.green("OK") : check.status === "warn" ? chalk.yellow("!!") : chalk.red("XX");
|
|
11
|
+
p.log.info(`${icon} ${chalk.bold(check.name)}: ${check.message}`);
|
|
12
|
+
if (check.fix)
|
|
13
|
+
p.log.info(chalk.dim(` Fix: ${check.fix}`));
|
|
14
|
+
}
|
|
15
|
+
const fails = checks.filter((c) => c.status === "fail").length;
|
|
16
|
+
const warns = checks.filter((c) => c.status === "warn").length;
|
|
17
|
+
if (fails > 0) {
|
|
18
|
+
p.log.error(`${fails} issue${fails > 1 ? "s" : ""} found`);
|
|
19
|
+
}
|
|
20
|
+
else if (warns > 0) {
|
|
21
|
+
p.log.warn(`${warns} warning${warns > 1 ? "s" : ""}`);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
p.log.success("All checks passed");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const fixChecks = checks.filter((c) => c.fix && c.status !== "pass");
|
|
28
|
+
if (fixChecks.length === 0)
|
|
29
|
+
return;
|
|
30
|
+
const fixOptions = fixChecks.map((c) => {
|
|
31
|
+
const cmd = c.fix.replace(/^Run:\s*/, "");
|
|
32
|
+
return { value: cmd, label: `Run: ${cmd}`, hint: c.name };
|
|
33
|
+
});
|
|
34
|
+
const fixAction = await p.select({
|
|
35
|
+
message: "Run a fix?",
|
|
36
|
+
options: [
|
|
37
|
+
...fixOptions,
|
|
38
|
+
{ value: "__recheck", label: "Re-check now" },
|
|
39
|
+
{ value: "__back", label: "Back to menu" },
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
handleCancel(fixAction);
|
|
43
|
+
if (fixAction === "__back")
|
|
44
|
+
return;
|
|
45
|
+
if (fixAction === "__recheck")
|
|
46
|
+
continue;
|
|
40
47
|
const cmd = fixAction;
|
|
41
48
|
const SAFE_PREFIXES = ["arcana ", "git config "];
|
|
42
49
|
if (!SAFE_PREFIXES.some((pre) => cmd.startsWith(pre))) {
|
|
@@ -52,6 +59,6 @@ export async function checkHealth() {
|
|
|
52
59
|
// Non-zero exit expected for some commands
|
|
53
60
|
}
|
|
54
61
|
}
|
|
55
|
-
|
|
62
|
+
// Loop continues, re-runs checks automatically
|
|
56
63
|
}
|
|
57
64
|
}
|
|
@@ -4,6 +4,20 @@ export declare function handleCancel(value: unknown): void;
|
|
|
4
4
|
export declare function countInstalled(): number;
|
|
5
5
|
export declare function truncate(str: string, max: number): string;
|
|
6
6
|
export declare function getInstalledNames(): string[];
|
|
7
|
+
export declare function getTokenEstimate(skillName: string): {
|
|
8
|
+
tokens: number;
|
|
9
|
+
kb: number;
|
|
10
|
+
};
|
|
11
|
+
export declare function getTotalTokenBudget(): {
|
|
12
|
+
totalKB: number;
|
|
13
|
+
totalTokens: number;
|
|
14
|
+
count: number;
|
|
15
|
+
skills: {
|
|
16
|
+
name: string;
|
|
17
|
+
tokens: number;
|
|
18
|
+
kb: number;
|
|
19
|
+
}[];
|
|
20
|
+
};
|
|
7
21
|
export declare function buildMenuOptions(installedCount: number, _availableCount: number): {
|
|
8
22
|
value: string;
|
|
9
23
|
label: string;
|
|
@@ -2,7 +2,7 @@ import { existsSync, readdirSync, statSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import * as p from "@clack/prompts";
|
|
4
4
|
import chalk from "chalk";
|
|
5
|
-
import { getInstallDir } from "../utils/fs.js";
|
|
5
|
+
import { getInstallDir, getSkillDir, getDirSize } from "../utils/fs.js";
|
|
6
6
|
import { clearProviderCache } from "../registry.js";
|
|
7
7
|
export const AMBER = chalk.hex("#d4943a");
|
|
8
8
|
export function cancelAndExit() {
|
|
@@ -45,21 +45,48 @@ export function getInstalledNames() {
|
|
|
45
45
|
})
|
|
46
46
|
.sort();
|
|
47
47
|
}
|
|
48
|
+
export function getTokenEstimate(skillName) {
|
|
49
|
+
const dir = getSkillDir(skillName);
|
|
50
|
+
if (!existsSync(dir))
|
|
51
|
+
return { tokens: 0, kb: 0 };
|
|
52
|
+
const bytes = getDirSize(dir);
|
|
53
|
+
return { tokens: Math.round(bytes / 4), kb: Math.round(bytes / 1024) };
|
|
54
|
+
}
|
|
55
|
+
export function getTotalTokenBudget() {
|
|
56
|
+
const names = getInstalledNames();
|
|
57
|
+
const skills = names.map((name) => {
|
|
58
|
+
const est = getTokenEstimate(name);
|
|
59
|
+
return { name, tokens: est.tokens, kb: est.kb };
|
|
60
|
+
});
|
|
61
|
+
skills.sort((a, b) => b.tokens - a.tokens);
|
|
62
|
+
const totalKB = skills.reduce((sum, s) => sum + s.kb, 0);
|
|
63
|
+
const totalTokens = skills.reduce((sum, s) => sum + s.tokens, 0);
|
|
64
|
+
return { totalKB, totalTokens, count: names.length, skills };
|
|
65
|
+
}
|
|
48
66
|
export function buildMenuOptions(installedCount, _availableCount) {
|
|
49
67
|
const isNew = installedCount === 0;
|
|
50
68
|
const options = [];
|
|
51
69
|
if (isNew) {
|
|
52
|
-
options.push({
|
|
70
|
+
options.push({
|
|
71
|
+
value: "setup",
|
|
72
|
+
label: AMBER("Get Started"),
|
|
73
|
+
hint: "detect project, install recommended skills",
|
|
74
|
+
});
|
|
53
75
|
}
|
|
54
76
|
else {
|
|
55
|
-
options.push({
|
|
77
|
+
options.push({
|
|
78
|
+
value: "installed",
|
|
79
|
+
label: "Your skills",
|
|
80
|
+
hint: `${installedCount} installed, manage & update`,
|
|
81
|
+
});
|
|
56
82
|
}
|
|
57
|
-
options.push({ value: "browse", label: "Browse
|
|
58
|
-
options.push({ value: "search", label: "Search
|
|
83
|
+
options.push({ value: "browse", label: "Browse marketplace" });
|
|
84
|
+
options.push({ value: "search", label: "Search skills" });
|
|
59
85
|
if (!isNew) {
|
|
60
86
|
options.push({ value: "setup", label: "Get Started", hint: "detect project, add more skills" });
|
|
61
87
|
}
|
|
62
|
-
options.push({ value: "health", label: "
|
|
88
|
+
options.push({ value: "health", label: "Health check" });
|
|
89
|
+
options.push({ value: "optimize", label: "Token budget" });
|
|
63
90
|
options.push({ value: "ref", label: "CLI reference" });
|
|
64
91
|
options.push({ value: "exit", label: "Exit" });
|
|
65
92
|
return options;
|
|
@@ -35,7 +35,7 @@ export async function manageInstalled(allSkills, providerName) {
|
|
|
35
35
|
hint: `${g.skills.length} installed`,
|
|
36
36
|
}));
|
|
37
37
|
const picked = await p.select({
|
|
38
|
-
message: `
|
|
38
|
+
message: `Your skills > Select category`,
|
|
39
39
|
options: [
|
|
40
40
|
...options,
|
|
41
41
|
{ value: "__update", label: chalk.cyan("Check for updates") },
|
|
@@ -78,7 +78,7 @@ async function installedCategoryList(categoryName, installedNames, allSkills, pr
|
|
|
78
78
|
};
|
|
79
79
|
});
|
|
80
80
|
const picked = await p.select({
|
|
81
|
-
message:
|
|
81
|
+
message: `Your skills > ${categoryName}`,
|
|
82
82
|
options: [...options, { value: "__back", label: "Back" }],
|
|
83
83
|
});
|
|
84
84
|
handleCancel(picked);
|
|
@@ -108,7 +108,7 @@ async function bulkUninstall(installedNames) {
|
|
|
108
108
|
return;
|
|
109
109
|
let removed = 0;
|
|
110
110
|
for (const name of names) {
|
|
111
|
-
if (doUninstall(name))
|
|
111
|
+
if (doUninstall(name).success)
|
|
112
112
|
removed++;
|
|
113
113
|
}
|
|
114
114
|
p.log.success(`Removed ${removed} skill${removed !== 1 ? "s" : ""}`);
|
package/dist/interactive/menu.js
CHANGED
|
@@ -11,6 +11,7 @@ import { searchFlow } from "./search.js";
|
|
|
11
11
|
import { quickSetup } from "./setup.js";
|
|
12
12
|
import { manageInstalled } from "./manage.js";
|
|
13
13
|
import { checkHealth } from "./health.js";
|
|
14
|
+
import { optimizeInteractive } from "./optimize-flow.js";
|
|
14
15
|
export async function showInteractiveMenu(version) {
|
|
15
16
|
const config = loadConfig();
|
|
16
17
|
const providerName = config.defaultProvider;
|
|
@@ -93,6 +94,9 @@ export async function showInteractiveMenu(version) {
|
|
|
93
94
|
case "health":
|
|
94
95
|
await checkHealth();
|
|
95
96
|
break;
|
|
97
|
+
case "optimize":
|
|
98
|
+
await optimizeInteractive();
|
|
99
|
+
break;
|
|
96
100
|
case "ref":
|
|
97
101
|
p.note(getCliReference(), "CLI Reference");
|
|
98
102
|
break;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function optimizeInteractive(): Promise<void>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as p from "@clack/prompts";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { getTotalTokenBudget, AMBER, handleCancel } from "./helpers.js";
|
|
4
|
+
export async function optimizeInteractive() {
|
|
5
|
+
const budget = getTotalTokenBudget();
|
|
6
|
+
const barWidth = 30;
|
|
7
|
+
const maxTokens = 200_000;
|
|
8
|
+
const pct = Math.min(100, Math.round((budget.totalTokens / maxTokens) * 100));
|
|
9
|
+
const filled = Math.round((pct / 100) * barWidth);
|
|
10
|
+
const bar = AMBER("█".repeat(filled)) + chalk.dim("░".repeat(barWidth - filled));
|
|
11
|
+
const lines = [];
|
|
12
|
+
lines.push(`${bar} ${pct}% of 200K context window`);
|
|
13
|
+
lines.push("");
|
|
14
|
+
lines.push(`${chalk.bold(String(budget.count))} skills installed, ${chalk.bold(String(budget.totalKB))} KB total (~${(budget.totalTokens / 1000).toFixed(0)}K tokens)`);
|
|
15
|
+
if (budget.skills.length > 0) {
|
|
16
|
+
lines.push("");
|
|
17
|
+
lines.push(chalk.dim("Largest skills:"));
|
|
18
|
+
for (const s of budget.skills.slice(0, 7)) {
|
|
19
|
+
const pctOfTotal = budget.totalTokens > 0 ? Math.round((s.tokens / budget.totalTokens) * 100) : 0;
|
|
20
|
+
lines.push(` ${s.name.padEnd(32)} ${String(s.kb).padStart(4)} KB ${String(pctOfTotal).padStart(3)}%`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
p.note(lines.join("\n"), "Token Budget");
|
|
24
|
+
const action = await p.select({
|
|
25
|
+
message: "What next?",
|
|
26
|
+
options: [
|
|
27
|
+
{ value: "full", label: "Run full optimization report" },
|
|
28
|
+
{ value: "__back", label: "Back" },
|
|
29
|
+
],
|
|
30
|
+
});
|
|
31
|
+
handleCancel(action);
|
|
32
|
+
if (action === "full") {
|
|
33
|
+
const { optimizeCommand } = await import("../commands/optimize.js");
|
|
34
|
+
await optimizeCommand({ json: false });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -51,7 +51,7 @@ async function searchResultsPicker(results, allSkills, providerName) {
|
|
|
51
51
|
hint: truncate(skill.description, 50),
|
|
52
52
|
}));
|
|
53
53
|
const picked = await p.select({
|
|
54
|
-
message:
|
|
54
|
+
message: `Search > Results`,
|
|
55
55
|
options: [...options, { value: "__search", label: "Search again" }, { value: "__back", label: "Back" }],
|
|
56
56
|
});
|
|
57
57
|
handleCancel(picked);
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { SkillInfo } from "../types.js";
|
|
2
2
|
declare function doInstall(skillName: string, providerName: string): Promise<boolean>;
|
|
3
|
-
declare function doUninstall(skillName: string):
|
|
3
|
+
declare function doUninstall(skillName: string): {
|
|
4
|
+
success: boolean;
|
|
5
|
+
backupPath?: string;
|
|
6
|
+
};
|
|
4
7
|
export declare function skillDetailFlow(skillName: string, allSkills: SkillInfo[], providerName: string): Promise<"back" | "menu">;
|
|
5
8
|
export { doInstall, doUninstall };
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { existsSync, rmSync } from "node:fs";
|
|
2
2
|
import * as p from "@clack/prompts";
|
|
3
3
|
import chalk from "chalk";
|
|
4
|
-
import { isSkillInstalled, readSkillMeta, getSkillDir } from "../utils/fs.js";
|
|
5
|
-
import { getProvider } from "../registry.js";
|
|
4
|
+
import { isSkillInstalled, readSkillMeta, getSkillDir, getDirSize } from "../utils/fs.js";
|
|
6
5
|
import { appendHistory } from "../utils/history.js";
|
|
7
6
|
import { installOneCore } from "../utils/install-core.js";
|
|
7
|
+
import { backupSkill } from "../utils/backup.js";
|
|
8
8
|
import { removeSymlinksFor } from "../commands/uninstall.js";
|
|
9
9
|
import { ui } from "../utils/ui.js";
|
|
10
|
+
import { getProvider } from "../registry.js";
|
|
10
11
|
import { handleCancel } from "./helpers.js";
|
|
11
12
|
import { getCategoryFor, getRelatedSkills } from "./categories.js";
|
|
12
13
|
async function doInstall(skillName, providerName) {
|
|
@@ -35,69 +36,93 @@ async function doInstall(skillName, providerName) {
|
|
|
35
36
|
function doUninstall(skillName) {
|
|
36
37
|
const skillDir = getSkillDir(skillName);
|
|
37
38
|
if (!existsSync(skillDir))
|
|
38
|
-
return false;
|
|
39
|
+
return { success: false };
|
|
39
40
|
try {
|
|
41
|
+
const backupPath = backupSkill(skillName);
|
|
40
42
|
rmSync(skillDir, { recursive: true, force: true });
|
|
41
43
|
removeSymlinksFor(skillName);
|
|
42
44
|
appendHistory("uninstall", skillName);
|
|
43
|
-
return true;
|
|
45
|
+
return { success: true, backupPath: backupPath ?? undefined };
|
|
44
46
|
}
|
|
45
47
|
catch {
|
|
46
|
-
return false;
|
|
48
|
+
return { success: false };
|
|
47
49
|
}
|
|
48
50
|
}
|
|
51
|
+
function getTokenEstimate(skillName) {
|
|
52
|
+
const dir = getSkillDir(skillName);
|
|
53
|
+
if (!existsSync(dir))
|
|
54
|
+
return { tokens: 0, kb: 0 };
|
|
55
|
+
const bytes = getDirSize(dir);
|
|
56
|
+
return { tokens: Math.round(bytes / 4), kb: Math.round(bytes / 1024) };
|
|
57
|
+
}
|
|
49
58
|
export async function skillDetailFlow(skillName, allSkills, providerName) {
|
|
50
59
|
const info = allSkills.find((s) => s.name === skillName);
|
|
51
60
|
const installed = isSkillInstalled(skillName);
|
|
52
61
|
const meta = installed ? readSkillMeta(skillName) : null;
|
|
53
|
-
// Build info block
|
|
62
|
+
// Build info block with visual hierarchy
|
|
54
63
|
const lines = [];
|
|
55
|
-
|
|
64
|
+
// Header
|
|
65
|
+
lines.push(`${chalk.bold(skillName)} ${info ? chalk.dim(`v${info.version}`) : ""}`);
|
|
56
66
|
if (info?.description)
|
|
57
|
-
lines.push(info.description);
|
|
67
|
+
lines.push(chalk.dim(info.description));
|
|
58
68
|
lines.push("");
|
|
69
|
+
// Status (most important, shown first)
|
|
70
|
+
if (installed && meta) {
|
|
71
|
+
const date = meta.installedAt ? new Date(meta.installedAt).toLocaleDateString() : "";
|
|
72
|
+
const est = getTokenEstimate(skillName);
|
|
73
|
+
const tokenStr = est.tokens > 0 ? ` ~${(est.tokens / 1000).toFixed(1)}K tokens` : "";
|
|
74
|
+
lines.push(`${chalk.green("Installed")} v${meta.version}${date ? ` ${date}` : ""}${tokenStr}`);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
lines.push(chalk.dim("Not installed"));
|
|
78
|
+
}
|
|
79
|
+
lines.push("");
|
|
80
|
+
// Aligned metadata
|
|
81
|
+
const metadata = [];
|
|
59
82
|
if (info?.verified)
|
|
60
|
-
|
|
83
|
+
metadata.push(["Trust", chalk.green("Verified (official)")]);
|
|
61
84
|
else
|
|
62
|
-
|
|
85
|
+
metadata.push(["Trust", "Community"]);
|
|
63
86
|
if (info?.author)
|
|
64
|
-
|
|
87
|
+
metadata.push(["Author", info.author]);
|
|
65
88
|
if (info?.tags && info.tags.length > 0)
|
|
66
|
-
|
|
89
|
+
metadata.push(["Tags", info.tags.join(", ")]);
|
|
67
90
|
const category = getCategoryFor(skillName);
|
|
68
91
|
if (category)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
lines.push(`Companions: ${info.companions.join(", ")}`);
|
|
74
|
-
}
|
|
75
|
-
if (info?.conflicts && info.conflicts.length > 0) {
|
|
76
|
-
lines.push(`${chalk.red("Conflicts:")} ${info.conflicts.join(", ")}`);
|
|
77
|
-
}
|
|
78
|
-
if (installed && meta) {
|
|
79
|
-
const date = meta.installedAt ? new Date(meta.installedAt).toLocaleDateString() : "";
|
|
80
|
-
lines.push(`Status: ${chalk.green("installed")} (v${meta.version}${date ? `, ${date}` : ""})`);
|
|
81
|
-
}
|
|
82
|
-
else {
|
|
83
|
-
lines.push(`Status: ${chalk.dim("not installed")}`);
|
|
92
|
+
metadata.push(["Category", category]);
|
|
93
|
+
const maxLabel = Math.max(...metadata.map(([k]) => k.length));
|
|
94
|
+
for (const [key, val] of metadata) {
|
|
95
|
+
lines.push(`${chalk.dim(key.padEnd(maxLabel + 1))} ${val}`);
|
|
84
96
|
}
|
|
97
|
+
// Relations
|
|
85
98
|
const related = getRelatedSkills(skillName);
|
|
86
|
-
|
|
87
|
-
|
|
99
|
+
const hasRelations = (info?.companions && info.companions.length > 0) ||
|
|
100
|
+
(info?.conflicts && info.conflicts.length > 0) ||
|
|
101
|
+
related.length > 0;
|
|
102
|
+
if (hasRelations) {
|
|
103
|
+
lines.push("");
|
|
104
|
+
if (info?.companions && info.companions.length > 0) {
|
|
105
|
+
lines.push(`${chalk.dim("Works with:")} ${info.companions.join(", ")}`);
|
|
106
|
+
}
|
|
107
|
+
if (info?.conflicts && info.conflicts.length > 0) {
|
|
108
|
+
lines.push(`${chalk.red("Conflicts:")} ${info.conflicts.join(", ")}`);
|
|
109
|
+
}
|
|
110
|
+
if (related.length > 0) {
|
|
111
|
+
lines.push(`${chalk.dim("Related:")} ${related.join(", ")}`);
|
|
112
|
+
}
|
|
88
113
|
}
|
|
89
114
|
p.note(lines.join("\n"), skillName);
|
|
90
115
|
// Action menu
|
|
91
116
|
const actions = [];
|
|
92
117
|
if (installed) {
|
|
93
|
-
actions.push({ value: "reinstall", label: "
|
|
94
|
-
actions.push({ value: "uninstall", label: "Uninstall
|
|
118
|
+
actions.push({ value: "reinstall", label: "Update to latest" });
|
|
119
|
+
actions.push({ value: "uninstall", label: "Uninstall" });
|
|
95
120
|
}
|
|
96
121
|
else {
|
|
97
122
|
actions.push({ value: "install", label: "Install this skill" });
|
|
98
123
|
}
|
|
99
|
-
actions.push({ value: "
|
|
100
|
-
const action = await p.select({ message:
|
|
124
|
+
actions.push({ value: "__back", label: "Back" });
|
|
125
|
+
const action = await p.select({ message: `${skillName} > Action`, options: actions });
|
|
101
126
|
handleCancel(action);
|
|
102
127
|
switch (action) {
|
|
103
128
|
case "install":
|
|
@@ -106,12 +131,21 @@ export async function skillDetailFlow(skillName, allSkills, providerName) {
|
|
|
106
131
|
return "back";
|
|
107
132
|
}
|
|
108
133
|
case "uninstall": {
|
|
134
|
+
// Dry-run preview
|
|
135
|
+
const skillDir = getSkillDir(skillName);
|
|
136
|
+
const size = getDirSize(skillDir);
|
|
137
|
+
p.log.info(chalk.dim(` Will remove: ${skillDir}`));
|
|
138
|
+
p.log.info(chalk.dim(` Size: ${(size / 1024).toFixed(0)} KB (${meta?.fileCount ?? "?"} files)`));
|
|
139
|
+
p.log.info(chalk.dim(` A backup will be created before removal.`));
|
|
109
140
|
const ok = await p.confirm({ message: `Uninstall ${chalk.bold(skillName)}?` });
|
|
110
141
|
handleCancel(ok);
|
|
111
142
|
if (ok) {
|
|
112
|
-
const
|
|
113
|
-
if (success) {
|
|
143
|
+
const result = doUninstall(skillName);
|
|
144
|
+
if (result.success) {
|
|
114
145
|
p.log.success(`Removed ${chalk.bold(skillName)}`);
|
|
146
|
+
if (result.backupPath) {
|
|
147
|
+
p.log.info(chalk.dim(` Backup: ${result.backupPath}`));
|
|
148
|
+
}
|
|
115
149
|
}
|
|
116
150
|
else {
|
|
117
151
|
p.log.error(`Failed to remove ${skillName}`);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, cpSync, readdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { getSkillDir } from "./fs.js";
|
|
5
|
+
const BACKUP_DIR = join(homedir(), ".arcana", "backups");
|
|
6
|
+
const MAX_BACKUPS_PER_SKILL = 10;
|
|
7
|
+
export function getBackupDir() {
|
|
8
|
+
return BACKUP_DIR;
|
|
9
|
+
}
|
|
10
|
+
export function backupSkill(skillName) {
|
|
11
|
+
const skillDir = getSkillDir(skillName);
|
|
12
|
+
if (!existsSync(skillDir))
|
|
13
|
+
return null;
|
|
14
|
+
mkdirSync(BACKUP_DIR, { recursive: true });
|
|
15
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
16
|
+
const dest = join(BACKUP_DIR, `${skillName}_${timestamp}`);
|
|
17
|
+
cpSync(skillDir, dest, { recursive: true });
|
|
18
|
+
pruneOldBackups(skillName);
|
|
19
|
+
return dest;
|
|
20
|
+
}
|
|
21
|
+
export function pruneOldBackups(skillName) {
|
|
22
|
+
if (!existsSync(BACKUP_DIR))
|
|
23
|
+
return;
|
|
24
|
+
const prefix = `${skillName}_`;
|
|
25
|
+
const entries = readdirSync(BACKUP_DIR)
|
|
26
|
+
.filter((d) => d.startsWith(prefix))
|
|
27
|
+
.sort();
|
|
28
|
+
while (entries.length > MAX_BACKUPS_PER_SKILL) {
|
|
29
|
+
const oldest = entries.shift();
|
|
30
|
+
rmSync(join(BACKUP_DIR, oldest), { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -138,13 +138,16 @@ export function validateSkillDir(skillDir, skillName) {
|
|
|
138
138
|
return result;
|
|
139
139
|
}
|
|
140
140
|
if (!parsed.description) {
|
|
141
|
-
result.
|
|
141
|
+
result.valid = false;
|
|
142
|
+
result.errors.push("Missing description in frontmatter");
|
|
142
143
|
}
|
|
143
144
|
else if (parsed.description.length < MIN_DESC_LENGTH) {
|
|
144
|
-
result.
|
|
145
|
+
result.valid = false;
|
|
146
|
+
result.errors.push(`Description too short (${parsed.description.length} chars, minimum ${MIN_DESC_LENGTH})`);
|
|
145
147
|
}
|
|
146
148
|
else if (parsed.description.length > MAX_DESC_LENGTH) {
|
|
147
|
-
result.
|
|
149
|
+
result.valid = false;
|
|
150
|
+
result.errors.push(`Description too long (${parsed.description.length} chars, max ${MAX_DESC_LENGTH})`);
|
|
148
151
|
}
|
|
149
152
|
// Check for non-standard fields (metadata is invalid per spec)
|
|
150
153
|
const standardFields = ["name", "description"];
|
|
@@ -181,11 +184,11 @@ export function validateSkillDir(skillDir, skillName) {
|
|
|
181
184
|
result.warnings.push("SKILL.md body is very short (less than 50 chars)");
|
|
182
185
|
}
|
|
183
186
|
if (extracted.body.trim().length >= 50 && !extracted.body.includes("##")) {
|
|
184
|
-
result.
|
|
187
|
+
result.warnings.push("Body has no ## headings (required for structure)");
|
|
185
188
|
}
|
|
186
189
|
// Check for code blocks (quality signal)
|
|
187
190
|
if (extracted.body.trim().length >= 50 && !extracted.body.includes("```")) {
|
|
188
|
-
result.
|
|
191
|
+
result.warnings.push("No code blocks found (skills must include code examples)");
|
|
189
192
|
}
|
|
190
193
|
// Check for BAD/GOOD pattern examples
|
|
191
194
|
const hasPattern = /(?:BAD|GOOD|WRONG|RIGHT|AVOID|PREFER|DO NOT|INSTEAD)/i.test(extracted.body) ||
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { MarketplacePlugin } from "../types.js";
|
|
2
|
+
export interface CrossValidationIssue {
|
|
3
|
+
level: "error" | "warning";
|
|
4
|
+
category: "marketplace-drift" | "orphan" | "companion" | "duplicate-desc";
|
|
5
|
+
skill: string;
|
|
6
|
+
detail: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Jaccard word-level similarity between two strings.
|
|
10
|
+
* Returns 0.0 (completely different) to 1.0 (identical).
|
|
11
|
+
*/
|
|
12
|
+
export declare function jaccardSimilarity(a: string, b: string): number;
|
|
13
|
+
/**
|
|
14
|
+
* Validate companion references in marketplace plugins.
|
|
15
|
+
* Every companion must reference an existing plugin name.
|
|
16
|
+
*/
|
|
17
|
+
export declare function validateCompanions(plugins: MarketplacePlugin[]): CrossValidationIssue[];
|
|
18
|
+
/**
|
|
19
|
+
* Check description sync between SKILL.md frontmatter and marketplace.json.
|
|
20
|
+
* Returns an issue if similarity is below 0.5.
|
|
21
|
+
*/
|
|
22
|
+
export declare function validateDescriptionSync(skillName: string, frontmatterDesc: string, marketplaceDesc: string): CrossValidationIssue | null;
|
|
23
|
+
/**
|
|
24
|
+
* Cross-validate skill directories against marketplace.json.
|
|
25
|
+
* Checks: orphans, companions, description drift, near-duplicates.
|
|
26
|
+
*/
|
|
27
|
+
export declare function crossValidate(skillsDir: string, marketplacePath: string): CrossValidationIssue[];
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { extractFrontmatter, parseFrontmatter } from "./frontmatter.js";
|
|
4
|
+
/**
|
|
5
|
+
* Jaccard word-level similarity between two strings.
|
|
6
|
+
* Returns 0.0 (completely different) to 1.0 (identical).
|
|
7
|
+
*/
|
|
8
|
+
export function jaccardSimilarity(a, b) {
|
|
9
|
+
const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(Boolean));
|
|
10
|
+
const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(Boolean));
|
|
11
|
+
if (wordsA.size === 0 && wordsB.size === 0)
|
|
12
|
+
return 1.0;
|
|
13
|
+
if (wordsA.size === 0 || wordsB.size === 0)
|
|
14
|
+
return 0.0;
|
|
15
|
+
let intersection = 0;
|
|
16
|
+
for (const w of wordsA) {
|
|
17
|
+
if (wordsB.has(w))
|
|
18
|
+
intersection++;
|
|
19
|
+
}
|
|
20
|
+
const union = wordsA.size + wordsB.size - intersection;
|
|
21
|
+
return union === 0 ? 0 : intersection / union;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Validate companion references in marketplace plugins.
|
|
25
|
+
* Every companion must reference an existing plugin name.
|
|
26
|
+
*/
|
|
27
|
+
export function validateCompanions(plugins) {
|
|
28
|
+
const issues = [];
|
|
29
|
+
const names = new Set(plugins.map((p) => p.name));
|
|
30
|
+
for (const plugin of plugins) {
|
|
31
|
+
if (!plugin.companions)
|
|
32
|
+
continue;
|
|
33
|
+
for (const companion of plugin.companions) {
|
|
34
|
+
if (!names.has(companion)) {
|
|
35
|
+
issues.push({
|
|
36
|
+
level: "error",
|
|
37
|
+
category: "companion",
|
|
38
|
+
skill: plugin.name,
|
|
39
|
+
detail: `Companion "${companion}" does not exist in marketplace`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return issues;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Check description sync between SKILL.md frontmatter and marketplace.json.
|
|
48
|
+
* Returns an issue if similarity is below 0.5.
|
|
49
|
+
*/
|
|
50
|
+
export function validateDescriptionSync(skillName, frontmatterDesc, marketplaceDesc) {
|
|
51
|
+
if (!frontmatterDesc || !marketplaceDesc)
|
|
52
|
+
return null;
|
|
53
|
+
const similarity = jaccardSimilarity(frontmatterDesc, marketplaceDesc);
|
|
54
|
+
if (similarity < 0.5) {
|
|
55
|
+
return {
|
|
56
|
+
level: "warning",
|
|
57
|
+
category: "marketplace-drift",
|
|
58
|
+
skill: skillName,
|
|
59
|
+
detail: `Description drift (${Math.round(similarity * 100)}% similarity) between SKILL.md and marketplace.json`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Cross-validate skill directories against marketplace.json.
|
|
66
|
+
* Checks: orphans, companions, description drift, near-duplicates.
|
|
67
|
+
*/
|
|
68
|
+
export function crossValidate(skillsDir, marketplacePath) {
|
|
69
|
+
const issues = [];
|
|
70
|
+
// Load marketplace
|
|
71
|
+
let marketplace;
|
|
72
|
+
try {
|
|
73
|
+
marketplace = JSON.parse(readFileSync(marketplacePath, "utf-8"));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
issues.push({
|
|
77
|
+
level: "error",
|
|
78
|
+
category: "orphan",
|
|
79
|
+
skill: "marketplace.json",
|
|
80
|
+
detail: "Cannot read or parse marketplace.json",
|
|
81
|
+
});
|
|
82
|
+
return issues;
|
|
83
|
+
}
|
|
84
|
+
const pluginNames = new Set(marketplace.plugins.map((p) => p.name));
|
|
85
|
+
const pluginMap = new Map(marketplace.plugins.map((p) => [p.name, p]));
|
|
86
|
+
// Get skill directories
|
|
87
|
+
let skillDirs;
|
|
88
|
+
try {
|
|
89
|
+
skillDirs = readdirSync(skillsDir).filter((d) => {
|
|
90
|
+
try {
|
|
91
|
+
return statSync(join(skillsDir, d)).isDirectory();
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
issues.push({
|
|
100
|
+
level: "error",
|
|
101
|
+
category: "orphan",
|
|
102
|
+
skill: "skills/",
|
|
103
|
+
detail: "Cannot read skills directory",
|
|
104
|
+
});
|
|
105
|
+
return issues;
|
|
106
|
+
}
|
|
107
|
+
const dirNames = new Set(skillDirs);
|
|
108
|
+
// 1. Orphan directories (dir exists, no marketplace entry)
|
|
109
|
+
for (const dir of skillDirs) {
|
|
110
|
+
if (!pluginNames.has(dir)) {
|
|
111
|
+
issues.push({
|
|
112
|
+
level: "error",
|
|
113
|
+
category: "orphan",
|
|
114
|
+
skill: dir,
|
|
115
|
+
detail: "Skill directory exists but no marketplace.json entry",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// 2. Orphan entries (marketplace entry, no dir)
|
|
120
|
+
for (const name of pluginNames) {
|
|
121
|
+
if (!dirNames.has(name)) {
|
|
122
|
+
issues.push({
|
|
123
|
+
level: "error",
|
|
124
|
+
category: "orphan",
|
|
125
|
+
skill: name,
|
|
126
|
+
detail: "Marketplace entry exists but no skill directory",
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// 3. Companion validation
|
|
131
|
+
issues.push(...validateCompanions(marketplace.plugins));
|
|
132
|
+
// 4. Description drift (SKILL.md frontmatter vs marketplace.json)
|
|
133
|
+
for (const dir of skillDirs) {
|
|
134
|
+
const plugin = pluginMap.get(dir);
|
|
135
|
+
if (!plugin)
|
|
136
|
+
continue;
|
|
137
|
+
const skillMd = join(skillsDir, dir, "SKILL.md");
|
|
138
|
+
if (!existsSync(skillMd))
|
|
139
|
+
continue;
|
|
140
|
+
try {
|
|
141
|
+
const content = readFileSync(skillMd, "utf-8");
|
|
142
|
+
const extracted = extractFrontmatter(content);
|
|
143
|
+
if (!extracted)
|
|
144
|
+
continue;
|
|
145
|
+
const parsed = parseFrontmatter(extracted.raw);
|
|
146
|
+
if (!parsed?.description)
|
|
147
|
+
continue;
|
|
148
|
+
const driftIssue = validateDescriptionSync(dir, parsed.description, plugin.description);
|
|
149
|
+
if (driftIssue)
|
|
150
|
+
issues.push(driftIssue);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
/* skip unreadable */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// 5. Near-duplicate descriptions across skills
|
|
157
|
+
const descriptions = marketplace.plugins.map((p) => ({ name: p.name, desc: p.description }));
|
|
158
|
+
for (let i = 0; i < descriptions.length; i++) {
|
|
159
|
+
for (let j = i + 1; j < descriptions.length; j++) {
|
|
160
|
+
const a = descriptions[i];
|
|
161
|
+
const b = descriptions[j];
|
|
162
|
+
const sim = jaccardSimilarity(a.desc, b.desc);
|
|
163
|
+
if (sim > 0.85) {
|
|
164
|
+
issues.push({
|
|
165
|
+
level: "warning",
|
|
166
|
+
category: "duplicate-desc",
|
|
167
|
+
skill: a.name,
|
|
168
|
+
detail: `Near-duplicate description with ${b.name} (${Math.round(sim * 100)}% similarity)`,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return issues;
|
|
174
|
+
}
|