@sporesec/arcana 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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);
@@ -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 {};
@@ -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
- // Rating
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 >= 85)
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 installDir = getInstallDir();
95
- if (!existsSync(installDir)) {
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(installDir).filter((d) => statSync(join(installDir, d)).isDirectory());
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(installDir, name);
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}/100)`)}`);
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})`)}` : "";
@@ -2,4 +2,7 @@ export declare function validateCommand(skill: string | undefined, opts: {
2
2
  all?: boolean;
3
3
  fix?: boolean;
4
4
  json?: boolean;
5
+ source?: string;
6
+ cross?: boolean;
7
+ minScore?: number;
5
8
  }): Promise<void>;
@@ -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 installDir = getInstallDir();
12
- if (!existsSync(installDir)) {
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(installDir).filter((d) => statSync(join(installDir, d)).isDirectory());
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(installDir, name);
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
- results.push(result);
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
- console.log(JSON.stringify({
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
- }, null, 2));
101
- if (results.some((r) => !r.valid))
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
- console.log(` ${icon} ${ui.bold(r.skill)}${fixTag}`);
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
  }
@@ -138,13 +138,16 @@ export function validateSkillDir(skillDir, skillName) {
138
138
  return result;
139
139
  }
140
140
  if (!parsed.description) {
141
- result.warnings.push("Missing description in frontmatter");
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.warnings.push(`Description too short (${parsed.description.length} chars, recommend ${MIN_DESC_LENGTH}+)`);
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.warnings.push(`Description too long (${parsed.description.length} chars, max ${MAX_DESC_LENGTH})`);
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.infos.push("Body has no ## headings (recommended for structure)");
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.infos.push("No code blocks found (procedural skills should include code examples)");
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sporesec/arcana",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Universal AI development CLI. Skills, scaffolding, diagnostics, and analytics for every agent.",
5
5
  "bin": {
6
6
  "arcana": "dist/index.js"