@robosoft/skillhub-cli 0.1.1 → 0.3.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +21 -21
  2. package/README.md +166 -184
  3. package/bin/skillhub.mjs +734 -364
  4. package/docs/skillhub-cli-logic.md +115 -83
  5. package/package.json +8 -5
  6. package/skillhub-registry/CHANGELOG.md +65 -0
  7. package/skillhub-registry/CLAUDE.md +108 -0
  8. package/skillhub-registry/README.md +196 -16
  9. package/skillhub-registry/docs/cheat-sheet.md +272 -0
  10. package/skillhub-registry/docs/contributing.md +166 -0
  11. package/skillhub-registry/docs/cost-hygiene.md +175 -0
  12. package/skillhub-registry/docs/customization.md +321 -0
  13. package/skillhub-registry/docs/exception-process.md +194 -0
  14. package/skillhub-registry/docs/installation.md +277 -0
  15. package/skillhub-registry/domain/api.md +303 -0
  16. package/skillhub-registry/domain/frontend/web-app.md +17 -0
  17. package/skillhub-registry/domain/frontend-app.md +46 -0
  18. package/skillhub-registry/domain/frontend-architecture.md +126 -0
  19. package/skillhub-registry/rules/anti-patterns.md +95 -0
  20. package/skillhub-registry/rules/code-standards.md +182 -0
  21. package/skillhub-registry/rules/frontend/antipattern.md +21 -0
  22. package/skillhub-registry/rules/frontend/component-standards.md +10 -0
  23. package/skillhub-registry/rules/frontend-app.md +24 -0
  24. package/skillhub-registry/rules/general.md +51 -0
  25. package/skillhub-registry/skills/api/SKILL.md +167 -0
  26. package/skillhub-registry/skills/build/SKILL.md +114 -0
  27. package/skillhub-registry/skills/fast/SKILL.md +56 -0
  28. package/skillhub-registry/skills/feature-dev/SKILL.md +166 -0
  29. package/skillhub-registry/skills/frontend/app/SKILL.md +28 -0
  30. package/skillhub-registry/skills/frontend/app/rules/main.md +6 -0
  31. package/skillhub-registry/skills/frontend/app/skill.json +10 -0
  32. package/skillhub-registry/skills/frontend/app/templates/feature/{{kebabName}}.tsx.hbs +11 -0
  33. package/skillhub-registry/skills/frontend-app/SKILL.md +48 -0
  34. package/skillhub-registry/skills/frontend-app/rules/main.md +6 -0
  35. package/skillhub-registry/skills/frontend-app/skill.json +11 -0
  36. package/skillhub-registry/skills/frontend-app/templates/feature/{{kebabName}}.tsx.hbs +11 -0
  37. package/skillhub-registry/skills/performance/SKILL.md +168 -0
  38. package/skillhub-registry/skills/react/SKILL.md +224 -0
  39. package/skillhub-registry/skills/refactor/SKILL.md +149 -0
  40. package/skillhub-registry/skills/review/SKILL.md +199 -0
  41. package/skillhub-registry/skills/strict/SKILL.md +74 -0
  42. package/skillhub-registry/skills/testing/SKILL.md +132 -0
  43. package/skillhub-registry/accessibility-review/1.0.0/SKILL.md +0 -22
  44. package/skillhub-registry/accessibility-review/1.0.0/checklist/ui-review.md +0 -8
  45. package/skillhub-registry/accessibility-review/1.0.0/skill.json +0 -9
  46. package/skillhub-registry/nextjs-clean-architecture/1.0.0/SKILL.md +0 -37
  47. package/skillhub-registry/nextjs-clean-architecture/1.0.0/checklist/definition-of-done.md +0 -9
  48. package/skillhub-registry/nextjs-clean-architecture/1.0.0/rules/folder-structure.md +0 -7
  49. package/skillhub-registry/nextjs-clean-architecture/1.0.0/skill.json +0 -9
  50. package/skillhub-registry/shadcn-crud-generator/1.0.0/SKILL.md +0 -25
  51. package/skillhub-registry/shadcn-crud-generator/1.0.0/skill.json +0 -9
  52. package/skillhub-registry/shadcn-crud-generator/1.0.0/templates/feature/actions.ts.hbs +0 -16
  53. package/skillhub-registry/shadcn-crud-generator/1.0.0/templates/feature/page.tsx.hbs +0 -13
package/bin/skillhub.mjs CHANGED
@@ -1,34 +1,73 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import fs from "node:fs/promises";
4
+ import fsSync from "node:fs";
4
5
  import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
5
7
  import crypto from "node:crypto";
6
8
  import process from "node:process";
7
9
  import readline from "node:readline/promises";
8
10
 
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = path.dirname(__filename);
13
+ const PACKAGE_ROOT = path.resolve(__dirname, "..");
14
+ const BUNDLED_REGISTRY_DIR = path.join(PACKAGE_ROOT, "skillhub-registry");
15
+
9
16
  const CONFIG_FILE = "skillhub.json";
10
17
  const LOCK_FILE = "skillhub.lock.json";
11
18
  const DEFAULT_TARGET = "ai";
19
+ const GENERATED_START = "<!-- skillhub:start -->";
20
+ const GENERATED_END = "<!-- skillhub:end -->";
21
+
12
22
  const TARGETS = {
13
23
  ai: {
14
- label: ".ai/skills",
15
- skillsDir: ".ai/skills",
24
+ label: "Generic AI",
25
+ sections: {
26
+ skills: ".ai/skills",
27
+ rules: ".ai/rules",
28
+ domain: ".ai/domain",
29
+ },
16
30
  adapters: { agentsMd: true, cursorRules: false, claude: false, githubCopilot: false },
17
31
  },
18
32
  cursor: {
19
- label: ".cursor/skills",
20
- skillsDir: ".cursor/skills",
33
+ label: "Cursor",
34
+ sections: {
35
+ skills: ".cursor/skills",
36
+ rules: ".cursor/rules",
37
+ domain: ".cursor/domain",
38
+ },
21
39
  adapters: { agentsMd: false, cursorRules: true, claude: false, githubCopilot: false },
22
40
  },
23
41
  claude: {
24
- label: ".claude/skills",
25
- skillsDir: ".claude/skills",
42
+ label: "Claude",
43
+ sections: {
44
+ skills: ".claude/skills",
45
+ rules: ".claude/rules",
46
+ domain: ".claude/domain",
47
+ },
26
48
  adapters: { agentsMd: false, cursorRules: false, claude: true, githubCopilot: false },
27
49
  },
28
50
  };
29
- const DEFAULT_SKILLS_DIR = TARGETS[DEFAULT_TARGET].skillsDir;
30
- const GENERATED_START = "<!-- skillhub:start -->";
31
- const GENERATED_END = "<!-- skillhub:end -->";
51
+
52
+ const SECTION_ALIASES = {
53
+ skill: "skills",
54
+ skills: "skills",
55
+ sk: "skills",
56
+ rule: "rules",
57
+ rules: "rules",
58
+ rl: "rules",
59
+ domain: "domain",
60
+ domains: "domain",
61
+ knowledge: "domain",
62
+ "domain-knowledge": "domain",
63
+ dk: "domain",
64
+ };
65
+
66
+ const SECTION_SINGULAR = {
67
+ skills: "skill",
68
+ rules: "rule",
69
+ domain: "domain",
70
+ };
32
71
 
33
72
  const colors = {
34
73
  reset: "\x1b[0m",
@@ -95,7 +134,7 @@ function normalizeSlashes(value) {
95
134
  function safeJoin(root, relativePath) {
96
135
  const target = path.resolve(root, relativePath);
97
136
  const normalizedRoot = path.resolve(root);
98
- if (!target.startsWith(normalizedRoot)) {
137
+ if (target !== normalizedRoot && !target.startsWith(`${normalizedRoot}${path.sep}`)) {
99
138
  throw new Error(`Unsafe path detected: ${relativePath}`);
100
139
  }
101
140
  return target;
@@ -141,11 +180,8 @@ function normalizeTarget(value) {
141
180
  ai: "ai",
142
181
  cursor: "cursor",
143
182
  cursorrules: "cursor",
144
- ".cursor": "cursor",
145
183
  claude: "claude",
146
184
  cloude: "claude",
147
- ".claude": "claude",
148
- ".cloude": "claude",
149
185
  };
150
186
 
151
187
  const target = aliases[normalized] || normalized;
@@ -156,17 +192,24 @@ function normalizeTarget(value) {
156
192
  return target;
157
193
  }
158
194
 
159
- async function askTarget(defaultTarget = DEFAULT_TARGET) {
160
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
161
- return defaultTarget;
195
+ function normalizeSection(value, fallback = "skills") {
196
+ const raw = String(value || fallback).trim().toLowerCase();
197
+ const normalized = SECTION_ALIASES[raw] || raw;
198
+ if (!TARGETS.ai.sections[normalized]) {
199
+ throw new Error(`Invalid section "${value}". Use: skill, rule, or domain.`);
162
200
  }
201
+ return normalized;
202
+ }
203
+
204
+ async function askTarget(defaultTarget = DEFAULT_TARGET) {
205
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return defaultTarget;
163
206
 
164
207
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
165
208
  try {
166
- console.log(paint("bold", "Where should SkillHub install skill files?"));
167
- console.log(` 1. ${TARGETS.ai.skillsDir} ${paint("dim", "(generic AI folder + AGENTS.md)")}`);
168
- console.log(` 2. ${TARGETS.cursor.skillsDir} ${paint("dim", "(Cursor rules)")}`);
169
- console.log(` 3. ${TARGETS.claude.skillsDir} ${paint("dim", "(Claude project instructions)")}`);
209
+ console.log(paint("bold", "Where should SkillHub attach project knowledge?"));
210
+ console.log(` 1. .ai ${paint("dim", "(generic AI folder + AGENTS.md)")}`);
211
+ console.log(` 2. .cursor ${paint("dim", "(Cursor skills/rules/domain)")}`);
212
+ console.log(` 3. .claude ${paint("dim", "(Claude skills/rules/domain)")}`);
170
213
 
171
214
  const answer = await rl.question(`Choose target [1/2/3 or ai/cursor/claude] (${defaultTarget}): `);
172
215
  const raw = answer.trim();
@@ -180,10 +223,14 @@ async function askTarget(defaultTarget = DEFAULT_TARGET) {
180
223
  }
181
224
  }
182
225
 
226
+ function targetSections(target) {
227
+ return { ...TARGETS[normalizeTarget(target) || DEFAULT_TARGET].sections };
228
+ }
229
+
183
230
  function getConfigTarget(config) {
184
231
  if (config?.target) return normalizeTarget(config.target);
185
232
 
186
- const skillsDir = config?.skillsDir || DEFAULT_SKILLS_DIR;
233
+ const skillsDir = config?.skillsDir || config?.sections?.skills || TARGETS.ai.sections.skills;
187
234
  if (skillsDir.startsWith(".cursor/")) return "cursor";
188
235
  if (skillsDir.startsWith(".claude/") || skillsDir.startsWith(".cloude/")) return "claude";
189
236
  return "ai";
@@ -194,42 +241,141 @@ function applyTargetToConfig(config, targetValue, { preserveExistingAdapters = f
194
241
  const targetConfig = TARGETS[target];
195
242
 
196
243
  config.target = target;
197
- config.skillsDir = targetConfig.skillsDir;
244
+ config.sections = targetSections(target);
245
+ config.skillsDir = config.sections.skills; // backward compatibility
246
+ config.rulesDir = config.sections.rules;
247
+ config.domainDir = config.sections.domain;
198
248
  config.adapters = preserveExistingAdapters
199
249
  ? { ...targetConfig.adapters, ...(config.adapters || {}) }
200
- : { ...targetConfig.adapters, ...(target === "ai" ? { agentsMd: true } : {}) };
250
+ : { ...targetConfig.adapters };
201
251
 
202
252
  return config;
203
253
  }
204
254
 
205
- function parseSkillSpec(spec) {
206
- if (!spec || spec.trim().length === 0) {
207
- throw new Error("Skill name is required.");
255
+ function defaultInstalled() {
256
+ return { skills: {}, rules: {}, domain: {} };
257
+ }
258
+
259
+ function normalizeInstalled(value = {}) {
260
+ return {
261
+ skills: { ...(value.skills || {}) },
262
+ rules: { ...(value.rules || {}) },
263
+ domain: { ...(value.domain || {}) },
264
+ };
265
+ }
266
+
267
+ function defaultConfig(projectRoot, target = DEFAULT_TARGET) {
268
+ const config = {
269
+ $schema: "https://skillhub.local/schema/skillhub.schema.json",
270
+ project: path.basename(projectRoot),
271
+ registry: "./skillhub-registry",
272
+ target: DEFAULT_TARGET,
273
+ sections: targetSections(DEFAULT_TARGET),
274
+ skillsDir: TARGETS.ai.sections.skills,
275
+ rulesDir: TARGETS.ai.sections.rules,
276
+ domainDir: TARGETS.ai.sections.domain,
277
+ skills: {}, // backward compatibility
278
+ installed: defaultInstalled(),
279
+ adapters: {},
280
+ };
281
+
282
+ return applyTargetToConfig(config, target);
283
+ }
284
+
285
+ function defaultLock() {
286
+ return {
287
+ lockfileVersion: 2,
288
+ updatedAt: now(),
289
+ installed: defaultInstalled(),
290
+ skills: {}, // backward compatibility
291
+ };
292
+ }
293
+
294
+ async function readProjectConfig(projectRoot) {
295
+ const configPath = path.join(projectRoot, CONFIG_FILE);
296
+ const config = await readJson(configPath, null);
297
+ if (!config) throw new Error(`No ${CONFIG_FILE} found. Run: skillhub init`);
298
+
299
+ const target = getConfigTarget(config);
300
+ config.target = target;
301
+ config.sections = { ...targetSections(target), ...(config.sections || {}) };
302
+
303
+ // Migrate the older Cursor default. Rules should live directly under .cursor/rules,
304
+ // not under an extra .cursor/rules/skillhub namespace.
305
+ if (target === "cursor" && config.sections.rules === ".cursor/rules/skillhub") {
306
+ config.sections.rules = TARGETS.cursor.sections.rules;
208
307
  }
209
308
 
309
+ config.skillsDir = config.sections.skills || config.skillsDir || TARGETS[target].sections.skills;
310
+ config.rulesDir = config.sections.rules || config.rulesDir || TARGETS[target].sections.rules;
311
+ config.domainDir = config.sections.domain || config.domainDir || TARGETS[target].sections.domain;
312
+ config.adapters = config.adapters || TARGETS[target].adapters;
313
+ config.installed = normalizeInstalled(config.installed || { skills: config.skills || {} });
314
+ config.skills = config.installed.skills;
315
+
316
+ return config;
317
+ }
318
+
319
+ async function readProjectLock(projectRoot) {
320
+ const lock = (await readJson(path.join(projectRoot, LOCK_FILE), null)) || defaultLock();
321
+ lock.installed = normalizeInstalled(lock.installed || { skills: lock.skills || {} });
322
+ lock.skills = lock.installed.skills;
323
+ return lock;
324
+ }
325
+
326
+ function parseArtifactSpec(spec) {
327
+ if (!spec || spec.trim().length === 0) throw new Error("Name is required.");
328
+
210
329
  const trimmed = spec.trim();
211
330
  const atIndex = trimmed.lastIndexOf("@");
331
+ if (atIndex > 0) return { name: trimmed.slice(0, atIndex).replace(/\.md$/i, ""), version: trimmed.slice(atIndex + 1) };
332
+ return { name: trimmed.replace(/\.md$/i, ""), version: null };
333
+ }
212
334
 
213
- if (atIndex > 0) {
214
- return {
215
- name: trimmed.slice(0, atIndex),
216
- version: trimmed.slice(atIndex + 1),
217
- };
218
- }
335
+ function lastPathSegment(value) {
336
+ const parts = String(value || "")
337
+ .replace(/\\/g, "/")
338
+ .split("/")
339
+ .map((part) => part.trim())
340
+ .filter(Boolean);
341
+ return parts.at(-1) || String(value || "").trim();
342
+ }
343
+
344
+ function localArtifactName(section, requestedName, manifest, flags = {}) {
345
+ if (flags.as) return String(flags.as).trim().replace(/\.md$/i, "");
346
+
347
+ // Registry paths can be namespaced, for example frontend/antipattern.
348
+ // The namespace is only used to resolve the item from SkillHub. Locally we install
349
+ // the artifact by its leaf name: antipattern.md, app/, web-app.md.
350
+ const candidate = requestedName || manifest?.name || "";
351
+ const leaf = lastPathSegment(candidate);
352
+ return leaf || lastPathSegment(manifest?.name) || SECTION_SINGULAR[section];
353
+ }
354
+
355
+ function installedEntryVersion(entry) {
356
+ if (!entry) return null;
357
+ if (typeof entry === "string") return entry;
358
+ return entry.version || null;
359
+ }
219
360
 
220
- return { name: trimmed, version: null };
361
+ function installedEntryLocalName(key, entry) {
362
+ if (!entry || typeof entry === "string") return lastPathSegment(key);
363
+ return entry.localName || lastPathSegment(entry.name || key);
364
+ }
365
+
366
+ function installedEntrySpec(key, entry) {
367
+ if (!entry || typeof entry === "string") return key;
368
+ return entry.name || entry.sourceName || key;
221
369
  }
222
370
 
223
371
  function sortVersions(versions) {
224
372
  return versions.sort((left, right) => {
225
373
  const a = left.split(".").map((part) => Number.parseInt(part, 10) || 0);
226
374
  const b = right.split(".").map((part) => Number.parseInt(part, 10) || 0);
227
-
228
375
  for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
229
376
  const diff = (b[index] ?? 0) - (a[index] ?? 0);
230
377
  if (diff !== 0) return diff;
231
378
  }
232
-
233
379
  return right.localeCompare(left);
234
380
  });
235
381
  }
@@ -239,7 +385,8 @@ async function listFiles(root, current = root) {
239
385
  const files = [];
240
386
 
241
387
  for (const entry of entries) {
242
- if (entry.name === "node_modules" || entry.name === ".git") continue;
388
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === ".DS_Store") continue;
389
+ if (entry.name.startsWith("._")) continue;
243
390
 
244
391
  const fullPath = path.join(current, entry.name);
245
392
  if (entry.isDirectory()) {
@@ -252,22 +399,18 @@ async function listFiles(root, current = root) {
252
399
  return files.sort();
253
400
  }
254
401
 
255
- async function readSkillPackageFiles(packageDir) {
402
+ async function readPackageFiles(packageDir) {
256
403
  const filePaths = await listFiles(packageDir);
257
404
  const files = [];
258
-
259
405
  for (const relativePath of filePaths) {
260
- const fullPath = path.join(packageDir, relativePath);
261
- const content = await fs.readFile(fullPath, "utf8");
262
- files.push({ path: relativePath, content });
406
+ files.push({ path: relativePath, content: await fs.readFile(path.join(packageDir, relativePath), "utf8") });
263
407
  }
264
-
265
408
  return files;
266
409
  }
267
410
 
268
411
  function checksumFiles(files) {
269
412
  const hash = crypto.createHash("sha256");
270
- for (const file of files.sort((a, b) => a.path.localeCompare(b.path))) {
413
+ for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
271
414
  hash.update(file.path);
272
415
  hash.update("\0");
273
416
  hash.update(file.content);
@@ -276,444 +419,674 @@ function checksumFiles(files) {
276
419
  return `sha256-${hash.digest("hex")}`;
277
420
  }
278
421
 
279
- function defaultConfig(projectRoot, target = DEFAULT_TARGET) {
280
- const config = {
281
- $schema: "https://skillhub.local/schema/skillhub.schema.json",
282
- project: path.basename(projectRoot),
283
- registry: "./skillhub-registry",
284
- target: DEFAULT_TARGET,
285
- skillsDir: DEFAULT_SKILLS_DIR,
286
- skills: {},
287
- adapters: {},
288
- };
289
-
290
- return applyTargetToConfig(config, target);
291
- }
422
+ function resolveRegistry(projectRoot, config, flags = {}, options = {}) {
423
+ const { fallbackToBundled = true } = options;
424
+ const hasFlagRegistry = Boolean(flags.registry);
425
+ const hasEnvRegistry = Boolean(process.env.SKILLHUB_REGISTRY);
426
+ const value = flags.registry || process.env.SKILLHUB_REGISTRY || config.registry || "./skillhub-registry";
292
427
 
293
- function defaultLock() {
294
- return {
295
- lockfileVersion: 1,
296
- updatedAt: now(),
297
- skills: {},
298
- };
299
- }
428
+ if (value.startsWith("http://") || value.startsWith("https://")) return value.replace(/\/$/, "");
300
429
 
301
- async function readProjectConfig(projectRoot) {
302
- const configPath = path.join(projectRoot, CONFIG_FILE);
303
- const config = await readJson(configPath, null);
304
- if (!config) {
305
- throw new Error(`No ${CONFIG_FILE} found. Run: skillhub init`);
430
+ const resolved = path.resolve(projectRoot, value);
431
+ const isDefaultLocalRegistry = value === "./skillhub-registry" || value === "skillhub-registry";
432
+ if (fallbackToBundled && !hasFlagRegistry && !hasEnvRegistry && isDefaultLocalRegistry && !fsSync.existsSync(resolved) && fsSync.existsSync(BUNDLED_REGISTRY_DIR)) {
433
+ return BUNDLED_REGISTRY_DIR;
306
434
  }
307
435
 
308
- config.target = getConfigTarget(config);
309
- config.skillsDir = config.skillsDir || TARGETS[config.target].skillsDir;
310
- config.adapters = config.adapters || TARGETS[config.target].adapters;
311
- return config;
436
+ return resolved;
312
437
  }
313
438
 
314
- function resolveRegistry(projectRoot, config, flags = {}) {
315
- const value = flags.registry || process.env.SKILLHUB_REGISTRY || config.registry || "./skillhub-registry";
439
+ function firstHeading(content) {
440
+ const line = content.split(/\r?\n/).find((item) => item.trim().startsWith("# "));
441
+ return line ? line.replace(/^#\s+/, "").trim() : null;
442
+ }
316
443
 
317
- if (value.startsWith("http://") || value.startsWith("https://")) {
318
- return value.replace(/\/$/, "");
319
- }
444
+ async function readOptionalJson(filePath) {
445
+ return (await exists(filePath)) ? readJson(filePath) : null;
446
+ }
320
447
 
321
- return path.resolve(projectRoot, value);
448
+ function defaultManifest(section, name, version, files) {
449
+ const entryFile = section === "skills" ? "SKILL.md" : section === "rules" ? "RULE.md" : "DOMAIN.md";
450
+ const entry = files.find((file) => file.path === entryFile) || files[0];
451
+ const title = entry ? firstHeading(entry.content) : name;
452
+ return {
453
+ name,
454
+ version: version || "1.0.0",
455
+ type: SECTION_SINGULAR[section],
456
+ section,
457
+ description: title ? `${title} ${SECTION_SINGULAR[section]}` : `Reusable ${SECTION_SINGULAR[section]} knowledge package`,
458
+ entry: entry?.path || entryFile,
459
+ };
322
460
  }
323
461
 
324
- async function loadLocalSkill(registryPath, name, requestedVersion) {
325
- const skillRoot = safeJoin(registryPath, name);
326
-
327
- if (!(await exists(skillRoot))) {
328
- throw new Error(`Skill "${name}" was not found in registry: ${registryPath}`);
329
- }
462
+ async function loadVersionedPackage(baseDir, manifestName, requestedVersion) {
463
+ if (!(await exists(baseDir))) return null;
330
464
 
331
465
  let packageDir;
332
466
  let version = requestedVersion;
333
-
334
467
  if (requestedVersion) {
335
- packageDir = path.join(skillRoot, requestedVersion);
336
- } else if (await exists(path.join(skillRoot, "skill.json"))) {
337
- packageDir = skillRoot;
468
+ const versionedDir = path.join(baseDir, requestedVersion);
469
+ if (await exists(versionedDir)) {
470
+ packageDir = versionedDir;
471
+ } else if (await exists(path.join(baseDir, manifestName)) || await exists(path.join(baseDir, "skill.json"))) {
472
+ // Unversioned registry package. Use the package folder but keep the requested version in metadata.
473
+ packageDir = baseDir;
474
+ } else {
475
+ packageDir = versionedDir;
476
+ }
477
+ } else if (await exists(path.join(baseDir, manifestName))) {
478
+ packageDir = baseDir;
479
+ } else if (await exists(path.join(baseDir, "skill.json"))) {
480
+ packageDir = baseDir;
481
+ manifestName = "skill.json";
338
482
  } else {
339
- const entries = await fs.readdir(skillRoot, { withFileTypes: true });
483
+ const entries = await fs.readdir(baseDir, { withFileTypes: true });
340
484
  const versions = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
341
- if (versions.length === 0) {
342
- throw new Error(`Skill "${name}" has no versions in registry.`);
343
- }
485
+ if (versions.length === 0) return null;
344
486
  version = sortVersions(versions)[0];
345
- packageDir = path.join(skillRoot, version);
487
+ packageDir = path.join(baseDir, version);
346
488
  }
347
489
 
348
- if (!(await exists(packageDir))) {
349
- throw new Error(`Skill "${name}@${requestedVersion}" was not found.`);
490
+ if (!(await exists(packageDir))) return null;
491
+ return { packageDir, version };
492
+ }
493
+
494
+ async function loadLocalArtifact(registryPath, section, name, requestedVersion) {
495
+ const singular = SECTION_SINGULAR[section];
496
+ const manifestName = `${singular}.json`;
497
+ const entryName = section === "skills" ? "SKILL.md" : section === "rules" ? "RULE.md" : "DOMAIN.md";
498
+ const sectionDir = safeJoin(registryPath, section);
499
+
500
+ if (!(await exists(registryPath))) throw new Error(`Registry not found: ${registryPath}`);
501
+
502
+ // Flat file style: registry/rules/code-standards.md, registry/rules/frontend/antipattern.md, or registry/domain/api.md
503
+ if (section !== "skills") {
504
+ const flatFile = safeJoin(sectionDir, `${name}.md`);
505
+ if (await exists(flatFile)) {
506
+ const content = await fs.readFile(flatFile, "utf8");
507
+ const files = [{ path: entryName, content }];
508
+ return { manifest: defaultManifest(section, name, requestedVersion || "1.0.0", files), files, source: normalizeSlashes(path.relative(process.cwd(), flatFile)) || flatFile };
509
+ }
350
510
  }
351
511
 
352
- const manifestPath = path.join(packageDir, "skill.json");
353
- if (!(await exists(manifestPath))) {
354
- throw new Error(`Missing skill.json in ${packageDir}`);
512
+ // Section package style: registry/skills/react/SKILL.md or registry/rules/frontend/RULE.md
513
+ const sectionPackageBase = safeJoin(sectionDir, name);
514
+ const sectionPackage = await loadVersionedPackage(sectionPackageBase, manifestName, requestedVersion);
515
+ if (sectionPackage) {
516
+ const files = await readPackageFiles(sectionPackage.packageDir);
517
+ const manifest = (await readOptionalJson(path.join(sectionPackage.packageDir, manifestName))) ||
518
+ (section === "skills" ? await readOptionalJson(path.join(sectionPackage.packageDir, "skill.json")) : null) ||
519
+ defaultManifest(section, name, sectionPackage.version || requestedVersion || "1.0.0", files);
520
+ validateArtifactManifest(section, manifest, files);
521
+ return {
522
+ manifest: { ...manifest, name: manifest.name || name, version: manifest.version || sectionPackage.version || "1.0.0", type: manifest.type || singular, section },
523
+ files,
524
+ source: normalizeSlashes(path.relative(process.cwd(), sectionPackage.packageDir)) || sectionPackage.packageDir,
525
+ };
355
526
  }
356
527
 
357
- const manifest = await readJson(manifestPath);
358
- const files = await readSkillPackageFiles(packageDir);
528
+ // Backward compatible skill registry style: registry/nextjs-clean-architecture/1.0.0/skill.json
529
+ if (section === "skills") {
530
+ const legacyBase = safeJoin(registryPath, name);
531
+ const legacyPackage = await loadVersionedPackage(legacyBase, "skill.json", requestedVersion);
532
+ if (legacyPackage) {
533
+ const files = await readPackageFiles(legacyPackage.packageDir);
534
+ const manifest = (await readOptionalJson(path.join(legacyPackage.packageDir, "skill.json"))) || defaultManifest(section, name, legacyPackage.version || "1.0.0", files);
535
+ validateArtifactManifest(section, manifest, files);
536
+ return { manifest: { ...manifest, type: "skill", section }, files, source: normalizeSlashes(path.relative(process.cwd(), legacyPackage.packageDir)) || legacyPackage.packageDir };
537
+ }
538
+ }
359
539
 
360
- validateSkillManifest(manifest, files);
540
+ throw new Error(`${SECTION_SINGULAR[section]} "${name}" was not found in registry: ${registryPath}`);
541
+ }
361
542
 
362
- return {
363
- manifest: {
364
- ...manifest,
365
- name: manifest.name || name,
366
- version: manifest.version || version || "0.0.0",
367
- },
368
- files,
369
- source: normalizeSlashes(path.relative(process.cwd(), packageDir)) || packageDir,
370
- };
543
+ function encodePathParts(value) {
544
+ return String(value)
545
+ .split("/")
546
+ .filter(Boolean)
547
+ .map((part) => encodeURIComponent(part))
548
+ .join("/");
371
549
  }
372
550
 
373
- async function loadRemoteSkill(registryUrl, name, requestedVersion) {
374
- const versionPath = requestedVersion ? requestedVersion : "latest";
375
- const url = `${registryUrl}/skills/${encodeURIComponent(name)}/${encodeURIComponent(versionPath)}`;
551
+ async function fetchJsonIfOk(url) {
376
552
  const response = await fetch(url);
553
+ if (!response.ok) return null;
554
+ return response.json();
555
+ }
377
556
 
378
- if (!response.ok) {
379
- throw new Error(`Registry returned ${response.status} for ${url}`);
557
+ async function loadRemoteArtifact(registryUrl, section, name, requestedVersion) {
558
+ const versionPath = requestedVersion || "latest";
559
+ const itemPath = encodePathParts(name);
560
+ const version = encodeURIComponent(versionPath);
561
+
562
+ const urls = [
563
+ // Recommended web app contract:
564
+ // GET https://your-skillhub-app.com/api/skillhub/rules/frontend/antipattern/latest
565
+ `${registryUrl}/api/skillhub/${section}/${itemPath}/${version}`,
566
+
567
+ // Registry-only contract:
568
+ // GET https://your-registry.com/rules/frontend/antipattern/latest
569
+ `${registryUrl}/${section}/${itemPath}/${version}`,
570
+
571
+ // Legacy encoded-name contract:
572
+ `${registryUrl}/${section}/${encodeURIComponent(name)}/${version}`,
573
+ ];
574
+
575
+ let payload = null;
576
+ let usedUrl = null;
577
+ for (const url of urls) {
578
+ payload = await fetchJsonIfOk(url);
579
+ if (payload) {
580
+ usedUrl = url;
581
+ break;
582
+ }
380
583
  }
381
584
 
382
- const payload = await response.json();
383
- const manifest = payload.manifest || payload.skill || payload;
384
- const files = payload.files || [];
385
-
386
- validateSkillManifest(manifest, files);
585
+ if (!payload) {
586
+ throw new Error(`Remote ${SECTION_SINGULAR[section]} "${name}" was not found. Tried: ${urls.join(", ")}`);
587
+ }
387
588
 
388
- return {
389
- manifest,
390
- files,
391
- source: url,
392
- };
589
+ const manifest = payload.manifest || payload[SECTION_SINGULAR[section]] || payload;
590
+ const files = payload.files || [];
591
+ validateArtifactManifest(section, manifest, files);
592
+ return { manifest: { ...manifest, name: manifest.name || name, section, type: manifest.type || SECTION_SINGULAR[section] }, files, source: usedUrl };
393
593
  }
394
594
 
395
- async function loadSkill(registry, name, requestedVersion) {
595
+ async function loadArtifact(registry, section, name, requestedVersion) {
396
596
  if (registry.startsWith("http://") || registry.startsWith("https://")) {
397
- return loadRemoteSkill(registry, name, requestedVersion);
597
+ return loadRemoteArtifact(registry, section, name, requestedVersion);
398
598
  }
599
+ return loadLocalArtifact(registry, section, name, requestedVersion);
600
+ }
601
+
602
+ function validateArtifactManifest(section, manifest, files) {
603
+ if (!manifest || typeof manifest !== "object") throw new Error("Invalid manifest.");
604
+ if (!manifest.name) throw new Error("Manifest must include name.");
605
+ if (!manifest.version) throw new Error("Manifest must include version.");
606
+
607
+ const required = section === "skills" ? "SKILL.md" : section === "rules" ? "RULE.md" : "DOMAIN.md";
608
+ const hasRequired = files.some((file) => file.path === required || file.path.endsWith(`/${required}`));
609
+ if (!hasRequired && files.length === 0) throw new Error(`${manifest.name}@${manifest.version} has no files.`);
610
+ }
399
611
 
400
- return loadLocalSkill(registry, name, requestedVersion);
612
+ function cursorRuleContent(content, title) {
613
+ if (content.trimStart().startsWith("---")) return content;
614
+ return `---\ndescription: ${title}\nalwaysApply: true\n---\n\n${content}`;
401
615
  }
402
616
 
403
- function validateSkillManifest(manifest, files) {
404
- if (!manifest || typeof manifest !== "object") {
405
- throw new Error("Invalid skill manifest.");
617
+ async function writeArtifactFiles(projectRoot, config, section, artifactName, files) {
618
+ const target = getConfigTarget(config);
619
+ const baseDir = config.sections?.[section] || TARGETS[target].sections[section];
620
+
621
+ if (section === "skills") {
622
+ const targetDir = path.join(projectRoot, baseDir, artifactName);
623
+ await fs.rm(targetDir, { recursive: true, force: true });
624
+ await ensureDir(targetDir);
625
+ for (const file of files) {
626
+ const targetPath = safeJoin(targetDir, file.path);
627
+ await ensureDir(path.dirname(targetPath));
628
+ await fs.writeFile(targetPath, file.content, "utf8");
629
+ }
630
+ return `${baseDir}/${artifactName}`;
406
631
  }
407
632
 
408
- if (!manifest.name) throw new Error("skill.json must include name.");
409
- if (!manifest.version) throw new Error("skill.json must include version.");
633
+ const entryName = section === "rules" ? "RULE.md" : "DOMAIN.md";
634
+ const singleEntry = files.length === 1 && files[0].path === entryName;
410
635
 
411
- const hasSkillMd = files.some((file) => file.path === "SKILL.md");
412
- if (!hasSkillMd) {
413
- throw new Error(`${manifest.name}@${manifest.version} must include SKILL.md.`);
636
+ if (singleEntry) {
637
+ const targetPath = safeJoin(path.join(projectRoot, baseDir), `${artifactName}.md`);
638
+ await ensureDir(path.dirname(targetPath));
639
+ await fs.writeFile(targetPath, files[0].content, "utf8");
640
+ return `${baseDir}/${artifactName}.md`;
414
641
  }
415
- }
416
642
 
417
- async function writeSkillFiles(projectRoot, skillsDir, skillName, files) {
418
- const targetDir = path.join(projectRoot, skillsDir, skillName);
643
+ const targetDir = path.join(projectRoot, baseDir, artifactName);
419
644
  await fs.rm(targetDir, { recursive: true, force: true });
420
645
  await ensureDir(targetDir);
421
-
422
646
  for (const file of files) {
423
647
  const targetPath = safeJoin(targetDir, file.path);
424
648
  await ensureDir(path.dirname(targetPath));
425
649
  await fs.writeFile(targetPath, file.content, "utf8");
426
650
  }
427
-
428
- return targetDir;
429
- }
430
-
431
- async function updateAdapters(projectRoot, config, lock) {
432
- const adapters = config.adapters || {};
433
- const skillsDir = config.skillsDir || DEFAULT_SKILLS_DIR;
434
- const installed = Object.entries(lock.skills || {}).sort(([a], [b]) => a.localeCompare(b));
435
-
436
- if (adapters.agentsMd) {
437
- await updateAgentsMd(projectRoot, skillsDir, installed);
438
- }
439
-
440
- if (adapters.cursorRules) {
441
- await updateCursorRules(projectRoot, skillsDir, installed);
442
- }
443
-
444
- if (adapters.claude) {
445
- await updateClaudeMd(projectRoot, skillsDir, installed);
446
- }
447
-
448
- if (adapters.githubCopilot) {
449
- await updateCopilotInstructions(projectRoot, skillsDir, installed);
450
- }
651
+ return `${baseDir}/${artifactName}`;
451
652
  }
452
653
 
453
- function generatedSkillList(skillsDir, installed) {
454
- if (installed.length === 0) {
455
- return "No SkillHub skills installed yet.";
654
+ function generatedArtifactList(lock) {
655
+ const installed = normalizeInstalled(lock.installed || { skills: lock.skills || {} });
656
+ const sections = [
657
+ ["Skills", "skills"],
658
+ ["Rules", "rules"],
659
+ ["Domain Knowledge", "domain"],
660
+ ];
661
+
662
+ const lines = [];
663
+ for (const [title, section] of sections) {
664
+ const entries = Object.entries(installed[section] || {}).sort(([a], [b]) => a.localeCompare(b));
665
+ lines.push(`## ${title}`);
666
+ if (entries.length === 0) {
667
+ lines.push("No entries installed.");
668
+ } else {
669
+ for (const [name, meta] of entries) {
670
+ const version = installedEntryVersion(meta);
671
+ const installTo = typeof meta === "string" ? "" : `: ${meta.installTo}`;
672
+ const localName = installedEntryLocalName(name, meta);
673
+ const label = localName && localName !== name ? `${name} → ${localName}` : name;
674
+ lines.push(`- ${label}@${version}${installTo}`);
675
+ }
676
+ }
677
+ lines.push("");
456
678
  }
457
-
458
- return installed
459
- .map(([name, meta]) => `- ${name}@${meta.version}: ${skillsDir}/${name}/SKILL.md`)
460
- .join("\n");
679
+ return lines.join("\n").trim();
461
680
  }
462
681
 
463
682
  async function replaceGeneratedBlock(filePath, generatedBlock, defaultPrefix = "") {
464
683
  let content = defaultPrefix;
465
-
466
- if (await exists(filePath)) {
467
- content = await fs.readFile(filePath, "utf8");
468
- }
684
+ if (await exists(filePath)) content = await fs.readFile(filePath, "utf8");
469
685
 
470
686
  const block = `${GENERATED_START}\n${generatedBlock.trim()}\n${GENERATED_END}`;
471
687
  const pattern = new RegExp(`${GENERATED_START}[\\s\\S]*?${GENERATED_END}`);
472
-
473
- if (pattern.test(content)) {
474
- content = content.replace(pattern, block);
475
- } else {
476
- const separator = content.trim().length > 0 ? "\n\n" : "";
477
- content = `${content.trimEnd()}${separator}${block}\n`;
478
- }
688
+ if (pattern.test(content)) content = content.replace(pattern, block);
689
+ else content = `${content.trimEnd()}${content.trim().length > 0 ? "\n\n" : ""}${block}\n`;
479
690
 
480
691
  await ensureDir(path.dirname(filePath));
481
692
  await fs.writeFile(filePath, content, "utf8");
482
693
  }
483
694
 
484
- async function updateAgentsMd(projectRoot, skillsDir, installed) {
485
- 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)}`;
486
- await replaceGeneratedBlock(path.join(projectRoot, "AGENTS.md"), body, "# Project Agents\n");
487
- }
695
+ async function updateAdapters(projectRoot, config, lock) {
696
+ const adapters = config.adapters || {};
697
+ const target = getConfigTarget(config);
698
+ const body = `# SkillHub Project Knowledge\n\nBefore editing this project, read and follow the attached SkillHub knowledge packages.\n\n${generatedArtifactList(lock)}`;
488
699
 
489
- async function updateCursorRules(projectRoot, skillsDir, installed) {
490
- 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)}`;
491
- await ensureDir(path.join(projectRoot, ".cursor/rules"));
492
- await fs.writeFile(path.join(projectRoot, ".cursor/rules/skillhub.mdc"), `${body}\n`, "utf8");
493
- }
700
+ if (adapters.agentsMd) {
701
+ await replaceGeneratedBlock(path.join(projectRoot, "AGENTS.md"), body, "# Project Agents\n");
702
+ }
494
703
 
495
- async function updateClaudeMd(projectRoot, skillsDir, installed) {
496
- const body = `# SkillHub Instructions\n\nFollow the installed skills below before generating or changing code:\n\n${generatedSkillList(skillsDir, installed)}`;
497
- await replaceGeneratedBlock(path.join(projectRoot, "CLAUDE.md"), body, "# Claude Project Instructions\n");
498
- await replaceGeneratedBlock(path.join(projectRoot, ".claude/skillhub.md"), body, "# Claude SkillHub Instructions\n");
499
- }
704
+ if (adapters.cursorRules) {
705
+ const cursorBody = `---\ndescription: SkillHub installed project knowledge\nalwaysApply: true\n---\n\n${body}\n\nUse the linked skill, rule, and domain files as the source of truth.`;
706
+ await ensureDir(path.join(projectRoot, ".cursor/rules"));
707
+ await fs.writeFile(path.join(projectRoot, ".cursor/rules/skillhub.md"), `${cursorBody}\n`, "utf8");
708
+ }
500
709
 
501
- async function updateCopilotInstructions(projectRoot, skillsDir, installed) {
502
- const body = `# SkillHub Instructions\n\nUse these installed skill files as project coding guidance:\n\n${generatedSkillList(skillsDir, installed)}`;
503
- await replaceGeneratedBlock(path.join(projectRoot, ".github/copilot-instructions.md"), body, "# GitHub Copilot Instructions\n");
710
+ if (adapters.claude) {
711
+ await replaceGeneratedBlock(path.join(projectRoot, "CLAUDE.md"), body, "# Claude Project Instructions\n");
712
+ await replaceGeneratedBlock(path.join(projectRoot, ".claude/skillhub.md"), body, "# Claude SkillHub Instructions\n");
713
+ }
714
+
715
+ if (adapters.githubCopilot) {
716
+ await replaceGeneratedBlock(path.join(projectRoot, ".github/copilot-instructions.md"), body, "# GitHub Copilot Instructions\n");
717
+ }
718
+
719
+ await ensureDir(path.join(projectRoot, TARGETS[target].sections.skills));
720
+ await ensureDir(path.join(projectRoot, TARGETS[target].sections.rules));
721
+ await ensureDir(path.join(projectRoot, TARGETS[target].sections.domain));
504
722
  }
505
723
 
506
724
  async function commandInit(projectRoot, flags) {
507
725
  const configPath = path.join(projectRoot, CONFIG_FILE);
508
726
  const lockPath = path.join(projectRoot, LOCK_FILE);
509
- const target = normalizeTarget(flags.target || flags.to) || (flags.yes ? DEFAULT_TARGET : await askTarget(DEFAULT_TARGET));
727
+ const requestedTarget = normalizeTarget(flags.target || flags.to);
728
+ let config;
510
729
 
511
730
  if (await exists(configPath)) {
731
+ config = await readProjectConfig(projectRoot);
512
732
  warn(`${CONFIG_FILE} already exists.`);
513
- info(`To change target later: skillhub target ${target}`);
733
+ if (requestedTarget) {
734
+ const previousTarget = getConfigTarget(config);
735
+ applyTargetToConfig(config, requestedTarget);
736
+ if (flags.registry) config.registry = flags.registry;
737
+ await writeJson(configPath, config);
738
+ ok(previousTarget === requestedTarget ? `${CONFIG_FILE} already uses target ${requestedTarget}` : `Updated ${CONFIG_FILE} target: ${previousTarget} → ${requestedTarget}`);
739
+ } else if (flags.registry) {
740
+ config.registry = flags.registry;
741
+ await writeJson(configPath, config);
742
+ ok(`Updated ${CONFIG_FILE} registry`);
743
+ } else {
744
+ info(`Current target: ${getConfigTarget(config)}`);
745
+ info(`To change target: skillhub init --target cursor`);
746
+ }
514
747
  } else {
515
- const config = defaultConfig(projectRoot, target);
748
+ const target = requestedTarget || (flags.yes ? DEFAULT_TARGET : await askTarget(DEFAULT_TARGET));
749
+ config = defaultConfig(projectRoot, target);
516
750
  if (flags.registry) config.registry = flags.registry;
517
751
  await writeJson(configPath, config);
518
752
  ok(`Created ${CONFIG_FILE}`);
519
753
  }
520
754
 
521
- if (await exists(lockPath)) {
522
- warn(`${LOCK_FILE} already exists.`);
523
- } else {
524
- await writeJson(lockPath, defaultLock());
755
+ const lock = (await exists(lockPath)) ? await readProjectLock(projectRoot) : defaultLock();
756
+ if (await exists(lockPath)) warn(`${LOCK_FILE} already exists.`);
757
+ else {
758
+ await writeJson(lockPath, lock);
525
759
  ok(`Created ${LOCK_FILE}`);
526
760
  }
527
761
 
528
- const config = await readProjectConfig(projectRoot);
529
- await ensureDir(path.join(projectRoot, config.skillsDir));
530
- ok(`Created ${config.skillsDir}`);
531
-
532
- info("Next: skillhub install nextjs-clean-architecture");
762
+ await updateAdapters(projectRoot, config, lock);
763
+ ok(`Created ${config.sections.skills}`);
764
+ ok(`Created ${config.sections.rules}`);
765
+ ok(`Created ${config.sections.domain}`);
766
+ info(`Target: ${config.target}`);
767
+ info("Next: skillhub rule frontend/antipattern");
533
768
  }
534
769
 
535
- async function commandInstall(projectRoot, spec, flags = {}) {
770
+ async function persistInstall(projectRoot, config, lock, section, sourceName, localName, artifact, installTo) {
536
771
  const configPath = path.join(projectRoot, CONFIG_FILE);
537
772
  const lockPath = path.join(projectRoot, LOCK_FILE);
773
+ const checksum = checksumFiles(artifact.files);
774
+
775
+ config.installed = normalizeInstalled(config.installed);
776
+ config.installed[section][sourceName] = {
777
+ name: sourceName,
778
+ localName,
779
+ version: artifact.manifest.version,
780
+ installTo,
781
+ };
782
+ config.skills = config.installed.skills;
783
+
784
+ lock.installed = normalizeInstalled(lock.installed);
785
+ lock.installed[section][sourceName] = {
786
+ name: sourceName,
787
+ localName,
788
+ version: artifact.manifest.version,
789
+ source: artifact.source,
790
+ installTo,
791
+ checksum,
792
+ installedAt: now(),
793
+ };
794
+ lock.skills = lock.installed.skills;
795
+ lock.updatedAt = now();
796
+
797
+ await writeJson(configPath, config);
798
+ await writeJson(lockPath, lock);
799
+ await updateAdapters(projectRoot, config, lock);
800
+ }
801
+
802
+ async function commandAttach(projectRoot, sectionValue, spec, flags = {}) {
803
+ const section = normalizeSection(sectionValue || flags.type || flags.section || "skills");
804
+ if (!spec) throw new Error(`${SECTION_SINGULAR[section]} name is required.`);
805
+
538
806
  const config = await readProjectConfig(projectRoot);
539
807
  if (flags.target || flags.to) {
540
808
  applyTargetToConfig(config, flags.target || flags.to);
541
809
  }
542
- const lock = (await readJson(lockPath, null)) || defaultLock();
543
- const registry = resolveRegistry(projectRoot, config, flags);
544
- const { name, version } = parseSkillSpec(spec);
545
810
 
546
- info(`Resolving ${name}${version ? `@${version}` : ""}`);
547
- const skill = await loadSkill(registry, name, version);
548
- const skillName = skill.manifest.name;
549
- const skillsDir = config.skillsDir || DEFAULT_SKILLS_DIR;
550
- const checksum = checksumFiles(skill.files);
811
+ const lock = await readProjectLock(projectRoot);
812
+ const registry = resolveRegistry(projectRoot, config, flags);
813
+ const { name, version } = parseArtifactSpec(spec);
814
+
815
+ info(`Resolving ${SECTION_SINGULAR[section]} ${name}${version ? `@${version}` : ""}`);
816
+ const artifact = await loadArtifact(registry, section, name, version);
817
+ const sourceName = name;
818
+ const artifactName = localArtifactName(section, sourceName, artifact.manifest, flags);
819
+ const installTo = await writeArtifactFiles(projectRoot, config, section, artifactName, artifact.files);
820
+ await persistInstall(projectRoot, config, lock, section, sourceName, artifactName, artifact, installTo);
821
+
822
+ ok(`Attached ${SECTION_SINGULAR[section]} ${sourceName}@${artifact.manifest.version}`);
823
+ if (sourceName !== artifactName) info(`Local name: ${artifactName}`);
824
+ info(`Files written to ${installTo}`);
825
+ }
551
826
 
552
- await writeSkillFiles(projectRoot, skillsDir, skillName, skill.files);
827
+ async function commandInstall(projectRoot, positional, flags = {}) {
828
+ if (positional.length === 0) {
829
+ await commandSync(projectRoot, flags);
830
+ return;
831
+ }
553
832
 
554
- config.skills = config.skills || {};
555
- config.skills[skillName] = skill.manifest.version;
556
- await writeJson(configPath, config);
833
+ let section = flags.type || flags.section || "skills";
834
+ let spec = positional[0];
557
835
 
558
- lock.updatedAt = now();
559
- lock.skills = lock.skills || {};
560
- lock.skills[skillName] = {
561
- version: skill.manifest.version,
562
- source: skill.source,
563
- installTo: `${skillsDir}/${skillName}`,
564
- checksum,
565
- installedAt: now(),
566
- };
567
- await writeJson(lockPath, lock);
568
-
569
- await updateAdapters(projectRoot, config, lock);
836
+ if (positional.length >= 2 && SECTION_ALIASES[positional[0]]) {
837
+ section = positional[0];
838
+ spec = positional[1];
839
+ }
570
840
 
571
- ok(`Installed ${skillName}@${skill.manifest.version}`);
572
- info(`Files written to ${skillsDir}/${skillName}`);
841
+ await commandAttach(projectRoot, section, spec, flags);
573
842
  }
574
843
 
575
844
  async function commandSync(projectRoot, flags = {}) {
576
- const configPath = path.join(projectRoot, CONFIG_FILE);
577
845
  const config = await readProjectConfig(projectRoot);
578
846
  if (flags.target || flags.to) {
579
847
  applyTargetToConfig(config, flags.target || flags.to);
580
- await writeJson(configPath, config);
848
+ await writeJson(path.join(projectRoot, CONFIG_FILE), config);
581
849
  }
582
- const skills = Object.entries(config.skills || {});
583
850
 
584
- if (skills.length === 0) {
585
- warn("No skills configured in skillhub.json.");
851
+ const installed = normalizeInstalled(config.installed || { skills: config.skills || {} });
852
+ const entries = [
853
+ ...Object.entries(installed.skills).map(([key, entry]) => ["skills", `${installedEntrySpec(key, entry)}@${installedEntryVersion(entry)}`, installedEntryLocalName(key, entry)]),
854
+ ...Object.entries(installed.rules).map(([key, entry]) => ["rules", `${installedEntrySpec(key, entry)}@${installedEntryVersion(entry)}`, installedEntryLocalName(key, entry)]),
855
+ ...Object.entries(installed.domain).map(([key, entry]) => ["domain", `${installedEntrySpec(key, entry)}@${installedEntryVersion(entry)}`, installedEntryLocalName(key, entry)]),
856
+ ];
857
+
858
+ if (entries.length === 0) {
859
+ warn("No SkillHub knowledge configured in skillhub.json.");
586
860
  return;
587
861
  }
588
862
 
589
- for (const [name, version] of skills) {
590
- await commandInstall(projectRoot, `${name}@${version}`, flags);
863
+ for (const [section, spec, localName] of entries) {
864
+ await commandAttach(projectRoot, section, spec, { ...flags, as: flags.as || localName });
591
865
  }
592
866
 
593
- ok(`Synced ${skills.length} skill${skills.length === 1 ? "" : "s"}.`);
867
+ ok(`Synced ${entries.length} item${entries.length === 1 ? "" : "s"}.`);
594
868
  }
595
869
 
596
870
  async function commandList(projectRoot) {
597
- const lock = await readJson(path.join(projectRoot, LOCK_FILE), null);
871
+ const lock = await readProjectLock(projectRoot);
872
+ const installed = normalizeInstalled(lock.installed || { skills: lock.skills || {} });
873
+ const hasAny = Object.values(installed).some((items) => Object.keys(items).length > 0);
598
874
 
599
- if (!lock || !lock.skills || Object.keys(lock.skills).length === 0) {
600
- warn("No skills installed yet.");
875
+ if (!hasAny) {
876
+ warn("No SkillHub knowledge attached yet.");
601
877
  return;
602
878
  }
603
879
 
604
- console.log(paint("bold", "Installed SkillHub skills"));
605
- for (const [name, meta] of Object.entries(lock.skills).sort(([a], [b]) => a.localeCompare(b))) {
606
- console.log(`- ${name}@${meta.version} ${paint("dim", `→ ${meta.installTo}`)}`);
880
+ console.log(paint("bold", "Attached SkillHub knowledge"));
881
+ for (const [title, section] of [["Skills", "skills"], ["Rules", "rules"], ["Domain", "domain"]]) {
882
+ const entries = Object.entries(installed[section] || {}).sort(([a], [b]) => a.localeCompare(b));
883
+ if (entries.length === 0) continue;
884
+ console.log(`\n${paint("bold", title)}`);
885
+ for (const [name, meta] of entries) {
886
+ const version = installedEntryVersion(meta);
887
+ const installTo = typeof meta === "string" ? "" : ` → ${meta.installTo}`;
888
+ const localName = installedEntryLocalName(name, meta);
889
+ const label = localName && localName !== name ? `${name} → ${localName}` : name;
890
+ console.log(`- ${label}@${version}${paint("dim", installTo)}`);
891
+ }
607
892
  }
608
893
  }
609
894
 
610
- async function commandRemove(projectRoot, skillName) {
611
- if (!skillName) throw new Error("Skill name is required.");
895
+ async function commandRemove(projectRoot, sectionValue, name) {
896
+ let section = normalizeSection(sectionValue || "skills");
897
+ let artifactName = name;
898
+ if (!artifactName) {
899
+ artifactName = sectionValue;
900
+ section = "skills";
901
+ }
902
+ if (!artifactName) throw new Error("Name is required.");
612
903
 
613
- const configPath = path.join(projectRoot, CONFIG_FILE);
614
- const lockPath = path.join(projectRoot, LOCK_FILE);
615
904
  const config = await readProjectConfig(projectRoot);
616
- const lock = (await readJson(lockPath, null)) || defaultLock();
617
- const skillsDir = config.skillsDir || DEFAULT_SKILLS_DIR;
618
- const installedPath = lock.skills?.[skillName]?.installTo || `${skillsDir}/${skillName}`;
619
-
620
- delete config.skills?.[skillName];
621
- delete lock.skills?.[skillName];
905
+ const lock = await readProjectLock(projectRoot);
906
+ config.installed = normalizeInstalled(config.installed);
907
+ lock.installed = normalizeInstalled(lock.installed);
908
+
909
+ const sectionLock = lock.installed[section] || {};
910
+ const sectionConfig = config.installed[section] || {};
911
+ const key = sectionLock[artifactName] || sectionConfig[artifactName]
912
+ ? artifactName
913
+ : Object.keys({ ...sectionConfig, ...sectionLock }).find((candidate) => installedEntryLocalName(candidate, sectionLock[candidate] || sectionConfig[candidate]) === artifactName);
914
+
915
+ if (!key) throw new Error(`${SECTION_SINGULAR[section]} "${artifactName}" is not attached.`);
916
+
917
+ const installedPath = sectionLock[key]?.installTo || sectionConfig[key]?.installTo;
918
+ delete config.installed[section]?.[key];
919
+ delete lock.installed[section]?.[key];
920
+ config.skills = config.installed.skills;
921
+ lock.skills = lock.installed.skills;
622
922
  lock.updatedAt = now();
623
923
 
624
- await fs.rm(path.join(projectRoot, installedPath), { recursive: true, force: true });
625
- await writeJson(configPath, config);
626
- await writeJson(lockPath, lock);
924
+ if (installedPath) await fs.rm(path.join(projectRoot, installedPath), { recursive: true, force: true });
925
+ await writeJson(path.join(projectRoot, CONFIG_FILE), config);
926
+ await writeJson(path.join(projectRoot, LOCK_FILE), lock);
627
927
  await updateAdapters(projectRoot, config, lock);
628
928
 
629
- ok(`Removed ${skillName}`);
929
+ ok(`Removed ${SECTION_SINGULAR[section]} ${artifactName}`);
630
930
  }
631
931
 
632
932
  async function commandTarget(projectRoot, targetValue, flags = {}) {
633
- const configPath = path.join(projectRoot, CONFIG_FILE);
634
- const lockPath = path.join(projectRoot, LOCK_FILE);
635
933
  const config = await readProjectConfig(projectRoot);
636
934
  const target = normalizeTarget(targetValue || flags.target || flags.to) || await askTarget(getConfigTarget(config));
637
-
638
935
  applyTargetToConfig(config, target);
639
- await writeJson(configPath, config);
640
- await ensureDir(path.join(projectRoot, config.skillsDir));
936
+ await writeJson(path.join(projectRoot, CONFIG_FILE), config);
641
937
 
642
- const lock = (await readJson(lockPath, null)) || defaultLock();
938
+ const lock = await readProjectLock(projectRoot);
643
939
  await updateAdapters(projectRoot, config, lock);
644
940
 
645
941
  ok(`SkillHub target set to ${target}`);
646
- info(`Skill files will be installed to ${config.skillsDir}`);
647
- if (Object.keys(lock.skills || {}).length > 0) {
648
- info("Run: skillhub sync");
649
- }
942
+ info(`Skills: ${config.sections.skills}`);
943
+ info(`Rules: ${config.sections.rules}`);
944
+ info(`Domain: ${config.sections.domain}`);
945
+ if (Object.values(lock.installed || {}).some((items) => Object.keys(items).length > 0)) info("Run: skillhub sync");
650
946
  }
651
947
 
652
- async function commandCreate(projectRoot, name, flags = {}) {
653
- if (!name) throw new Error("Skill name is required.");
654
-
655
- const config = (await readJson(path.join(projectRoot, CONFIG_FILE), null)) || defaultConfig(projectRoot);
656
- const registry = resolveRegistry(projectRoot, config, flags);
948
+ function titleFromName(name) {
949
+ return String(name)
950
+ .replace(/[-_]+/g, " ")
951
+ .replace(/\b\w/g, (char) => char.toUpperCase());
952
+ }
657
953
 
658
- if (registry.startsWith("http://") || registry.startsWith("https://")) {
659
- throw new Error("Cannot create a skill inside a remote registry. Use --registry ./path");
954
+ async function commandCreate(projectRoot, positional, flags = {}) {
955
+ let section = "skills";
956
+ let name = positional[0];
957
+ if (positional.length >= 2 && SECTION_ALIASES[positional[0]]) {
958
+ section = normalizeSection(positional[0]);
959
+ name = positional[1];
660
960
  }
661
-
662
- const version = flags.version || "1.0.0";
663
- const category = flags.category || "general";
664
- const packageDir = path.join(registry, name, version);
665
-
666
- if (await exists(packageDir)) {
667
- throw new Error(`Skill already exists: ${packageDir}`);
961
+ if (!name && process.stdin.isTTY && process.stdout.isTTY) {
962
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
963
+ try {
964
+ const sectionAnswer = await rl.question("Create which section? [skill/rule/domain] (skill): ");
965
+ section = normalizeSection(sectionAnswer || "skill");
966
+ name = (await rl.question(`${SECTION_SINGULAR[section]} name: `)).trim();
967
+ if (!flags.description) flags.description = (await rl.question("Description: ")).trim();
968
+ } finally {
969
+ rl.close();
970
+ }
668
971
  }
972
+ if (!name) throw new Error("Name is required. Example: skillhub create skill frontend-app");
669
973
 
670
- await ensureDir(path.join(packageDir, "rules"));
671
- await ensureDir(path.join(packageDir, "checklist"));
672
-
673
- await writeJson(path.join(packageDir, "skill.json"), {
674
- name,
675
- version,
676
- description: flags.description || `Reusable ${name} project skill`,
677
- category,
678
- tags: [category],
679
- entry: "SKILL.md",
680
- compatibleWith: ["cursor", "claude", "chatgpt", "github-copilot"],
681
- });
682
-
683
- await fs.writeFile(
684
- path.join(packageDir, "SKILL.md"),
685
- `# ${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`,
686
- "utf8",
687
- );
974
+ const config = (await readJson(path.join(projectRoot, CONFIG_FILE), null)) || defaultConfig(projectRoot);
975
+ const registry = resolveRegistry(projectRoot, config, flags, { fallbackToBundled: false });
976
+ if (registry.startsWith("http://") || registry.startsWith("https://")) throw new Error("Cannot create inside a remote registry. Use --registry ./skillhub-registry");
688
977
 
689
- await fs.writeFile(path.join(packageDir, "rules/main.md"), `# ${name} Rules\n\n- Add project-specific rules here.\n`, "utf8");
690
- 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");
978
+ const version = flags.version || "1.0.0";
979
+ const description = flags.description || `Reusable ${SECTION_SINGULAR[section]} for ${titleFromName(name)}`;
980
+ const sectionDir = path.join(registry, section);
981
+
982
+ if (section === "skills") {
983
+ const packageDir = path.join(sectionDir, name);
984
+ if (await exists(packageDir)) throw new Error(`Skill already exists: ${packageDir}`);
985
+ await ensureDir(path.join(packageDir, "rules"));
986
+ await ensureDir(path.join(packageDir, "templates"));
987
+ await writeJson(path.join(packageDir, "skill.json"), {
988
+ name,
989
+ version,
990
+ type: "skill",
991
+ section: "skills",
992
+ description,
993
+ category: flags.category || "development",
994
+ tags: (flags.tags ? String(flags.tags).split(",") : ["ai", "development"]).map((item) => item.trim()).filter(Boolean),
995
+ entry: "SKILL.md",
996
+ compatibleWith: ["cursor", "claude", "codex", "gemini", "opencode"],
997
+ });
998
+ await fs.writeFile(path.join(packageDir, "SKILL.md"), `# ${titleFromName(name)}\n\n## Description\n\n${description}\n\n## When to Use\n\nUse this skill when the user asks for ${titleFromName(name).toLowerCase()} work.\n\n## Workflow\n\n1. Understand the requirement and constraints.\n2. Inspect the existing project structure before changing code.\n3. Propose the smallest safe implementation plan.\n4. Implement using project conventions.\n5. Validate with build, lint, tests, or manual checks.\n\n## Rules\n\n- Follow installed project rules first.\n- Use installed domain knowledge when relevant.\n- Do not invent APIs, paths, or product behavior.\n- Keep changes scoped to the user request.\n\n## Output\n\nExplain changed files, validation performed, and any remaining risks.\n`, "utf8");
999
+ await fs.writeFile(path.join(packageDir, "rules/main.md"), `# ${titleFromName(name)} Rules\n\n- Add skill-specific rules here.\n`, "utf8");
1000
+ ok(`Created skill ${name}@${version}`);
1001
+ info(`Location: ${packageDir}`);
1002
+ return;
1003
+ }
691
1004
 
692
- ok(`Created ${name}@${version}`);
693
- info(`Location: ${packageDir}`);
1005
+ await ensureDir(sectionDir);
1006
+ const fileName = `${name}.md`;
1007
+ const targetPath = path.join(sectionDir, fileName);
1008
+ if (await exists(targetPath)) throw new Error(`${SECTION_SINGULAR[section]} already exists: ${targetPath}`);
1009
+ const heading = section === "rules" ? `${titleFromName(name)} Rules` : `${titleFromName(name)} Domain Knowledge`;
1010
+ const body = flags.content || `# ${heading}\n\n## Purpose\n\n${description}\n\n## Guidance\n\n- Add concrete project knowledge here.\n- Include examples, naming conventions, edge cases, and decision rules.\n- Keep this file factual and maintainable.\n`;
1011
+ await fs.writeFile(targetPath, body, "utf8");
1012
+ ok(`Created ${SECTION_SINGULAR[section]} ${name}@${version}`);
1013
+ info(`Location: ${targetPath}`);
694
1014
  }
695
1015
 
696
- async function commandValidate(projectRoot, specOrPath, flags = {}) {
697
- if (!specOrPath) throw new Error("Skill name or path is required.");
698
-
699
- let skill;
700
- if (specOrPath.includes("/") || specOrPath.includes("\\") || specOrPath.startsWith(".")) {
701
- const packageDir = path.resolve(projectRoot, specOrPath);
702
- const manifest = await readJson(path.join(packageDir, "skill.json"));
703
- const files = await readSkillPackageFiles(packageDir);
704
- validateSkillManifest(manifest, files);
705
- skill = { manifest, files };
1016
+ async function commandValidate(projectRoot, positional, flags = {}) {
1017
+ let section = flags.type || flags.section || "skills";
1018
+ let spec = positional[0];
1019
+ if (positional.length >= 2 && SECTION_ALIASES[positional[0]]) {
1020
+ section = positional[0];
1021
+ spec = positional[1];
1022
+ }
1023
+ section = normalizeSection(section);
1024
+ if (!spec) throw new Error(`${SECTION_SINGULAR[section]} name or path is required.`);
1025
+
1026
+ let artifact;
1027
+ if (spec.includes("/") || spec.includes("\\") || spec.startsWith(".")) {
1028
+ const packagePath = path.resolve(projectRoot, spec);
1029
+ if ((await fs.stat(packagePath)).isFile()) {
1030
+ const entryName = section === "skills" ? "SKILL.md" : section === "rules" ? "RULE.md" : "DOMAIN.md";
1031
+ const files = [{ path: entryName, content: await fs.readFile(packagePath, "utf8") }];
1032
+ artifact = { manifest: defaultManifest(section, path.basename(packagePath, path.extname(packagePath)), "1.0.0", files), files };
1033
+ } else {
1034
+ const files = await readPackageFiles(packagePath);
1035
+ artifact = { manifest: defaultManifest(section, path.basename(packagePath), "1.0.0", files), files };
1036
+ }
706
1037
  } else {
707
1038
  const config = await readProjectConfig(projectRoot);
708
1039
  const registry = resolveRegistry(projectRoot, config, flags);
709
- const { name, version } = parseSkillSpec(specOrPath);
710
- skill = await loadSkill(registry, name, version);
1040
+ const { name, version } = parseArtifactSpec(spec);
1041
+ artifact = await loadArtifact(registry, section, name, version);
711
1042
  }
712
1043
 
713
- const checksum = checksumFiles(skill.files);
714
- ok(`Valid skill: ${skill.manifest.name}@${skill.manifest.version}`);
715
- info(`Files: ${skill.files.length}`);
716
- info(`Checksum: ${checksum}`);
1044
+ validateArtifactManifest(section, artifact.manifest, artifact.files);
1045
+ ok(`Valid ${SECTION_SINGULAR[section]}: ${artifact.manifest.name}@${artifact.manifest.version}`);
1046
+ info(`Files: ${artifact.files.length}`);
1047
+ info(`Checksum: ${checksumFiles(artifact.files)}`);
1048
+ }
1049
+
1050
+ async function collectRegistryNames(sectionDir, section) {
1051
+ if (!(await exists(sectionDir))) return [];
1052
+ const files = await listFiles(sectionDir);
1053
+ const names = new Set();
1054
+
1055
+ if (section === "skills") {
1056
+ for (const file of files) {
1057
+ if (file.endsWith("/SKILL.md") || file === "SKILL.md") {
1058
+ names.add(normalizeSlashes(path.dirname(file)));
1059
+ }
1060
+ if (file.endsWith("/skill.json") || file === "skill.json") {
1061
+ names.add(normalizeSlashes(path.dirname(file)));
1062
+ }
1063
+ }
1064
+ } else {
1065
+ for (const file of files) {
1066
+ if (file.toLowerCase().endsWith(".md")) {
1067
+ names.add(file.replace(/\.md$/i, ""));
1068
+ }
1069
+ }
1070
+ }
1071
+
1072
+ return [...names].filter((name) => name && name !== ".").sort();
1073
+ }
1074
+
1075
+ async function commandSearch(projectRoot, query, flags = {}) {
1076
+ const config = (await readJson(path.join(projectRoot, CONFIG_FILE), null)) || defaultConfig(projectRoot);
1077
+ const registry = resolveRegistry(projectRoot, config, flags);
1078
+ if (registry.startsWith("http://") || registry.startsWith("https://")) throw new Error("Remote search is not supported in this CLI build yet. Use your web app search UI, then fetch by path with skillhub rule/skill/domain.");
1079
+ const needle = String(query || "").toLowerCase();
1080
+
1081
+ console.log(paint("bold", "SkillHub registry"));
1082
+ for (const section of ["skills", "rules", "domain"]) {
1083
+ const sectionDir = path.join(registry, section);
1084
+ const names = (await collectRegistryNames(sectionDir, section))
1085
+ .filter((name) => !needle || name.toLowerCase().includes(needle));
1086
+ if (names.length === 0) continue;
1087
+ console.log(`\n${paint("bold", section)}`);
1088
+ for (const name of names) console.log(`- ${name}`);
1089
+ }
717
1090
  }
718
1091
 
719
1092
  function toWords(value) {
@@ -726,34 +1099,25 @@ function toWords(value) {
726
1099
  }
727
1100
 
728
1101
  function toKebabCase(value) {
729
- return toWords(value)
730
- .map((word) => word.toLowerCase())
731
- .join("-");
1102
+ return toWords(value).map((word) => word.toLowerCase()).join("-");
732
1103
  }
733
1104
 
734
1105
  function toCamelCase(value) {
735
1106
  const words = toWords(value).map((word) => word.toLowerCase());
736
- return words
737
- .map((word, index) => (index === 0 ? word : `${word.charAt(0).toUpperCase()}${word.slice(1)}`))
738
- .join("");
1107
+ return words.map((word, index) => (index === 0 ? word : `${word.charAt(0).toUpperCase()}${word.slice(1)}`)).join("");
739
1108
  }
740
1109
 
741
1110
  function toPascalCase(value) {
742
- return toWords(value)
743
- .map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1).toLowerCase()}`)
744
- .join("");
1111
+ return toWords(value).map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1).toLowerCase()}`).join("");
745
1112
  }
746
1113
 
747
1114
  function toTitleCase(value) {
748
- return toWords(value)
749
- .map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1).toLowerCase()}`)
750
- .join(" ");
1115
+ return toWords(value).map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1).toLowerCase()}`).join(" ");
751
1116
  }
752
1117
 
753
1118
  function templateVariables(name, flags = {}) {
754
1119
  const baseName = name || flags.name;
755
- if (!baseName) throw new Error("Generator name is required. Example: skillhub generate shadcn-crud-generator feature users");
756
-
1120
+ if (!baseName) throw new Error("Generator name is required. Example: skillhub generate frontend-app feature users");
757
1121
  return {
758
1122
  name: baseName,
759
1123
  kebabName: flags.kebabName || toKebabCase(baseName),
@@ -772,12 +1136,8 @@ async function commandGenerate(projectRoot, skillName, templateName, resourceNam
772
1136
  if (!templateName) throw new Error("Template name is required.");
773
1137
 
774
1138
  const config = await readProjectConfig(projectRoot);
775
- const skillsDir = config.skillsDir || DEFAULT_SKILLS_DIR;
776
- const templateDir = path.join(projectRoot, skillsDir, skillName, "templates", templateName);
777
-
778
- if (!(await exists(templateDir))) {
779
- throw new Error(`Template not found: ${skillsDir}/${skillName}/templates/${templateName}. Install the skill first.`);
780
- }
1139
+ const templateDir = path.join(projectRoot, config.sections.skills, skillName, "templates", templateName);
1140
+ if (!(await exists(templateDir))) throw new Error(`Template not found: ${config.sections.skills}/${skillName}/templates/${templateName}. Attach the skill first.`);
781
1141
 
782
1142
  const variables = templateVariables(resourceName || flags.name, flags);
783
1143
  const outDir = path.resolve(projectRoot, flags.out || `src/features/${variables.kebabName}`);
@@ -793,21 +1153,16 @@ async function commandGenerate(projectRoot, skillName, templateName, resourceNam
793
1153
  const targetPath = safeJoin(outDir, renderedRelativePath);
794
1154
  const sourcePath = path.join(templateDir, relativePath);
795
1155
  const content = await fs.readFile(sourcePath, "utf8");
796
-
797
- if ((await exists(targetPath)) && !flags.force) {
798
- throw new Error(`File already exists: ${targetPath}. Use --force to overwrite.`);
799
- }
800
-
1156
+ if ((await exists(targetPath)) && !flags.force) throw new Error(`File already exists: ${targetPath}. Use --force to overwrite.`);
801
1157
  await ensureDir(path.dirname(targetPath));
802
1158
  await fs.writeFile(targetPath, renderTemplate(content, variables), "utf8");
803
1159
  ok(`Generated ${normalizeSlashes(path.relative(projectRoot, targetPath))}`);
804
1160
  }
805
-
806
1161
  info(`Template variables: ${JSON.stringify(variables)}`);
807
1162
  }
808
1163
 
809
1164
  function printHelp() {
810
- 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 chosen skills folder\n target [ai|cursor|claude] Change where skills are installed\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 generate <skill> <template> <name> Generate files from a skill template\n\n${paint("bold", "Targets")}\n ai Install to .ai/skills and update AGENTS.md\n cursor Install to .cursor/skills and update .cursor/rules/skillhub.mdc\n claude Install to .claude/skills and update CLAUDE.md + .claude/skillhub.md\n\n${paint("bold", "Options")}\n --target <ai|cursor|claude> Install target. Alias: --to\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 --yes Use default init target without prompting\n\n${paint("bold", "Examples")}\n skillhub init --target cursor\n skillhub init --target claude\n skillhub install nextjs-clean-architecture --target cursor\n skillhub target claude\n skillhub sync\n skillhub create api-security-rules --category security\n`);
1165
+ console.log(`\n${paint("bold", "SkillHub CLI")}\n\nFetch SkillHub web-app knowledge into any codebase: skills, rules, and domain knowledge.\n\n${paint("bold", "Usage")}\n skillhub <command> [options]\n\n${paint("bold", "Commands")}\n init Create skillhub.json and target folders\n target [ai|cursor|claude] Change where knowledge is attached\n attach <skill|rule|domain> <name> Attach a registry item to this project\n install <name> Attach a skill. Alias for attach skill <name>\n install <rule|domain> <name> Attach rule/domain using install syntax\n sync Reinstall all configured knowledge\n list Show attached knowledge\n remove [section] <name> Remove attached knowledge\n create [skill|rule|domain] <name> Create a local registry item\n validate [section] <name|path> Validate registry item\n search [query] Search local/bundled registry\n generate <skill> <template> <name> Generate files from an attached skill template\n\n${paint("bold", "Targets")}\n ai .ai/skills, .ai/rules, .ai/domain + AGENTS.md\n cursor .cursor/skills, .cursor/rules, .cursor/domain + .cursor/rules/skillhub.md\n claude .claude/skills, .claude/rules, .claude/domain + CLAUDE.md\n\n${paint("bold", "Options")}\n --target <ai|cursor|claude> Install target. Alias: --to\n --registry <path|url> Registry location. Default: ./skillhub-registry; falls back to bundled registry\n --type <skill|rule|domain> Section for install/validate\n --version <version> Version used by create. Default: 1.0.0\n --description <text> Description used by create\n --category <name> Category used by create skill\n --tags <a,b,c> Tags used by create skill\n --yes Use default init target without prompting\n\n${paint("bold", "Examples")}\n skillhub init --target cursor\n skillhub attach skill frontend-app\n skillhub attach rule code-standards\n skillhub attach domain frontend-architecture\n skillhub install frontend-app --target claude\n skillhub create skill frontend-app --description "Develop production frontend apps"\n skillhub create rule react-query-rules\n skillhub create domain ott-video-cms\n skillhub search react\n`);
811
1166
  }
812
1167
 
813
1168
  async function main() {
@@ -824,9 +1179,22 @@ async function main() {
824
1179
  case "init":
825
1180
  await commandInit(projectRoot, flags);
826
1181
  break;
1182
+ case "attach":
1183
+ case "use":
1184
+ await commandAttach(projectRoot, positional[0], positional[1], flags);
1185
+ break;
1186
+ case "skill":
1187
+ case "skills":
1188
+ case "rule":
1189
+ case "rules":
1190
+ case "domain":
1191
+ case "domains":
1192
+ case "knowledge":
1193
+ await commandAttach(projectRoot, command, positional[0], flags);
1194
+ break;
827
1195
  case "install":
828
1196
  case "add":
829
- await commandInstall(projectRoot, positional[0], flags);
1197
+ await commandInstall(projectRoot, positional, flags);
830
1198
  break;
831
1199
  case "target":
832
1200
  case "configure":
@@ -841,13 +1209,16 @@ async function main() {
841
1209
  break;
842
1210
  case "remove":
843
1211
  case "rm":
844
- await commandRemove(projectRoot, positional[0]);
1212
+ await commandRemove(projectRoot, positional[0], positional[1]);
845
1213
  break;
846
1214
  case "create":
847
- await commandCreate(projectRoot, positional[0], flags);
1215
+ await commandCreate(projectRoot, positional, flags);
848
1216
  break;
849
1217
  case "validate":
850
- await commandValidate(projectRoot, positional[0], flags);
1218
+ await commandValidate(projectRoot, positional, flags);
1219
+ break;
1220
+ case "search":
1221
+ await commandSearch(projectRoot, positional[0], flags);
851
1222
  break;
852
1223
  case "generate":
853
1224
  case "gen":
@@ -864,13 +1235,12 @@ main().catch((error) => {
864
1235
  });
865
1236
 
866
1237
  export {
867
- parseSkillSpec,
1238
+ normalizeTarget,
1239
+ normalizeSection,
1240
+ parseArtifactSpec,
868
1241
  checksumFiles,
869
1242
  sortVersions,
870
- validateSkillManifest,
1243
+ validateArtifactManifest,
871
1244
  defaultConfig,
872
- normalizeTarget,
873
1245
  applyTargetToConfig,
874
1246
  };
875
-
876
- // Let people run this file through `node packages/skillhub-cli/bin/skillhub.mjs`.