@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.
- package/CHANGELOG.md +21 -21
- package/README.md +166 -184
- package/bin/skillhub.mjs +734 -364
- package/docs/skillhub-cli-logic.md +115 -83
- package/package.json +8 -5
- package/skillhub-registry/CHANGELOG.md +65 -0
- package/skillhub-registry/CLAUDE.md +108 -0
- package/skillhub-registry/README.md +196 -16
- package/skillhub-registry/docs/cheat-sheet.md +272 -0
- package/skillhub-registry/docs/contributing.md +166 -0
- package/skillhub-registry/docs/cost-hygiene.md +175 -0
- package/skillhub-registry/docs/customization.md +321 -0
- package/skillhub-registry/docs/exception-process.md +194 -0
- package/skillhub-registry/docs/installation.md +277 -0
- package/skillhub-registry/domain/api.md +303 -0
- package/skillhub-registry/domain/frontend/web-app.md +17 -0
- package/skillhub-registry/domain/frontend-app.md +46 -0
- package/skillhub-registry/domain/frontend-architecture.md +126 -0
- package/skillhub-registry/rules/anti-patterns.md +95 -0
- package/skillhub-registry/rules/code-standards.md +182 -0
- package/skillhub-registry/rules/frontend/antipattern.md +21 -0
- package/skillhub-registry/rules/frontend/component-standards.md +10 -0
- package/skillhub-registry/rules/frontend-app.md +24 -0
- package/skillhub-registry/rules/general.md +51 -0
- package/skillhub-registry/skills/api/SKILL.md +167 -0
- package/skillhub-registry/skills/build/SKILL.md +114 -0
- package/skillhub-registry/skills/fast/SKILL.md +56 -0
- package/skillhub-registry/skills/feature-dev/SKILL.md +166 -0
- package/skillhub-registry/skills/frontend/app/SKILL.md +28 -0
- package/skillhub-registry/skills/frontend/app/rules/main.md +6 -0
- package/skillhub-registry/skills/frontend/app/skill.json +10 -0
- package/skillhub-registry/skills/frontend/app/templates/feature/{{kebabName}}.tsx.hbs +11 -0
- package/skillhub-registry/skills/frontend-app/SKILL.md +48 -0
- package/skillhub-registry/skills/frontend-app/rules/main.md +6 -0
- package/skillhub-registry/skills/frontend-app/skill.json +11 -0
- package/skillhub-registry/skills/frontend-app/templates/feature/{{kebabName}}.tsx.hbs +11 -0
- package/skillhub-registry/skills/performance/SKILL.md +168 -0
- package/skillhub-registry/skills/react/SKILL.md +224 -0
- package/skillhub-registry/skills/refactor/SKILL.md +149 -0
- package/skillhub-registry/skills/review/SKILL.md +199 -0
- package/skillhub-registry/skills/strict/SKILL.md +74 -0
- package/skillhub-registry/skills/testing/SKILL.md +132 -0
- package/skillhub-registry/accessibility-review/1.0.0/SKILL.md +0 -22
- package/skillhub-registry/accessibility-review/1.0.0/checklist/ui-review.md +0 -8
- package/skillhub-registry/accessibility-review/1.0.0/skill.json +0 -9
- package/skillhub-registry/nextjs-clean-architecture/1.0.0/SKILL.md +0 -37
- package/skillhub-registry/nextjs-clean-architecture/1.0.0/checklist/definition-of-done.md +0 -9
- package/skillhub-registry/nextjs-clean-architecture/1.0.0/rules/folder-structure.md +0 -7
- package/skillhub-registry/nextjs-clean-architecture/1.0.0/skill.json +0 -9
- package/skillhub-registry/shadcn-crud-generator/1.0.0/SKILL.md +0 -25
- package/skillhub-registry/shadcn-crud-generator/1.0.0/skill.json +0 -9
- package/skillhub-registry/shadcn-crud-generator/1.0.0/templates/feature/actions.ts.hbs +0 -16
- 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: "
|
|
15
|
-
|
|
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: "
|
|
20
|
-
|
|
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: "
|
|
25
|
-
|
|
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
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
167
|
-
console.log(` 1.
|
|
168
|
-
console.log(` 2.
|
|
169
|
-
console.log(` 3.
|
|
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 ||
|
|
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.
|
|
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
|
|
250
|
+
: { ...targetConfig.adapters };
|
|
201
251
|
|
|
202
252
|
return config;
|
|
203
253
|
}
|
|
204
254
|
|
|
205
|
-
function
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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
|
|
315
|
-
const
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
444
|
+
async function readOptionalJson(filePath) {
|
|
445
|
+
return (await exists(filePath)) ? readJson(filePath) : null;
|
|
446
|
+
}
|
|
320
447
|
|
|
321
|
-
|
|
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
|
|
325
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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(
|
|
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(
|
|
487
|
+
packageDir = path.join(baseDir, version);
|
|
346
488
|
}
|
|
347
489
|
|
|
348
|
-
if (!(await exists(packageDir)))
|
|
349
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
358
|
-
|
|
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
|
-
|
|
540
|
+
throw new Error(`${SECTION_SINGULAR[section]} "${name}" was not found in registry: ${registryPath}`);
|
|
541
|
+
}
|
|
361
542
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|
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
|
-
|
|
379
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
595
|
+
async function loadArtifact(registry, section, name, requestedVersion) {
|
|
396
596
|
if (registry.startsWith("http://") || registry.startsWith("https://")) {
|
|
397
|
-
return
|
|
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
|
-
|
|
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
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
409
|
-
|
|
633
|
+
const entryName = section === "rules" ? "RULE.md" : "DOMAIN.md";
|
|
634
|
+
const singleEntry = files.length === 1 && files[0].path === entryName;
|
|
410
635
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
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
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
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
|
|
485
|
-
const
|
|
486
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
await writeJson(lockPath,
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
ok(`Created ${config.
|
|
531
|
-
|
|
532
|
-
info(
|
|
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
|
|
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
|
-
|
|
547
|
-
const
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
827
|
+
async function commandInstall(projectRoot, positional, flags = {}) {
|
|
828
|
+
if (positional.length === 0) {
|
|
829
|
+
await commandSync(projectRoot, flags);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
553
832
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
await writeJson(configPath, config);
|
|
833
|
+
let section = flags.type || flags.section || "skills";
|
|
834
|
+
let spec = positional[0];
|
|
557
835
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
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(
|
|
848
|
+
await writeJson(path.join(projectRoot, CONFIG_FILE), config);
|
|
581
849
|
}
|
|
582
|
-
const skills = Object.entries(config.skills || {});
|
|
583
850
|
|
|
584
|
-
|
|
585
|
-
|
|
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 [
|
|
590
|
-
await
|
|
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 ${
|
|
867
|
+
ok(`Synced ${entries.length} item${entries.length === 1 ? "" : "s"}.`);
|
|
594
868
|
}
|
|
595
869
|
|
|
596
870
|
async function commandList(projectRoot) {
|
|
597
|
-
const lock = await
|
|
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 (!
|
|
600
|
-
warn("No
|
|
875
|
+
if (!hasAny) {
|
|
876
|
+
warn("No SkillHub knowledge attached yet.");
|
|
601
877
|
return;
|
|
602
878
|
}
|
|
603
879
|
|
|
604
|
-
console.log(paint("bold", "
|
|
605
|
-
for (const [
|
|
606
|
-
|
|
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,
|
|
611
|
-
|
|
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 =
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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(
|
|
626
|
-
await writeJson(
|
|
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 ${
|
|
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(
|
|
640
|
-
await ensureDir(path.join(projectRoot, config.skillsDir));
|
|
936
|
+
await writeJson(path.join(projectRoot, CONFIG_FILE), config);
|
|
641
937
|
|
|
642
|
-
const lock =
|
|
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(`
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
948
|
+
function titleFromName(name) {
|
|
949
|
+
return String(name)
|
|
950
|
+
.replace(/[-_]+/g, " ")
|
|
951
|
+
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
952
|
+
}
|
|
657
953
|
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
|
671
|
-
|
|
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
|
-
|
|
690
|
-
|
|
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
|
-
|
|
693
|
-
|
|
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,
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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 } =
|
|
710
|
-
|
|
1040
|
+
const { name, version } = parseArtifactSpec(spec);
|
|
1041
|
+
artifact = await loadArtifact(registry, section, name, version);
|
|
711
1042
|
}
|
|
712
1043
|
|
|
713
|
-
|
|
714
|
-
ok(`Valid
|
|
715
|
-
info(`Files: ${
|
|
716
|
-
info(`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
|
|
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
|
|
776
|
-
|
|
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\
|
|
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
|
|
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
|
|
1215
|
+
await commandCreate(projectRoot, positional, flags);
|
|
848
1216
|
break;
|
|
849
1217
|
case "validate":
|
|
850
|
-
await commandValidate(projectRoot, positional
|
|
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
|
-
|
|
1238
|
+
normalizeTarget,
|
|
1239
|
+
normalizeSection,
|
|
1240
|
+
parseArtifactSpec,
|
|
868
1241
|
checksumFiles,
|
|
869
1242
|
sortVersions,
|
|
870
|
-
|
|
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`.
|