@loom-skills/cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.js +1019 -0
  3. package/package.json +41 -3
  4. package/README.md +0 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Loom
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/index.js ADDED
@@ -0,0 +1,1019 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Command as Command9 } from "commander";
5
+
6
+ // src/commands/install.ts
7
+ import { Command } from "commander";
8
+ import chalk from "chalk";
9
+ import ora from "ora";
10
+ import { existsSync, mkdirSync, cpSync, writeFileSync, readFileSync } from "node:fs";
11
+ import { join, resolve } from "node:path";
12
+ var installCommand = new Command("install").description("Install a skill package").argument("<package>", "Package name or path").option("-g, --global", "Install globally").option("-f, --force", "Force reinstall").action(async (packagePath, options) => {
13
+ const spinner = ora("Installing skill...").start();
14
+ try {
15
+ const targetDir = options.global ? join(process.env.HOME || "~", ".loom", "skills") : join(process.cwd(), ".loom", "skills");
16
+ if (!existsSync(targetDir)) {
17
+ mkdirSync(targetDir, { recursive: true });
18
+ }
19
+ let sourcePath;
20
+ if (packagePath.startsWith("./") || packagePath.startsWith("/")) {
21
+ sourcePath = resolve(packagePath);
22
+ } else {
23
+ sourcePath = join(process.cwd(), "skills", packagePath.replace(/^@[^/]+\//, ""));
24
+ }
25
+ if (!existsSync(sourcePath)) {
26
+ throw new Error(`Package not found: ${packagePath}`);
27
+ }
28
+ const yamlPath = join(sourcePath, "SKILL.yaml");
29
+ if (!existsSync(yamlPath)) {
30
+ throw new Error(`No SKILL.yaml found in: ${sourcePath}`);
31
+ }
32
+ const yamlContent = readFileSync(yamlPath, "utf-8");
33
+ const nameMatch = yamlContent.match(/name:\s*"?([^"\n]+)"?/);
34
+ const versionMatch = yamlContent.match(/version:\s*"?([^"\n]+)"?/);
35
+ const skillName = nameMatch?.[1] || packagePath.split("/").pop() || "unknown";
36
+ const skillVersion = versionMatch?.[1] || "0.0.0";
37
+ const skillDir = join(targetDir, skillName.replace(/^@[^/]+\//, ""));
38
+ if (existsSync(skillDir) && !options.force) {
39
+ spinner.warn(`Skill already installed: ${skillName}`);
40
+ return;
41
+ }
42
+ cpSync(sourcePath, skillDir, { recursive: true });
43
+ const manifest = {
44
+ name: skillName,
45
+ version: skillVersion,
46
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
47
+ source: sourcePath
48
+ };
49
+ writeFileSync(join(skillDir, ".loom-manifest.json"), JSON.stringify(manifest, null, 2));
50
+ spinner.succeed(chalk.green(`Installed ${skillName}@${skillVersion}`));
51
+ const providesMatch = yamlContent.match(/provides:\s*\n([\s\S]*?)(?=\nrequires:|\ncontext:|\ntags:|$)/);
52
+ if (providesMatch) {
53
+ console.log("\n" + chalk.bold("Capabilities:"));
54
+ const capLines = providesMatch[1].split("\n").filter((l) => l.includes("id:"));
55
+ for (const line of capLines) {
56
+ const capId = line.match(/id:\s*(\S+)/)?.[1];
57
+ if (capId) console.log(` ${chalk.cyan(capId)}`);
58
+ }
59
+ }
60
+ } catch (error) {
61
+ spinner.fail(chalk.red(`Failed to install: ${error instanceof Error ? error.message : error}`));
62
+ process.exit(1);
63
+ }
64
+ });
65
+
66
+ // src/commands/list.ts
67
+ import { Command as Command2 } from "commander";
68
+ import chalk2 from "chalk";
69
+ import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2 } from "node:fs";
70
+ import { join as join2 } from "node:path";
71
+ var listCommand = new Command2("list").description("List installed skills").option("-g, --global", "List global skills").option("--json", "Output as JSON").action((options) => {
72
+ const skillsDir = options.global ? join2(process.env.HOME || "~", ".loom", "skills") : join2(process.cwd(), ".loom", "skills");
73
+ if (!existsSync2(skillsDir)) {
74
+ console.log(chalk2.yellow("No skills installed."));
75
+ return;
76
+ }
77
+ const skillDirs = readdirSync(skillsDir).filter(
78
+ (f) => existsSync2(join2(skillsDir, f, "SKILL.yaml"))
79
+ );
80
+ if (skillDirs.length === 0) {
81
+ console.log(chalk2.yellow("No skills installed."));
82
+ return;
83
+ }
84
+ if (options.json) {
85
+ const skills = skillDirs.map((dir) => {
86
+ const manifestPath = join2(skillsDir, dir, ".loom-manifest.json");
87
+ if (existsSync2(manifestPath)) {
88
+ return JSON.parse(readFileSync2(manifestPath, "utf-8"));
89
+ }
90
+ return { name: dir, version: "unknown" };
91
+ });
92
+ console.log(JSON.stringify(skills, null, 2));
93
+ return;
94
+ }
95
+ console.log(chalk2.bold("\nInstalled Skills:\n"));
96
+ for (const dir of skillDirs) {
97
+ const manifestPath = join2(skillsDir, dir, ".loom-manifest.json");
98
+ let name = dir;
99
+ let version = "unknown";
100
+ let installedAt = "";
101
+ if (existsSync2(manifestPath)) {
102
+ const manifest = JSON.parse(readFileSync2(manifestPath, "utf-8"));
103
+ name = manifest.name || dir;
104
+ version = manifest.version || "unknown";
105
+ installedAt = manifest.installedAt || "";
106
+ }
107
+ console.log(`${chalk2.green("\u2713")} ${chalk2.bold(name)}@${chalk2.cyan(version)}`);
108
+ if (installedAt) {
109
+ console.log(` Installed: ${chalk2.gray(new Date(installedAt).toLocaleDateString())}`);
110
+ }
111
+ console.log();
112
+ }
113
+ });
114
+
115
+ // src/commands/compose.ts
116
+ import { Command as Command3 } from "commander";
117
+ import chalk3 from "chalk";
118
+ import ora2 from "ora";
119
+ import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync as readdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
120
+ import { join as join3 } from "node:path";
121
+ function resolveSkillPath(skillsDir, name) {
122
+ const shortName = name.replace(/^@[^/]+\//, "");
123
+ const flatPath = join3(skillsDir, shortName);
124
+ if (existsSync3(join3(flatPath, "SKILL.yaml"))) {
125
+ return flatPath;
126
+ }
127
+ const categories = existsSync3(skillsDir) ? readdirSync2(skillsDir).filter((f) => existsSync3(join3(skillsDir, f))) : [];
128
+ for (const category of categories) {
129
+ const candidate = join3(skillsDir, category, shortName);
130
+ if (existsSync3(join3(candidate, "SKILL.yaml"))) {
131
+ return candidate;
132
+ }
133
+ }
134
+ return null;
135
+ }
136
+ function parseSkillYaml(yamlPath) {
137
+ const content = readFileSync3(yamlPath, "utf-8");
138
+ const nameMatch = content.match(/name:\s*"?([^"\n]+)"?/);
139
+ const versionMatch = content.match(/version:\s*"?([^"\n]+)"?/);
140
+ const descMatch = content.match(/description:\s*"?([^"\n]+)"?/);
141
+ const provides = [];
142
+ const providesSection = content.match(/provides:\s*\n([\s\S]*?)(?=\nrequires:|\ncontext:|\ntags:|$)/);
143
+ if (providesSection) {
144
+ const capBlocks = providesSection[1].split(/(?=^\s*- id:)/m);
145
+ for (const block of capBlocks) {
146
+ const idMatch = block.match(/id:\s*(\S+)/);
147
+ const descMatch2 = block.match(/description:\s*"?([^"\n]+)"?/);
148
+ if (idMatch) {
149
+ provides.push({ id: idMatch[1], description: descMatch2?.[1] || "" });
150
+ }
151
+ }
152
+ }
153
+ const quality = [];
154
+ const qualitySection = content.match(/quality:\s*\n([\s\S]*?)(?=\nreferences:|\ntags:|$)/);
155
+ if (qualitySection) {
156
+ const gateBlocks = qualitySection[1].split(/(?=^\s*- id:)/m);
157
+ for (const block of gateBlocks) {
158
+ const idMatch = block.match(/id:\s*(\S+)/);
159
+ const descMatch2 = block.match(/description:\s*"?([^"\n]+)"?/);
160
+ const sevMatch = block.match(/severity:\s*(\S+)/);
161
+ if (idMatch) {
162
+ quality.push({
163
+ id: idMatch[1],
164
+ description: descMatch2?.[1] || "",
165
+ severity: sevMatch?.[1] || "warning"
166
+ });
167
+ }
168
+ }
169
+ }
170
+ return {
171
+ name: nameMatch?.[1] || "unknown",
172
+ version: versionMatch?.[1] || "0.0.0",
173
+ description: descMatch?.[1] || "",
174
+ provides,
175
+ quality
176
+ };
177
+ }
178
+ var composeCommand = new Command3("compose").description("Compose multiple skills into a workflow").argument("<skills...>", "Skills to compose").option("-o, --output <file>", "Output file").option("--dry-run", "Show composition without writing").action((skillNames, options) => {
179
+ const spinner = ora2("Composing skills...").start();
180
+ try {
181
+ const skills = [];
182
+ const skillsDir = join3(process.cwd(), "skills");
183
+ for (const name of skillNames) {
184
+ const skillPath = resolveSkillPath(skillsDir, name);
185
+ if (!skillPath) {
186
+ throw new Error(`Skill not found: ${name}`);
187
+ }
188
+ skills.push(parseSkillYaml(join3(skillPath, "SKILL.yaml")));
189
+ }
190
+ spinner.stop();
191
+ const allCapabilities = skills.flatMap((s) => s.provides);
192
+ const allQuality = skills.flatMap((s) => s.quality || []);
193
+ const lines = [
194
+ "# Loom Composed Workflow",
195
+ "",
196
+ `Generated: ${(/* @__PURE__ */ new Date()).toISOString()}`,
197
+ "",
198
+ "## Skills",
199
+ ""
200
+ ];
201
+ for (const skill of skills) {
202
+ lines.push(`- **${skill.name}**@${skill.version}: ${skill.description}`);
203
+ }
204
+ lines.push("", "## Capabilities", "");
205
+ for (const cap of allCapabilities) {
206
+ lines.push(`- **${cap.id}**: ${cap.description}`);
207
+ }
208
+ if (allQuality.length > 0) {
209
+ lines.push("", "## Quality Gates", "");
210
+ for (const gate of allQuality) {
211
+ lines.push(`- **${gate.id}**: ${gate.description} (${gate.severity})`);
212
+ }
213
+ }
214
+ const output = lines.join("\n");
215
+ if (options.dryRun) {
216
+ console.log(output);
217
+ return;
218
+ }
219
+ const outputFile = options.output || "loom-workflow.md";
220
+ writeFileSync2(outputFile, output);
221
+ console.log(chalk3.green(`Composed workflow written to ${outputFile}`));
222
+ console.log(`
223
+ Skills: ${skills.length} | Capabilities: ${allCapabilities.length}`);
224
+ } catch (error) {
225
+ spinner.fail(chalk3.red(`Failed: ${error instanceof Error ? error.message : error}`));
226
+ process.exit(1);
227
+ }
228
+ });
229
+
230
+ // src/commands/test.ts
231
+ import { Command as Command4 } from "commander";
232
+ import chalk4 from "chalk";
233
+ import { existsSync as existsSync4, readdirSync as readdirSync3, readFileSync as readFileSync4 } from "node:fs";
234
+ import { join as join4, dirname } from "node:path";
235
+ import { spawnSync } from "node:child_process";
236
+ import { createRequire } from "node:module";
237
+ var require2 = createRequire(import.meta.url);
238
+ function resolveVitestBin() {
239
+ const vitestPkgPath = require2.resolve("vitest/package.json");
240
+ const vitestPkg = JSON.parse(readFileSync4(vitestPkgPath, "utf-8"));
241
+ const binEntry = typeof vitestPkg.bin === "string" ? vitestPkg.bin : vitestPkg.bin?.vitest;
242
+ if (!binEntry) {
243
+ throw new Error("Could not resolve vitest executable");
244
+ }
245
+ return join4(dirname(vitestPkgPath), binEntry);
246
+ }
247
+ function runSkillTests(testDir, testFiles) {
248
+ const vitestBin = resolveVitestBin();
249
+ const result = spawnSync(
250
+ process.execPath,
251
+ [vitestBin, "run", ...testFiles, "--root", testDir, "--globals"],
252
+ { cwd: testDir, encoding: "utf-8" }
253
+ );
254
+ const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
255
+ return { passed: result.status === 0, output };
256
+ }
257
+ var testCommand = new Command4("test").description("Run skill tests").argument("[skill]", "Skill to test").action((skillName) => {
258
+ try {
259
+ const skillsDir = join4(process.cwd(), "skills");
260
+ if (!existsSync4(skillsDir)) {
261
+ console.log(chalk4.red("No skills directory found"));
262
+ process.exit(1);
263
+ }
264
+ const skillDirs = [];
265
+ if (skillName) {
266
+ let skillPath = /^@[^/]+\//.test(skillName) ? join4(skillsDir, skillName.replace(/^@[^/]+\//, "")) : join4(skillsDir, skillName);
267
+ if (!existsSync4(skillPath)) {
268
+ const short = skillName.replace(/^@[^/]+\//, "");
269
+ const found = readdirSync3(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith(".")).map((d) => join4(skillsDir, d.name, short)).find((p) => existsSync4(join4(p, "SKILL.yaml")));
270
+ if (found) skillPath = found;
271
+ }
272
+ if (!existsSync4(skillPath)) {
273
+ throw new Error(`Skill not found: ${skillName}`);
274
+ }
275
+ skillDirs.push(skillPath);
276
+ } else {
277
+ const categories = readdirSync3(skillsDir).filter(
278
+ (f) => existsSync4(join4(skillsDir, f)) && !f.startsWith(".")
279
+ );
280
+ for (const category of categories) {
281
+ const categoryPath = join4(skillsDir, category);
282
+ if (!existsSync4(categoryPath)) continue;
283
+ const skills = readdirSync3(categoryPath).filter(
284
+ (f) => existsSync4(join4(categoryPath, f, "SKILL.yaml"))
285
+ );
286
+ for (const skill of skills) {
287
+ skillDirs.push(join4(categoryPath, skill));
288
+ }
289
+ }
290
+ }
291
+ if (skillDirs.length === 0) {
292
+ console.log(chalk4.yellow("No skills found to test"));
293
+ return;
294
+ }
295
+ console.log(chalk4.bold("Running tests...\n"));
296
+ let passed = 0;
297
+ let failed = 0;
298
+ let skipped = 0;
299
+ for (const skillPath of skillDirs) {
300
+ const yamlPath = join4(skillPath, "SKILL.yaml");
301
+ const content = existsSync4(yamlPath) ? readFileSync4(yamlPath, "utf-8") : "";
302
+ const nameMatch = content.match(/name:\s*"?([^"\n]+)"?/);
303
+ const currentSkillName = nameMatch?.[1] || skillPath.split("/").pop() || "unknown";
304
+ const testDir = join4(skillPath, "tests");
305
+ if (!existsSync4(testDir)) {
306
+ console.log(chalk4.gray(` ${currentSkillName}: no tests (skipped)`));
307
+ skipped++;
308
+ continue;
309
+ }
310
+ const testFiles = readdirSync3(testDir).filter((f) => f.endsWith(".test.ts") || f.endsWith(".test.js"));
311
+ if (testFiles.length === 0) {
312
+ console.log(chalk4.gray(` ${currentSkillName}: no test files (skipped)`));
313
+ skipped++;
314
+ continue;
315
+ }
316
+ const { passed: skillPassed, output } = runSkillTests(testDir, testFiles);
317
+ if (skillPassed) {
318
+ console.log(chalk4.green(` ${currentSkillName}: ${testFiles.length} test file(s) passed \u2713`));
319
+ passed++;
320
+ } else {
321
+ console.log(chalk4.red(` ${currentSkillName}: tests failed \u2717`));
322
+ console.log(
323
+ output.split("\n").map((line) => ` ${line}`).join("\n")
324
+ );
325
+ failed++;
326
+ }
327
+ }
328
+ console.log("\n" + chalk4.bold("Summary:"));
329
+ console.log(` ${chalk4.green(`Passed: ${passed}`)}`);
330
+ if (failed > 0) {
331
+ console.log(` ${chalk4.red(`Failed: ${failed}`)}`);
332
+ }
333
+ if (skipped > 0) {
334
+ console.log(` ${chalk4.yellow(`Skipped: ${skipped}`)}`);
335
+ }
336
+ if (failed > 0) {
337
+ process.exit(1);
338
+ }
339
+ } catch (error) {
340
+ console.log(chalk4.red(`Failed: ${error instanceof Error ? error.message : error}`));
341
+ process.exit(1);
342
+ }
343
+ });
344
+
345
+ // src/commands/validate.ts
346
+ import { Command as Command5 } from "commander";
347
+ import chalk5 from "chalk";
348
+ import ora3 from "ora";
349
+ import { existsSync as existsSync6, readdirSync as readdirSync5 } from "node:fs";
350
+ import { join as join6 } from "node:path";
351
+
352
+ // ../core/dist/loader.js
353
+ import { readFileSync as readFileSync5, existsSync as existsSync5, readdirSync as readdirSync4, statSync } from "node:fs";
354
+ import { join as join5, resolve as resolve2 } from "node:path";
355
+ import { parse as parseYaml } from "yaml";
356
+ var SkillLoader = class {
357
+ cache = /* @__PURE__ */ new Map();
358
+ /**
359
+ * Load a skill from a directory path
360
+ */
361
+ async load(skillPath) {
362
+ const canonical = resolve2(skillPath);
363
+ if (this.cache.has(canonical)) {
364
+ return this.cache.get(canonical);
365
+ }
366
+ if (!existsSync5(skillPath) || !statSync(skillPath).isDirectory()) {
367
+ throw new Error(`Skill directory not found: ${skillPath}`);
368
+ }
369
+ const yamlPath = join5(skillPath, "SKILL.yaml");
370
+ if (!existsSync5(yamlPath)) {
371
+ throw new Error(`SKILL.yaml not found in: ${skillPath}`);
372
+ }
373
+ const yamlContent = readFileSync5(yamlPath, "utf-8");
374
+ const definition = parseYaml(yamlContent);
375
+ this.validateDefinition(definition, skillPath);
376
+ const mdPath = join5(skillPath, "SKILL.md");
377
+ let content = "";
378
+ if (existsSync5(mdPath)) {
379
+ content = readFileSync5(mdPath, "utf-8");
380
+ }
381
+ const references = this.loadReferences(skillPath, definition.references || []);
382
+ const loaded = {
383
+ definition,
384
+ content,
385
+ references,
386
+ path: skillPath
387
+ };
388
+ this.cache.set(canonical, loaded);
389
+ return loaded;
390
+ }
391
+ /**
392
+ * Load a skill by package name (e.g., @loom-skills/tdd).
393
+ * The scope prefix is stripped generically so any scope works.
394
+ */
395
+ async loadByName(name, searchPaths = []) {
396
+ const skillDir = name.replace(/^@[^/]+\//, "");
397
+ for (const searchPath of searchPaths) {
398
+ const candidate = join5(searchPath, skillDir);
399
+ if (existsSync5(candidate)) {
400
+ return this.load(candidate);
401
+ }
402
+ }
403
+ const localCandidate = join5(process.cwd(), "skills", skillDir);
404
+ if (existsSync5(localCandidate)) {
405
+ return this.load(localCandidate);
406
+ }
407
+ throw new Error(`Skill not found: ${name}`);
408
+ }
409
+ /**
410
+ * Load all skills from a directory
411
+ */
412
+ async loadAll(skillsDir) {
413
+ const skills = [];
414
+ if (!existsSync5(skillsDir)) {
415
+ return skills;
416
+ }
417
+ const categories = readdirSync4(skillsDir).filter((f) => statSync(join5(skillsDir, f)).isDirectory());
418
+ for (const category of categories) {
419
+ const categoryPath = join5(skillsDir, category);
420
+ const skillDirs = readdirSync4(categoryPath).filter((f) => statSync(join5(categoryPath, f)).isDirectory());
421
+ for (const skillDir of skillDirs) {
422
+ const skillPath = join5(categoryPath, skillDir);
423
+ try {
424
+ const skill = await this.load(skillPath);
425
+ skills.push(skill);
426
+ } catch {
427
+ }
428
+ }
429
+ }
430
+ return skills;
431
+ }
432
+ /**
433
+ * Clear the load cache
434
+ */
435
+ clearCache() {
436
+ this.cache.clear();
437
+ }
438
+ // ==========================================================================
439
+ // Private Methods
440
+ // ==========================================================================
441
+ validateDefinition(def, path) {
442
+ if (!def.name) {
443
+ throw new Error(`Skill missing 'name' field: ${path}`);
444
+ }
445
+ if (!def.version) {
446
+ throw new Error(`Skill missing 'version' field: ${path}`);
447
+ }
448
+ if (!def.description) {
449
+ throw new Error(`Skill missing 'description' field: ${path}`);
450
+ }
451
+ if (!Array.isArray(def.provides)) {
452
+ throw new Error(`Skill missing 'provides' array: ${path}`);
453
+ }
454
+ }
455
+ loadReferences(skillPath, refs) {
456
+ const references = /* @__PURE__ */ new Map();
457
+ for (const ref of refs) {
458
+ const refPath = join5(skillPath, ref.path);
459
+ if (existsSync5(refPath)) {
460
+ const content = readFileSync5(refPath, "utf-8");
461
+ references.set(ref.path, content);
462
+ }
463
+ }
464
+ return references;
465
+ }
466
+ };
467
+ var defaultLoader = null;
468
+ function getLoader() {
469
+ if (!defaultLoader) {
470
+ defaultLoader = new SkillLoader();
471
+ }
472
+ return defaultLoader;
473
+ }
474
+ async function loadSkill(path) {
475
+ return getLoader().load(path);
476
+ }
477
+
478
+ // ../core/dist/validator.js
479
+ var CONTENT_GATE_HEADINGS = {
480
+ "has-overview": /^#{1,6}\s*overview\s*$/im,
481
+ "has-when-to-use": /^#{1,6}\s*when to use\s*$/im,
482
+ "has-workflow": /^#{1,6}\s*workflow\s*$/im
483
+ };
484
+ var AUTO_SATISFIED_GATES = /* @__PURE__ */ new Set([
485
+ "valid-schema",
486
+ "valid-name",
487
+ "valid-version",
488
+ "has-quality-gates"
489
+ ]);
490
+ var Validator = class {
491
+ /**
492
+ * Validate a skill definition
493
+ */
494
+ validate(definition) {
495
+ const errors = [];
496
+ this.validateRequired(definition, errors);
497
+ this.validateFormats(definition, errors);
498
+ this.validateCapabilities(definition, errors);
499
+ this.validateQualityGates(definition, errors);
500
+ const validationErrors = errors.filter((e) => e.severity === "error");
501
+ const warnings = errors.filter((e) => e.severity === "warning");
502
+ return {
503
+ valid: validationErrors.length === 0,
504
+ errors: validationErrors,
505
+ warnings
506
+ };
507
+ }
508
+ /**
509
+ * Validate a loaded skill
510
+ */
511
+ validateLoaded(skill) {
512
+ const result = this.validate(skill.definition);
513
+ if (!skill.content) {
514
+ result.warnings.push({
515
+ field: "content",
516
+ message: "SKILL.md is empty or missing",
517
+ severity: "warning"
518
+ });
519
+ }
520
+ const unchecked = [];
521
+ for (const gate of skill.definition.quality ?? []) {
522
+ if (AUTO_SATISFIED_GATES.has(gate.id)) {
523
+ continue;
524
+ }
525
+ const headingPattern = CONTENT_GATE_HEADINGS[gate.id];
526
+ if (gate.type === "content" && headingPattern) {
527
+ if (!headingPattern.test(skill.content)) {
528
+ const entry = {
529
+ field: `quality.${gate.id}`,
530
+ message: `Quality gate '${gate.id}' failed: SKILL.md is missing a matching section (${gate.description})`,
531
+ severity: gate.severity
532
+ };
533
+ if (gate.severity === "error") {
534
+ result.errors.push(entry);
535
+ result.valid = false;
536
+ } else if (gate.severity === "warning") {
537
+ result.warnings.push(entry);
538
+ }
539
+ }
540
+ continue;
541
+ }
542
+ unchecked.push(gate.id);
543
+ }
544
+ result.unchecked = unchecked;
545
+ return result;
546
+ }
547
+ /**
548
+ * Validate skill name format
549
+ */
550
+ validateName(name) {
551
+ const nameRegex = /^(@[a-z0-9-]+\/)?[a-z0-9-]+$/;
552
+ return nameRegex.test(name);
553
+ }
554
+ /**
555
+ * Validate semver version
556
+ */
557
+ validateVersion(version) {
558
+ const semverRegex = /^\d+\.\d+\.\d+(-[a-z0-9.]+)?(\+[a-z0-9.]+)?$/;
559
+ return semverRegex.test(version);
560
+ }
561
+ // ==========================================================================
562
+ // Private Methods
563
+ // ==========================================================================
564
+ validateRequired(def, errors) {
565
+ if (!def.name) {
566
+ errors.push({
567
+ field: "name",
568
+ message: "Skill name is required",
569
+ severity: "error"
570
+ });
571
+ } else if (!this.validateName(def.name)) {
572
+ errors.push({
573
+ field: "name",
574
+ message: `Invalid skill name format: ${def.name}`,
575
+ severity: "error"
576
+ });
577
+ }
578
+ if (!def.version) {
579
+ errors.push({
580
+ field: "version",
581
+ message: "Skill version is required",
582
+ severity: "error"
583
+ });
584
+ } else if (!this.validateVersion(def.version)) {
585
+ errors.push({
586
+ field: "version",
587
+ message: `Invalid semver version: ${def.version}`,
588
+ severity: "error"
589
+ });
590
+ }
591
+ if (!def.description) {
592
+ errors.push({
593
+ field: "description",
594
+ message: "Skill description is required",
595
+ severity: "error"
596
+ });
597
+ }
598
+ if (!Array.isArray(def.provides) || def.provides.length === 0) {
599
+ errors.push({
600
+ field: "provides",
601
+ message: "At least one capability must be provided",
602
+ severity: "error"
603
+ });
604
+ }
605
+ }
606
+ validateFormats(def, errors) {
607
+ if (def.version && !this.validateVersion(def.version)) {
608
+ errors.push({
609
+ field: "version",
610
+ message: `Version must be valid semver: ${def.version}`,
611
+ severity: "error"
612
+ });
613
+ }
614
+ if (def.authors) {
615
+ for (const author of def.authors) {
616
+ if (author.email && !this.validateEmail(author.email)) {
617
+ errors.push({
618
+ field: "authors.email",
619
+ message: `Invalid email format: ${author.email}`,
620
+ severity: "warning"
621
+ });
622
+ }
623
+ }
624
+ }
625
+ if (def.invocation !== void 0 && !["user", "model", "either"].includes(def.invocation)) {
626
+ errors.push({
627
+ field: "invocation",
628
+ message: `Invalid invocation mode: ${def.invocation} (expected user, model, or either)`,
629
+ severity: "error"
630
+ });
631
+ }
632
+ }
633
+ validateCapabilities(def, errors) {
634
+ const capIds = /* @__PURE__ */ new Set();
635
+ for (const cap of def.provides) {
636
+ if (capIds.has(cap.id)) {
637
+ errors.push({
638
+ field: "provides",
639
+ message: `Duplicate capability ID: ${cap.id}`,
640
+ severity: "error"
641
+ });
642
+ }
643
+ capIds.add(cap.id);
644
+ if (!/^[a-z0-9-]+$/.test(cap.id)) {
645
+ errors.push({
646
+ field: "provides.id",
647
+ message: `Invalid capability ID format: ${cap.id}`,
648
+ severity: "error"
649
+ });
650
+ }
651
+ if (cap.input) {
652
+ for (const param of cap.input) {
653
+ this.validateParameter(param, `provides.${cap.id}.input`, errors);
654
+ }
655
+ }
656
+ if (cap.output) {
657
+ for (const param of cap.output) {
658
+ this.validateParameter(param, `provides.${cap.id}.output`, errors);
659
+ }
660
+ }
661
+ }
662
+ }
663
+ validateParameter(param, path, errors) {
664
+ if (!param.name) {
665
+ errors.push({
666
+ field: `${path}.name`,
667
+ message: "Parameter name is required",
668
+ severity: "error"
669
+ });
670
+ }
671
+ const validTypes = [
672
+ "string",
673
+ "number",
674
+ "boolean",
675
+ "file",
676
+ "file[]",
677
+ "object",
678
+ "array",
679
+ "enum"
680
+ ];
681
+ if (param.type && !validTypes.includes(param.type)) {
682
+ errors.push({
683
+ field: `${path}.type`,
684
+ message: `Invalid parameter type: ${param.type}`,
685
+ severity: "error"
686
+ });
687
+ }
688
+ }
689
+ validateQualityGates(def, errors) {
690
+ if (!def.quality)
691
+ return;
692
+ const gateIds = /* @__PURE__ */ new Set();
693
+ for (const gate of def.quality) {
694
+ if (gateIds.has(gate.id)) {
695
+ errors.push({
696
+ field: "quality",
697
+ message: `Duplicate quality gate ID: ${gate.id}`,
698
+ severity: "error"
699
+ });
700
+ }
701
+ gateIds.add(gate.id);
702
+ if (!/^[a-z0-9-]+$/.test(gate.id)) {
703
+ errors.push({
704
+ field: "quality.id",
705
+ message: `Invalid quality gate ID format: ${gate.id}`,
706
+ severity: "error"
707
+ });
708
+ }
709
+ const validTypes = ["structural", "behavioral", "content", "performance"];
710
+ if (!validTypes.includes(gate.type)) {
711
+ errors.push({
712
+ field: "quality.type",
713
+ message: `Invalid quality gate type: ${gate.type}`,
714
+ severity: "error"
715
+ });
716
+ }
717
+ const validSeverities = ["error", "warning", "info"];
718
+ if (!validSeverities.includes(gate.severity)) {
719
+ errors.push({
720
+ field: "quality.severity",
721
+ message: `Invalid quality gate severity: ${gate.severity}`,
722
+ severity: "error"
723
+ });
724
+ }
725
+ }
726
+ }
727
+ validateEmail(email) {
728
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
729
+ return emailRegex.test(email);
730
+ }
731
+ };
732
+ var defaultValidator = null;
733
+ function getValidator() {
734
+ if (!defaultValidator) {
735
+ defaultValidator = new Validator();
736
+ }
737
+ return defaultValidator;
738
+ }
739
+
740
+ // src/commands/validate.ts
741
+ var validateCommand = new Command5("validate").description("Validate skill definitions").argument("[skill]", "Skill to validate").option("--strict", "Treat warnings as errors").action(async (skillName, options = {}) => {
742
+ const spinner = ora3("Validating skills...").start();
743
+ try {
744
+ const skillsDir = join6(process.cwd(), "skills");
745
+ if (!existsSync6(skillsDir)) {
746
+ spinner.fail(chalk5.red("No skills directory found"));
747
+ process.exit(1);
748
+ }
749
+ const skillDirs = [];
750
+ if (skillName) {
751
+ let skillPath = /^@[^/]+\//.test(skillName) ? join6(skillsDir, skillName.replace(/^@[^/]+\//, "")) : join6(skillsDir, skillName);
752
+ if (!existsSync6(skillPath)) {
753
+ const short = skillName.replace(/^@[^/]+\//, "");
754
+ const found = readdirSync5(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith(".")).map((d) => join6(skillsDir, d.name, short)).find((p) => existsSync6(join6(p, "SKILL.yaml")));
755
+ if (found) skillPath = found;
756
+ }
757
+ if (!existsSync6(skillPath)) {
758
+ throw new Error(`Skill not found: ${skillName}`);
759
+ }
760
+ skillDirs.push(skillPath);
761
+ } else {
762
+ const categories = readdirSync5(skillsDir).filter(
763
+ (f) => existsSync6(join6(skillsDir, f)) && !f.startsWith(".")
764
+ );
765
+ for (const category of categories) {
766
+ const categoryPath = join6(skillsDir, category);
767
+ if (!existsSync6(categoryPath)) continue;
768
+ const skills = readdirSync5(categoryPath).filter(
769
+ (f) => existsSync6(join6(categoryPath, f, "SKILL.yaml"))
770
+ );
771
+ for (const skill of skills) {
772
+ skillDirs.push(join6(categoryPath, skill));
773
+ }
774
+ }
775
+ }
776
+ if (skillDirs.length === 0) {
777
+ spinner.warn(chalk5.yellow("No skills found to validate"));
778
+ return;
779
+ }
780
+ spinner.stop();
781
+ const validator = getValidator();
782
+ let valid = 0;
783
+ let invalid = 0;
784
+ for (const skillPath of skillDirs) {
785
+ const label = skillPath.split("/").pop() || "unknown";
786
+ try {
787
+ const skill = await loadSkill(skillPath);
788
+ const result = validator.validateLoaded(skill);
789
+ const name = `${skill.definition.name}@${skill.definition.version}`;
790
+ const failsStrict = options.strict && result.warnings.length > 0;
791
+ if (result.valid && !failsStrict) {
792
+ console.log(chalk5.green(` ${name}: valid`));
793
+ valid++;
794
+ } else {
795
+ console.log(chalk5.red(` ${name}: invalid`));
796
+ invalid++;
797
+ }
798
+ for (const error of result.errors) {
799
+ console.log(chalk5.red(` \u2717 ${error.field}: ${error.message}`));
800
+ }
801
+ for (const warning of result.warnings) {
802
+ console.log(chalk5.yellow(` \u26A0 ${warning.field}: ${warning.message}`));
803
+ }
804
+ if (result.unchecked && result.unchecked.length > 0) {
805
+ console.log(
806
+ chalk5.gray(
807
+ ` \u2139 requires manual verification: ${result.unchecked.join(", ")}`
808
+ )
809
+ );
810
+ }
811
+ } catch (error) {
812
+ console.log(chalk5.red(` ${label}: invalid`));
813
+ console.log(
814
+ chalk5.red(` \u2717 ${error instanceof Error ? error.message : error}`)
815
+ );
816
+ invalid++;
817
+ }
818
+ }
819
+ console.log("\n" + chalk5.bold("Summary:"));
820
+ console.log(` ${chalk5.green(`Valid: ${valid}`)}`);
821
+ if (invalid > 0) {
822
+ console.log(` ${chalk5.red(`Invalid: ${invalid}`)}`);
823
+ }
824
+ if (invalid > 0) process.exit(1);
825
+ } catch (error) {
826
+ spinner.fail(chalk5.red(`Failed: ${error instanceof Error ? error.message : error}`));
827
+ process.exit(1);
828
+ }
829
+ });
830
+
831
+ // src/commands/init.ts
832
+ import { Command as Command6 } from "commander";
833
+ import chalk6 from "chalk";
834
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "node:fs";
835
+ import { join as join7 } from "node:path";
836
+ var initCommand = new Command6("init").description("Initialize a new skill package").argument("<name>", "Skill name (e.g., my-skill)").option("-c, --category <category>", "Skill category", "engineering").action((name, options = {}) => {
837
+ const category = options.category || "engineering";
838
+ const skillDir = join7(process.cwd(), "skills", category, name);
839
+ mkdirSync2(join7(skillDir, "references"), { recursive: true });
840
+ mkdirSync2(join7(skillDir, "tests"), { recursive: true });
841
+ const yaml = `name: "${name}"
842
+ version: "0.1.0"
843
+ description: "TODO: Describe what this skill does"
844
+ license: "MIT"
845
+ authors:
846
+ - name: "Your Name"
847
+
848
+ provides:
849
+ - id: "${name.replace(/-/g, "_")}"
850
+ description: "TODO: Describe the capability"
851
+ input:
852
+ - name: "target"
853
+ type: "string"
854
+ required: true
855
+ output:
856
+ - name: "result"
857
+ type: "object"
858
+
859
+ context:
860
+ - type: "language"
861
+ values: ["typescript", "javascript"]
862
+ required: true
863
+
864
+ quality:
865
+ - id: "has-overview"
866
+ type: "content"
867
+ description: "Skill has overview section"
868
+ severity: "warning"
869
+
870
+ tags: []
871
+ category: "${category}"
872
+ difficulty: "intermediate"
873
+ `;
874
+ writeFileSync3(join7(skillDir, "SKILL.yaml"), yaml);
875
+ const md = `# ${name}
876
+
877
+ ## Overview
878
+
879
+ TODO: Describe what this skill does and when to use it.
880
+
881
+ ## When to Use
882
+
883
+ - TODO: Add triggering conditions
884
+
885
+ ## Workflow
886
+
887
+ ### Step 1: TODO
888
+
889
+ [Instructions]
890
+
891
+ ## Quality Gates
892
+
893
+ - **has-overview**: Skill has overview section
894
+ `;
895
+ writeFileSync3(join7(skillDir, "SKILL.md"), md);
896
+ writeFileSync3(join7(skillDir, "references", ".gitkeep"), "");
897
+ writeFileSync3(
898
+ join7(skillDir, "tests", `${name}.test.ts`),
899
+ `// Tests for ${name}
900
+ describe('${name}', () => {
901
+ it('should load successfully', () => {
902
+ // TODO: Add test
903
+ expect(true).toBe(true);
904
+ });
905
+ });
906
+ `
907
+ );
908
+ console.log(chalk6.green(`Created skill: ${name}`));
909
+ console.log(`
910
+ Location: ${skillDir}`);
911
+ console.log("\nFiles created:");
912
+ console.log(` ${chalk6.cyan("SKILL.yaml")} - Skill definition`);
913
+ console.log(` ${chalk6.cyan("SKILL.md")} - Skill instructions`);
914
+ console.log(` ${chalk6.cyan("references/")} - Reference files`);
915
+ console.log(` ${chalk6.cyan("tests/")} - Test files`);
916
+ console.log("\nNext steps:");
917
+ console.log(" 1. Edit SKILL.yaml with your skill definition");
918
+ console.log(" 2. Write SKILL.md with your skill instructions");
919
+ console.log(" 3. Run `loom validate` to check for issues");
920
+ });
921
+
922
+ // src/commands/search.ts
923
+ import { Command as Command7 } from "commander";
924
+ import chalk7 from "chalk";
925
+ import ora4 from "ora";
926
+ var searchCommand = new Command7("search").description("Search for skills").argument("<query>", "Search query").action((query) => {
927
+ const spinner = ora4("Searching...").start();
928
+ try {
929
+ spinner.stop();
930
+ console.log(chalk7.bold(`
931
+ Search results for "${query}":
932
+ `));
933
+ const results = [
934
+ { name: "@loom-skills/tdd", description: "Test-driven development", category: "engineering" },
935
+ { name: "@loom-skills/review", description: "Code review", category: "engineering" },
936
+ { name: "@loom-skills/debug", description: "Debugging workflow", category: "engineering" }
937
+ ];
938
+ const filtered = results.filter(
939
+ (r) => r.name.toLowerCase().includes(query.toLowerCase()) || r.description.toLowerCase().includes(query.toLowerCase())
940
+ );
941
+ if (filtered.length === 0) {
942
+ console.log(chalk7.yellow("No results found."));
943
+ return;
944
+ }
945
+ for (const result of filtered) {
946
+ console.log(`${chalk7.bold(result.name)}`);
947
+ console.log(` ${chalk7.gray(result.description)}`);
948
+ console.log(` Category: ${chalk7.cyan(result.category)}`);
949
+ console.log();
950
+ }
951
+ console.log(chalk7.gray(`Found ${filtered.length} results`));
952
+ } catch (error) {
953
+ spinner.fail(chalk7.red(`Failed: ${error instanceof Error ? error.message : error}`));
954
+ process.exit(1);
955
+ }
956
+ });
957
+
958
+ // src/commands/publish.ts
959
+ import { Command as Command8 } from "commander";
960
+ import chalk8 from "chalk";
961
+ import ora5 from "ora";
962
+ import { existsSync as existsSync7, readFileSync as readFileSync6 } from "node:fs";
963
+ import { join as join8 } from "node:path";
964
+ var publishCommand = new Command8("publish").description("Publish a skill to the registry").argument("[path]", "Path to skill directory", ".").option("--dry-run", "Preview publish without uploading").action((skillPath, options = {}) => {
965
+ const spinner = ora5("Preparing to publish...").start();
966
+ try {
967
+ const resolvedPath = skillPath === "." ? process.cwd() : skillPath;
968
+ const yamlPath = join8(resolvedPath, "SKILL.yaml");
969
+ if (!existsSync7(yamlPath)) {
970
+ throw new Error(`No SKILL.yaml found in: ${resolvedPath}`);
971
+ }
972
+ const content = readFileSync6(yamlPath, "utf-8");
973
+ const nameMatch = content.match(/name:\s*"?([^"\n]+)"?/);
974
+ const versionMatch = content.match(/version:\s*"?([^"\n]+)"?/);
975
+ const descMatch = content.match(/description:\s*"?([^"\n]+)"?/);
976
+ const name = nameMatch?.[1] || "unknown";
977
+ const version = versionMatch?.[1] || "0.0.0";
978
+ const description = descMatch?.[1] || "";
979
+ const errors = [];
980
+ if (!nameMatch) errors.push("Missing name");
981
+ if (!versionMatch) errors.push("Missing version");
982
+ if (!descMatch) errors.push("Missing description");
983
+ if (!content.includes("provides:")) errors.push("Missing provides section");
984
+ if (errors.length > 0) {
985
+ spinner.fail(chalk8.red("Validation failed:"));
986
+ for (const error of errors) {
987
+ console.log(chalk8.red(` \u2717 ${error}`));
988
+ }
989
+ process.exit(1);
990
+ }
991
+ spinner.stop();
992
+ console.log(chalk8.bold("\nPublish Preview:\n"));
993
+ console.log(` ${chalk8.bold("Name:")} ${name}`);
994
+ console.log(` ${chalk8.bold("Version:")} ${version}`);
995
+ console.log(` ${chalk8.bold("Description:")} ${description}`);
996
+ if (options.dryRun) {
997
+ console.log("\n" + chalk8.gray("Dry run - no files uploaded"));
998
+ return;
999
+ }
1000
+ console.log("\n" + chalk8.green("Publish functionality coming soon!"));
1001
+ console.log(chalk8.gray("This would upload to the Loom registry."));
1002
+ } catch (error) {
1003
+ spinner.fail(chalk8.red(`Failed: ${error instanceof Error ? error.message : error}`));
1004
+ process.exit(1);
1005
+ }
1006
+ });
1007
+
1008
+ // src/index.ts
1009
+ var program = new Command9();
1010
+ program.name("loom").description("Skills woven together \u2014 A skill operating system for AI coding agents").version("0.1.0");
1011
+ program.addCommand(installCommand);
1012
+ program.addCommand(listCommand);
1013
+ program.addCommand(composeCommand);
1014
+ program.addCommand(testCommand);
1015
+ program.addCommand(validateCommand);
1016
+ program.addCommand(initCommand);
1017
+ program.addCommand(searchCommand);
1018
+ program.addCommand(publishCommand);
1019
+ program.parse();
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@loom-skills/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Command-line interface for Loom skill system",
5
+ "homepage": "https://github.com/hemantho-r/loom",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/hemantho-r/loom.git",
9
+ "directory": "packages/cli"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/hemantho-r/loom/issues"
13
+ },
14
+ "type": "module",
15
+ "main": "dist/index.js",
16
+ "bin": {
17
+ "loom": "dist/index.js"
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "dependencies": {
26
+ "commander": "^12.0.0",
27
+ "chalk": "^5.3.0",
28
+ "ora": "^8.0.0",
29
+ "vitest": "^1.6.0",
30
+ "yaml": "^2.4.0"
31
+ },
32
+ "devDependencies": {
33
+ "typescript": "^5.4.0",
34
+ "@types/node": "^20.0.0",
35
+ "esbuild": "^0.21.0"
36
+ },
37
+ "scripts": {
38
+ "build": "rm -rf dist && esbuild src/index.ts --bundle --platform=node --format=esm --target=node18 --outfile=dist/index.js --external:commander --external:chalk --external:ora --external:vitest --external:yaml && chmod +x dist/index.js",
39
+ "dev": "tsc --watch",
40
+ "typecheck": "tsc --noEmit",
41
+ "lint": "tsc --noEmit",
42
+ "clean": "rm -rf dist"
43
+ }
6
44
  }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.