@robosoft/skillhub-cli 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.
@@ -0,0 +1,747 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import crypto from "node:crypto";
6
+ import process from "node:process";
7
+
8
+ const CONFIG_FILE = "skillhub.json";
9
+ const LOCK_FILE = "skillhub.lock.json";
10
+ const DEFAULT_SKILLS_DIR = ".ai/skills";
11
+ const GENERATED_START = "<!-- skillhub:start -->";
12
+ const GENERATED_END = "<!-- skillhub:end -->";
13
+
14
+ const colors = {
15
+ reset: "\x1b[0m",
16
+ dim: "\x1b[2m",
17
+ green: "\x1b[32m",
18
+ yellow: "\x1b[33m",
19
+ red: "\x1b[31m",
20
+ cyan: "\x1b[36m",
21
+ bold: "\x1b[1m",
22
+ };
23
+
24
+ function paint(color, value) {
25
+ return `${colors[color] ?? ""}${value}${colors.reset}`;
26
+ }
27
+
28
+ function ok(message) {
29
+ console.log(`${paint("green", "✓")} ${message}`);
30
+ }
31
+
32
+ function warn(message) {
33
+ console.log(`${paint("yellow", "!")} ${message}`);
34
+ }
35
+
36
+ function info(message) {
37
+ console.log(`${paint("cyan", "›")} ${message}`);
38
+ }
39
+
40
+ function fail(message) {
41
+ console.error(`${paint("red", "✕")} ${message}`);
42
+ }
43
+
44
+ async function exists(filePath) {
45
+ try {
46
+ await fs.access(filePath);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ async function ensureDir(dirPath) {
54
+ await fs.mkdir(dirPath, { recursive: true });
55
+ }
56
+
57
+ async function readJson(filePath, fallback = null) {
58
+ if (!(await exists(filePath))) return fallback;
59
+ const raw = await fs.readFile(filePath, "utf8");
60
+ return JSON.parse(raw);
61
+ }
62
+
63
+ async function writeJson(filePath, value) {
64
+ await ensureDir(path.dirname(filePath));
65
+ await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
66
+ }
67
+
68
+ function now() {
69
+ return new Date().toISOString();
70
+ }
71
+
72
+ function normalizeSlashes(value) {
73
+ return value.split(path.sep).join("/");
74
+ }
75
+
76
+ function safeJoin(root, relativePath) {
77
+ const target = path.resolve(root, relativePath);
78
+ const normalizedRoot = path.resolve(root);
79
+ if (!target.startsWith(normalizedRoot)) {
80
+ throw new Error(`Unsafe path detected: ${relativePath}`);
81
+ }
82
+ return target;
83
+ }
84
+
85
+ function parseFlags(argv) {
86
+ const positional = [];
87
+ const flags = {};
88
+
89
+ for (let index = 0; index < argv.length; index += 1) {
90
+ const arg = argv[index];
91
+
92
+ if (!arg.startsWith("--")) {
93
+ positional.push(arg);
94
+ continue;
95
+ }
96
+
97
+ const raw = arg.slice(2);
98
+ const [name, inlineValue] = raw.split("=");
99
+
100
+ if (inlineValue !== undefined) {
101
+ flags[name] = inlineValue;
102
+ continue;
103
+ }
104
+
105
+ const next = argv[index + 1];
106
+ if (next && !next.startsWith("--")) {
107
+ flags[name] = next;
108
+ index += 1;
109
+ } else {
110
+ flags[name] = true;
111
+ }
112
+ }
113
+
114
+ return { positional, flags };
115
+ }
116
+
117
+ function parseSkillSpec(spec) {
118
+ if (!spec || spec.trim().length === 0) {
119
+ throw new Error("Skill name is required.");
120
+ }
121
+
122
+ const trimmed = spec.trim();
123
+ const atIndex = trimmed.lastIndexOf("@");
124
+
125
+ if (atIndex > 0) {
126
+ return {
127
+ name: trimmed.slice(0, atIndex),
128
+ version: trimmed.slice(atIndex + 1),
129
+ };
130
+ }
131
+
132
+ return { name: trimmed, version: null };
133
+ }
134
+
135
+ function sortVersions(versions) {
136
+ return versions.sort((left, right) => {
137
+ const a = left.split(".").map((part) => Number.parseInt(part, 10) || 0);
138
+ const b = right.split(".").map((part) => Number.parseInt(part, 10) || 0);
139
+
140
+ for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
141
+ const diff = (b[index] ?? 0) - (a[index] ?? 0);
142
+ if (diff !== 0) return diff;
143
+ }
144
+
145
+ return right.localeCompare(left);
146
+ });
147
+ }
148
+
149
+ async function listFiles(root, current = root) {
150
+ const entries = await fs.readdir(current, { withFileTypes: true });
151
+ const files = [];
152
+
153
+ for (const entry of entries) {
154
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
155
+
156
+ const fullPath = path.join(current, entry.name);
157
+ if (entry.isDirectory()) {
158
+ files.push(...(await listFiles(root, fullPath)));
159
+ } else {
160
+ files.push(normalizeSlashes(path.relative(root, fullPath)));
161
+ }
162
+ }
163
+
164
+ return files.sort();
165
+ }
166
+
167
+ async function readSkillPackageFiles(packageDir) {
168
+ const filePaths = await listFiles(packageDir);
169
+ const files = [];
170
+
171
+ for (const relativePath of filePaths) {
172
+ const fullPath = path.join(packageDir, relativePath);
173
+ const content = await fs.readFile(fullPath, "utf8");
174
+ files.push({ path: relativePath, content });
175
+ }
176
+
177
+ return files;
178
+ }
179
+
180
+ function checksumFiles(files) {
181
+ const hash = crypto.createHash("sha256");
182
+ for (const file of files.sort((a, b) => a.path.localeCompare(b.path))) {
183
+ hash.update(file.path);
184
+ hash.update("\0");
185
+ hash.update(file.content);
186
+ hash.update("\0");
187
+ }
188
+ return `sha256-${hash.digest("hex")}`;
189
+ }
190
+
191
+ function defaultConfig(projectRoot) {
192
+ return {
193
+ $schema: "https://skillhub.local/schema/skillhub.schema.json",
194
+ project: path.basename(projectRoot),
195
+ registry: "./skillhub-registry",
196
+ skillsDir: DEFAULT_SKILLS_DIR,
197
+ skills: {},
198
+ adapters: {
199
+ agentsMd: true,
200
+ cursorRules: true,
201
+ claude: false,
202
+ githubCopilot: false,
203
+ },
204
+ };
205
+ }
206
+
207
+ function defaultLock() {
208
+ return {
209
+ lockfileVersion: 1,
210
+ updatedAt: now(),
211
+ skills: {},
212
+ };
213
+ }
214
+
215
+ async function readProjectConfig(projectRoot) {
216
+ const configPath = path.join(projectRoot, CONFIG_FILE);
217
+ const config = await readJson(configPath, null);
218
+ if (!config) {
219
+ throw new Error(`No ${CONFIG_FILE} found. Run: skillhub init`);
220
+ }
221
+ return config;
222
+ }
223
+
224
+ function resolveRegistry(projectRoot, config, flags = {}) {
225
+ const value = flags.registry || process.env.SKILLHUB_REGISTRY || config.registry || "./skillhub-registry";
226
+
227
+ if (value.startsWith("http://") || value.startsWith("https://")) {
228
+ return value.replace(/\/$/, "");
229
+ }
230
+
231
+ return path.resolve(projectRoot, value);
232
+ }
233
+
234
+ async function loadLocalSkill(registryPath, name, requestedVersion) {
235
+ const skillRoot = safeJoin(registryPath, name);
236
+
237
+ if (!(await exists(skillRoot))) {
238
+ throw new Error(`Skill "${name}" was not found in registry: ${registryPath}`);
239
+ }
240
+
241
+ let packageDir;
242
+ let version = requestedVersion;
243
+
244
+ if (requestedVersion) {
245
+ packageDir = path.join(skillRoot, requestedVersion);
246
+ } else if (await exists(path.join(skillRoot, "skill.json"))) {
247
+ packageDir = skillRoot;
248
+ } else {
249
+ const entries = await fs.readdir(skillRoot, { withFileTypes: true });
250
+ const versions = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
251
+ if (versions.length === 0) {
252
+ throw new Error(`Skill "${name}" has no versions in registry.`);
253
+ }
254
+ version = sortVersions(versions)[0];
255
+ packageDir = path.join(skillRoot, version);
256
+ }
257
+
258
+ if (!(await exists(packageDir))) {
259
+ throw new Error(`Skill "${name}@${requestedVersion}" was not found.`);
260
+ }
261
+
262
+ const manifestPath = path.join(packageDir, "skill.json");
263
+ if (!(await exists(manifestPath))) {
264
+ throw new Error(`Missing skill.json in ${packageDir}`);
265
+ }
266
+
267
+ const manifest = await readJson(manifestPath);
268
+ const files = await readSkillPackageFiles(packageDir);
269
+
270
+ validateSkillManifest(manifest, files);
271
+
272
+ return {
273
+ manifest: {
274
+ ...manifest,
275
+ name: manifest.name || name,
276
+ version: manifest.version || version || "0.0.0",
277
+ },
278
+ files,
279
+ source: normalizeSlashes(path.relative(process.cwd(), packageDir)) || packageDir,
280
+ };
281
+ }
282
+
283
+ async function loadRemoteSkill(registryUrl, name, requestedVersion) {
284
+ const versionPath = requestedVersion ? requestedVersion : "latest";
285
+ const url = `${registryUrl}/skills/${encodeURIComponent(name)}/${encodeURIComponent(versionPath)}`;
286
+ const response = await fetch(url);
287
+
288
+ if (!response.ok) {
289
+ throw new Error(`Registry returned ${response.status} for ${url}`);
290
+ }
291
+
292
+ const payload = await response.json();
293
+ const manifest = payload.manifest || payload.skill || payload;
294
+ const files = payload.files || [];
295
+
296
+ validateSkillManifest(manifest, files);
297
+
298
+ return {
299
+ manifest,
300
+ files,
301
+ source: url,
302
+ };
303
+ }
304
+
305
+ async function loadSkill(registry, name, requestedVersion) {
306
+ if (registry.startsWith("http://") || registry.startsWith("https://")) {
307
+ return loadRemoteSkill(registry, name, requestedVersion);
308
+ }
309
+
310
+ return loadLocalSkill(registry, name, requestedVersion);
311
+ }
312
+
313
+ function validateSkillManifest(manifest, files) {
314
+ if (!manifest || typeof manifest !== "object") {
315
+ throw new Error("Invalid skill manifest.");
316
+ }
317
+
318
+ if (!manifest.name) throw new Error("skill.json must include name.");
319
+ if (!manifest.version) throw new Error("skill.json must include version.");
320
+
321
+ const hasSkillMd = files.some((file) => file.path === "SKILL.md");
322
+ if (!hasSkillMd) {
323
+ throw new Error(`${manifest.name}@${manifest.version} must include SKILL.md.`);
324
+ }
325
+ }
326
+
327
+ async function writeSkillFiles(projectRoot, skillsDir, skillName, files) {
328
+ const targetDir = path.join(projectRoot, skillsDir, skillName);
329
+ await fs.rm(targetDir, { recursive: true, force: true });
330
+ await ensureDir(targetDir);
331
+
332
+ for (const file of files) {
333
+ const targetPath = safeJoin(targetDir, file.path);
334
+ await ensureDir(path.dirname(targetPath));
335
+ await fs.writeFile(targetPath, file.content, "utf8");
336
+ }
337
+
338
+ return targetDir;
339
+ }
340
+
341
+ async function updateAdapters(projectRoot, config, lock) {
342
+ const adapters = config.adapters || {};
343
+ const skillsDir = config.skillsDir || DEFAULT_SKILLS_DIR;
344
+ const installed = Object.entries(lock.skills || {}).sort(([a], [b]) => a.localeCompare(b));
345
+
346
+ if (adapters.agentsMd) {
347
+ await updateAgentsMd(projectRoot, skillsDir, installed);
348
+ }
349
+
350
+ if (adapters.cursorRules) {
351
+ await updateCursorRules(projectRoot, skillsDir, installed);
352
+ }
353
+
354
+ if (adapters.claude) {
355
+ await updateClaudeMd(projectRoot, skillsDir, installed);
356
+ }
357
+
358
+ if (adapters.githubCopilot) {
359
+ await updateCopilotInstructions(projectRoot, skillsDir, installed);
360
+ }
361
+ }
362
+
363
+ function generatedSkillList(skillsDir, installed) {
364
+ if (installed.length === 0) {
365
+ return "No SkillHub skills installed yet.";
366
+ }
367
+
368
+ return installed
369
+ .map(([name, meta]) => `- ${name}@${meta.version}: ${skillsDir}/${name}/SKILL.md`)
370
+ .join("\n");
371
+ }
372
+
373
+ async function replaceGeneratedBlock(filePath, generatedBlock, defaultPrefix = "") {
374
+ let content = defaultPrefix;
375
+
376
+ if (await exists(filePath)) {
377
+ content = await fs.readFile(filePath, "utf8");
378
+ }
379
+
380
+ const block = `${GENERATED_START}\n${generatedBlock.trim()}\n${GENERATED_END}`;
381
+ const pattern = new RegExp(`${GENERATED_START}[\\s\\S]*?${GENERATED_END}`);
382
+
383
+ if (pattern.test(content)) {
384
+ content = content.replace(pattern, block);
385
+ } else {
386
+ const separator = content.trim().length > 0 ? "\n\n" : "";
387
+ content = `${content.trimEnd()}${separator}${block}\n`;
388
+ }
389
+
390
+ await ensureDir(path.dirname(filePath));
391
+ await fs.writeFile(filePath, content, "utf8");
392
+ }
393
+
394
+ async function updateAgentsMd(projectRoot, skillsDir, installed) {
395
+ const body = `# SkillHub Instructions\n\nWhen working in this project, read and follow the installed skills below before generating or changing code.\n\n${generatedSkillList(skillsDir, installed)}`;
396
+ await replaceGeneratedBlock(path.join(projectRoot, "AGENTS.md"), body, "# Project Agents\n");
397
+ }
398
+
399
+ async function updateCursorRules(projectRoot, skillsDir, installed) {
400
+ const body = `---\ndescription: SkillHub installed project skills\nalwaysApply: true\n---\n\n# SkillHub Rules\n\nFollow these installed skill files before editing code:\n\n${generatedSkillList(skillsDir, installed)}`;
401
+ await ensureDir(path.join(projectRoot, ".cursor/rules"));
402
+ await fs.writeFile(path.join(projectRoot, ".cursor/rules/skillhub.mdc"), `${body}\n`, "utf8");
403
+ }
404
+
405
+ async function updateClaudeMd(projectRoot, skillsDir, installed) {
406
+ const body = `# SkillHub Instructions\n\nFollow the installed skills below:\n\n${generatedSkillList(skillsDir, installed)}`;
407
+ await replaceGeneratedBlock(path.join(projectRoot, "CLAUDE.md"), body, "# Claude Project Instructions\n");
408
+ }
409
+
410
+ async function updateCopilotInstructions(projectRoot, skillsDir, installed) {
411
+ const body = `# SkillHub Instructions\n\nUse these installed skill files as project coding guidance:\n\n${generatedSkillList(skillsDir, installed)}`;
412
+ await replaceGeneratedBlock(path.join(projectRoot, ".github/copilot-instructions.md"), body, "# GitHub Copilot Instructions\n");
413
+ }
414
+
415
+ async function commandInit(projectRoot, flags) {
416
+ const configPath = path.join(projectRoot, CONFIG_FILE);
417
+ const lockPath = path.join(projectRoot, LOCK_FILE);
418
+
419
+ if (await exists(configPath)) {
420
+ warn(`${CONFIG_FILE} already exists.`);
421
+ } else {
422
+ const config = defaultConfig(projectRoot);
423
+ if (flags.registry) config.registry = flags.registry;
424
+ await writeJson(configPath, config);
425
+ ok(`Created ${CONFIG_FILE}`);
426
+ }
427
+
428
+ if (await exists(lockPath)) {
429
+ warn(`${LOCK_FILE} already exists.`);
430
+ } else {
431
+ await writeJson(lockPath, defaultLock());
432
+ ok(`Created ${LOCK_FILE}`);
433
+ }
434
+
435
+ await ensureDir(path.join(projectRoot, DEFAULT_SKILLS_DIR));
436
+ ok(`Created ${DEFAULT_SKILLS_DIR}`);
437
+
438
+ info("Next: skillhub install nextjs-clean-architecture");
439
+ }
440
+
441
+ async function commandInstall(projectRoot, spec, flags = {}) {
442
+ const configPath = path.join(projectRoot, CONFIG_FILE);
443
+ const lockPath = path.join(projectRoot, LOCK_FILE);
444
+ const config = await readProjectConfig(projectRoot);
445
+ const lock = (await readJson(lockPath, null)) || defaultLock();
446
+ const registry = resolveRegistry(projectRoot, config, flags);
447
+ const { name, version } = parseSkillSpec(spec);
448
+
449
+ info(`Resolving ${name}${version ? `@${version}` : ""}`);
450
+ const skill = await loadSkill(registry, name, version);
451
+ const skillName = skill.manifest.name;
452
+ const skillsDir = config.skillsDir || DEFAULT_SKILLS_DIR;
453
+ const checksum = checksumFiles(skill.files);
454
+
455
+ await writeSkillFiles(projectRoot, skillsDir, skillName, skill.files);
456
+
457
+ config.skills = config.skills || {};
458
+ config.skills[skillName] = skill.manifest.version;
459
+ await writeJson(configPath, config);
460
+
461
+ lock.updatedAt = now();
462
+ lock.skills = lock.skills || {};
463
+ lock.skills[skillName] = {
464
+ version: skill.manifest.version,
465
+ source: skill.source,
466
+ installTo: `${skillsDir}/${skillName}`,
467
+ checksum,
468
+ installedAt: now(),
469
+ };
470
+ await writeJson(lockPath, lock);
471
+
472
+ await updateAdapters(projectRoot, config, lock);
473
+
474
+ ok(`Installed ${skillName}@${skill.manifest.version}`);
475
+ info(`Files written to ${skillsDir}/${skillName}`);
476
+ }
477
+
478
+ async function commandSync(projectRoot, flags = {}) {
479
+ const config = await readProjectConfig(projectRoot);
480
+ const skills = Object.entries(config.skills || {});
481
+
482
+ if (skills.length === 0) {
483
+ warn("No skills configured in skillhub.json.");
484
+ return;
485
+ }
486
+
487
+ for (const [name, version] of skills) {
488
+ await commandInstall(projectRoot, `${name}@${version}`, flags);
489
+ }
490
+
491
+ ok(`Synced ${skills.length} skill${skills.length === 1 ? "" : "s"}.`);
492
+ }
493
+
494
+ async function commandList(projectRoot) {
495
+ const lock = await readJson(path.join(projectRoot, LOCK_FILE), null);
496
+
497
+ if (!lock || !lock.skills || Object.keys(lock.skills).length === 0) {
498
+ warn("No skills installed yet.");
499
+ return;
500
+ }
501
+
502
+ console.log(paint("bold", "Installed SkillHub skills"));
503
+ for (const [name, meta] of Object.entries(lock.skills).sort(([a], [b]) => a.localeCompare(b))) {
504
+ console.log(`- ${name}@${meta.version} ${paint("dim", `→ ${meta.installTo}`)}`);
505
+ }
506
+ }
507
+
508
+ async function commandRemove(projectRoot, skillName) {
509
+ if (!skillName) throw new Error("Skill name is required.");
510
+
511
+ const configPath = path.join(projectRoot, CONFIG_FILE);
512
+ const lockPath = path.join(projectRoot, LOCK_FILE);
513
+ const config = await readProjectConfig(projectRoot);
514
+ const lock = (await readJson(lockPath, null)) || defaultLock();
515
+ const skillsDir = config.skillsDir || DEFAULT_SKILLS_DIR;
516
+
517
+ delete config.skills?.[skillName];
518
+ delete lock.skills?.[skillName];
519
+ lock.updatedAt = now();
520
+
521
+ await fs.rm(path.join(projectRoot, skillsDir, skillName), { recursive: true, force: true });
522
+ await writeJson(configPath, config);
523
+ await writeJson(lockPath, lock);
524
+ await updateAdapters(projectRoot, config, lock);
525
+
526
+ ok(`Removed ${skillName}`);
527
+ }
528
+
529
+ async function commandCreate(projectRoot, name, flags = {}) {
530
+ if (!name) throw new Error("Skill name is required.");
531
+
532
+ const config = (await readJson(path.join(projectRoot, CONFIG_FILE), null)) || defaultConfig(projectRoot);
533
+ const registry = resolveRegistry(projectRoot, config, flags);
534
+
535
+ if (registry.startsWith("http://") || registry.startsWith("https://")) {
536
+ throw new Error("Cannot create a skill inside a remote registry. Use --registry ./path");
537
+ }
538
+
539
+ const version = flags.version || "1.0.0";
540
+ const category = flags.category || "general";
541
+ const packageDir = path.join(registry, name, version);
542
+
543
+ if (await exists(packageDir)) {
544
+ throw new Error(`Skill already exists: ${packageDir}`);
545
+ }
546
+
547
+ await ensureDir(path.join(packageDir, "rules"));
548
+ await ensureDir(path.join(packageDir, "checklist"));
549
+
550
+ await writeJson(path.join(packageDir, "skill.json"), {
551
+ name,
552
+ version,
553
+ description: flags.description || `Reusable ${name} project skill`,
554
+ category,
555
+ tags: [category],
556
+ entry: "SKILL.md",
557
+ compatibleWith: ["cursor", "claude", "chatgpt", "github-copilot"],
558
+ });
559
+
560
+ await fs.writeFile(
561
+ path.join(packageDir, "SKILL.md"),
562
+ `# ${name}\n\n## Purpose\n\nDescribe when this skill should be used.\n\n## Rules\n\n1. Add your first rule.\n2. Add examples and edge cases.\n3. Keep outputs consistent across projects.\n\n## Definition of Done\n\n- Requirements are clear\n- Implementation follows project standards\n- Tests or validation steps are documented\n`,
563
+ "utf8",
564
+ );
565
+
566
+ await fs.writeFile(path.join(packageDir, "rules/main.md"), `# ${name} Rules\n\n- Add project-specific rules here.\n`, "utf8");
567
+ await fs.writeFile(path.join(packageDir, "checklist/definition-of-done.md"), `# Definition of Done\n\n- [ ] Rules followed\n- [ ] Code reviewed\n- [ ] Edge cases checked\n`, "utf8");
568
+
569
+ ok(`Created ${name}@${version}`);
570
+ info(`Location: ${packageDir}`);
571
+ }
572
+
573
+ async function commandValidate(projectRoot, specOrPath, flags = {}) {
574
+ if (!specOrPath) throw new Error("Skill name or path is required.");
575
+
576
+ let skill;
577
+ if (specOrPath.includes("/") || specOrPath.includes("\\") || specOrPath.startsWith(".")) {
578
+ const packageDir = path.resolve(projectRoot, specOrPath);
579
+ const manifest = await readJson(path.join(packageDir, "skill.json"));
580
+ const files = await readSkillPackageFiles(packageDir);
581
+ validateSkillManifest(manifest, files);
582
+ skill = { manifest, files };
583
+ } else {
584
+ const config = await readProjectConfig(projectRoot);
585
+ const registry = resolveRegistry(projectRoot, config, flags);
586
+ const { name, version } = parseSkillSpec(specOrPath);
587
+ skill = await loadSkill(registry, name, version);
588
+ }
589
+
590
+ const checksum = checksumFiles(skill.files);
591
+ ok(`Valid skill: ${skill.manifest.name}@${skill.manifest.version}`);
592
+ info(`Files: ${skill.files.length}`);
593
+ info(`Checksum: ${checksum}`);
594
+ }
595
+
596
+ function toWords(value) {
597
+ return String(value)
598
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
599
+ .replace(/[_\-/]+/g, " ")
600
+ .trim()
601
+ .split(/\s+/)
602
+ .filter(Boolean);
603
+ }
604
+
605
+ function toKebabCase(value) {
606
+ return toWords(value)
607
+ .map((word) => word.toLowerCase())
608
+ .join("-");
609
+ }
610
+
611
+ function toCamelCase(value) {
612
+ const words = toWords(value).map((word) => word.toLowerCase());
613
+ return words
614
+ .map((word, index) => (index === 0 ? word : `${word.charAt(0).toUpperCase()}${word.slice(1)}`))
615
+ .join("");
616
+ }
617
+
618
+ function toPascalCase(value) {
619
+ return toWords(value)
620
+ .map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1).toLowerCase()}`)
621
+ .join("");
622
+ }
623
+
624
+ function toTitleCase(value) {
625
+ return toWords(value)
626
+ .map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1).toLowerCase()}`)
627
+ .join(" ");
628
+ }
629
+
630
+ function templateVariables(name, flags = {}) {
631
+ const baseName = name || flags.name;
632
+ if (!baseName) throw new Error("Generator name is required. Example: skillhub generate shadcn-crud-generator feature users");
633
+
634
+ return {
635
+ name: baseName,
636
+ kebabName: flags.kebabName || toKebabCase(baseName),
637
+ camelName: flags.camelName || toCamelCase(baseName),
638
+ pascalName: flags.pascalName || toPascalCase(baseName),
639
+ title: flags.title || toTitleCase(baseName),
640
+ };
641
+ }
642
+
643
+ function renderTemplate(value, variables) {
644
+ return value.replace(/{{\s*(\w+)\s*}}/g, (_, key) => variables[key] ?? "");
645
+ }
646
+
647
+ async function commandGenerate(projectRoot, skillName, templateName, resourceName, flags = {}) {
648
+ if (!skillName) throw new Error("Skill name is required.");
649
+ if (!templateName) throw new Error("Template name is required.");
650
+
651
+ const config = await readProjectConfig(projectRoot);
652
+ const skillsDir = config.skillsDir || DEFAULT_SKILLS_DIR;
653
+ const templateDir = path.join(projectRoot, skillsDir, skillName, "templates", templateName);
654
+
655
+ if (!(await exists(templateDir))) {
656
+ throw new Error(`Template not found: ${skillsDir}/${skillName}/templates/${templateName}. Install the skill first.`);
657
+ }
658
+
659
+ const variables = templateVariables(resourceName || flags.name, flags);
660
+ const outDir = path.resolve(projectRoot, flags.out || `src/features/${variables.kebabName}`);
661
+ const templateFiles = await listFiles(templateDir);
662
+
663
+ if (templateFiles.length === 0) {
664
+ warn(`No template files found in ${templateDir}`);
665
+ return;
666
+ }
667
+
668
+ for (const relativePath of templateFiles) {
669
+ const renderedRelativePath = renderTemplate(relativePath, variables).replace(/\.hbs$/, "");
670
+ const targetPath = safeJoin(outDir, renderedRelativePath);
671
+ const sourcePath = path.join(templateDir, relativePath);
672
+ const content = await fs.readFile(sourcePath, "utf8");
673
+
674
+ if ((await exists(targetPath)) && !flags.force) {
675
+ throw new Error(`File already exists: ${targetPath}. Use --force to overwrite.`);
676
+ }
677
+
678
+ await ensureDir(path.dirname(targetPath));
679
+ await fs.writeFile(targetPath, renderTemplate(content, variables), "utf8");
680
+ ok(`Generated ${normalizeSlashes(path.relative(projectRoot, targetPath))}`);
681
+ }
682
+
683
+ info(`Template variables: ${JSON.stringify(variables)}`);
684
+ }
685
+
686
+ function printHelp() {
687
+ console.log(`\n${paint("bold", "SkillHub CLI")}\n\nInstall reusable AI/project skills into any codebase.\n\n${paint("bold", "Usage")}\n skillhub <command> [options]\n\n${paint("bold", "Commands")}\n init Create skillhub.json, lockfile, and .ai/skills\n install <skill[@version]> Install a skill from the registry\n sync Reinstall all skills from skillhub.json\n list Show installed skills\n remove <skill> Remove an installed skill\n create <skill> Scaffold a new local skill package\n validate <skill|path> Validate skill.json and SKILL.md\n\n${paint("bold", "Options")}\n --registry <path|url> Registry location. Default: ./skillhub-registry\n --version <version> Version used by create. Default: 1.0.0\n --category <name> Category used by create. Default: general\n\n${paint("bold", "Examples")}\n skillhub init\n skillhub install nextjs-clean-architecture\n skillhub install shadcn-crud-generator@1.0.0\n skillhub sync\n skillhub create api-security-rules --category security\n`);
688
+ }
689
+
690
+ async function main() {
691
+ const [, , command, ...rest] = process.argv;
692
+ const { positional, flags } = parseFlags(rest);
693
+ const projectRoot = process.cwd();
694
+
695
+ if (!command || command === "help" || command === "--help" || command === "-h") {
696
+ printHelp();
697
+ return;
698
+ }
699
+
700
+ switch (command) {
701
+ case "init":
702
+ await commandInit(projectRoot, flags);
703
+ break;
704
+ case "install":
705
+ case "add":
706
+ await commandInstall(projectRoot, positional[0], flags);
707
+ break;
708
+ case "sync":
709
+ await commandSync(projectRoot, flags);
710
+ break;
711
+ case "list":
712
+ case "ls":
713
+ await commandList(projectRoot);
714
+ break;
715
+ case "remove":
716
+ case "rm":
717
+ await commandRemove(projectRoot, positional[0]);
718
+ break;
719
+ case "create":
720
+ await commandCreate(projectRoot, positional[0], flags);
721
+ break;
722
+ case "validate":
723
+ await commandValidate(projectRoot, positional[0], flags);
724
+ break;
725
+ case "generate":
726
+ case "gen":
727
+ await commandGenerate(projectRoot, positional[0], positional[1], positional[2], flags);
728
+ break;
729
+ default:
730
+ throw new Error(`Unknown command: ${command}`);
731
+ }
732
+ }
733
+
734
+ main().catch((error) => {
735
+ fail(error.message);
736
+ process.exitCode = 1;
737
+ });
738
+
739
+ export {
740
+ parseSkillSpec,
741
+ checksumFiles,
742
+ sortVersions,
743
+ validateSkillManifest,
744
+ defaultConfig,
745
+ };
746
+
747
+ // Let people run this file through `node packages/skillhub-cli/bin/skillhub.mjs`.