skillex 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,356 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as nodeFs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { DEFAULT_CATALOG_PATH, DEFAULT_REF, DEFAULT_REPO, DEFAULT_SKILLS_DIR, } from "./config.js";
5
+ import { normalizeAdapterList } from "./adapters.js";
6
+ import { assertSafeRelativePath } from "./fs.js";
7
+ import { fetchJson, fetchOptionalJson, fetchText } from "./http.js";
8
+ import { debug } from "./output.js";
9
+ import { CatalogError } from "./types.js";
10
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
11
+ /**
12
+ * Reads a cached catalog from disk.
13
+ *
14
+ * @param cacheDir - Directory where cache files are stored.
15
+ * @param cacheKey - Unique key for this catalog source.
16
+ * @returns Cached catalog data, or `null` if missing or expired.
17
+ */
18
+ export async function readCatalogCache(cacheDir, cacheKey) {
19
+ const cachePath = path.join(cacheDir, `${cacheKey}.json`);
20
+ try {
21
+ const content = await nodeFs.readFile(cachePath, "utf-8");
22
+ const cache = JSON.parse(content);
23
+ if (new Date(cache.expiresAt).getTime() > Date.now()) {
24
+ return cache.data;
25
+ }
26
+ return null; // expired
27
+ }
28
+ catch {
29
+ return null; // missing or invalid
30
+ }
31
+ }
32
+ /**
33
+ * Writes catalog data to the local cache with a 5-minute TTL.
34
+ *
35
+ * @param cacheDir - Directory where cache files are stored.
36
+ * @param cacheKey - Unique key for this catalog source.
37
+ * @param data - Catalog data to cache.
38
+ */
39
+ export async function writeCatalogCache(cacheDir, cacheKey, data) {
40
+ const cachePath = path.join(cacheDir, `${cacheKey}.json`);
41
+ const cache = {
42
+ expiresAt: new Date(Date.now() + CACHE_TTL_MS).toISOString(),
43
+ data,
44
+ };
45
+ await nodeFs.mkdir(cacheDir, { recursive: true });
46
+ await nodeFs.writeFile(cachePath, JSON.stringify(cache), "utf-8");
47
+ }
48
+ /**
49
+ * Computes a short, stable cache key for a catalog source URL.
50
+ *
51
+ * @param source - Resolved catalog source.
52
+ * @returns 16-character hex string.
53
+ */
54
+ export function computeCatalogCacheKey(source) {
55
+ const url = source.catalogUrl ?? buildRawGitHubUrl(source.repo, source.ref, source.catalogPath);
56
+ return createHash("sha256").update(url).digest("hex").slice(0, 16);
57
+ }
58
+ /**
59
+ * Loads a remote skill catalog from `catalog.json` or falls back to repository tree inspection.
60
+ *
61
+ * @param options - Catalog source overrides.
62
+ * @returns Normalized remote catalog data.
63
+ * @throws {CatalogError} When the catalog cannot be fetched or normalized.
64
+ */
65
+ export async function loadCatalog(options = {}) {
66
+ try {
67
+ const source = resolveSource(options);
68
+ const cacheDir = options.cacheDir;
69
+ const cacheKey = computeCatalogCacheKey(source);
70
+ // Check local cache first
71
+ if (cacheDir && !options.noCache) {
72
+ const cached = await readCatalogCache(cacheDir, cacheKey);
73
+ if (cached) {
74
+ debug(`Catalog cache hit for ${source.repo}@${source.ref}`);
75
+ return cached;
76
+ }
77
+ debug(`Catalog cache miss — fetching from network`);
78
+ }
79
+ const catalogUrl = source.catalogUrl ?? buildRawGitHubUrl(source.repo, source.ref, source.catalogPath);
80
+ const remoteCatalog = await fetchOptionalJson(catalogUrl);
81
+ const result = remoteCatalog ? normalizeCatalog(remoteCatalog, source) : await loadCatalogFromTree(source);
82
+ // Write to cache (fire-and-forget; never fail the caller)
83
+ if (cacheDir) {
84
+ writeCatalogCache(cacheDir, cacheKey, result).catch(() => { });
85
+ }
86
+ return result;
87
+ }
88
+ catch (error) {
89
+ if (error instanceof CatalogError) {
90
+ throw error;
91
+ }
92
+ const message = error instanceof Error ? error.message : String(error);
93
+ throw new CatalogError(`Failed to load catalog: ${message}`);
94
+ }
95
+ }
96
+ /**
97
+ * Resolves the effective GitHub catalog source from CLI options.
98
+ *
99
+ * @param options - Catalog source overrides.
100
+ * @returns Normalized catalog source.
101
+ * @throws {CatalogError} When the repository reference is invalid.
102
+ */
103
+ export function resolveSource(options = {}) {
104
+ const repoParts = parseGitHubRepo(options.repo || DEFAULT_REPO);
105
+ return {
106
+ owner: repoParts.owner,
107
+ repoName: repoParts.repo,
108
+ repo: `${repoParts.owner}/${repoParts.repo}`,
109
+ ref: options.ref || repoParts.ref || DEFAULT_REF,
110
+ catalogPath: options.catalogPath || DEFAULT_CATALOG_PATH,
111
+ skillsDir: options.skillsDir || DEFAULT_SKILLS_DIR,
112
+ catalogUrl: options.catalogUrl || null,
113
+ };
114
+ }
115
+ /**
116
+ * Parses a GitHub repository reference in `owner/repo` or GitHub URL format.
117
+ *
118
+ * @param input - Repository reference to parse.
119
+ * @returns Parsed GitHub repository parts.
120
+ * @throws {CatalogError} When the input cannot be parsed.
121
+ */
122
+ export function parseGitHubRepo(input) {
123
+ if (!input || !input.trim()) {
124
+ throw new CatalogError("Provide a GitHub repository in owner/repo format or a GitHub URL.", "INVALID_REPOSITORY");
125
+ }
126
+ const trimmed = input.trim();
127
+ if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
128
+ const url = new URL(trimmed);
129
+ if (url.hostname !== "github.com") {
130
+ throw new CatalogError("Only github.com URLs are supported.", "INVALID_REPOSITORY");
131
+ }
132
+ const parts = url.pathname.split("/").filter(Boolean);
133
+ if (parts.length < 2) {
134
+ throw new CatalogError(`Could not extract owner/repo from "${trimmed}".`, "INVALID_REPOSITORY");
135
+ }
136
+ const result = { owner: parts[0], repo: parts[1], ref: null };
137
+ if (parts[2] === "tree" && parts[3]) {
138
+ result.ref = parts.slice(3).join("/");
139
+ }
140
+ return result;
141
+ }
142
+ const parts = trimmed.split("/");
143
+ if (parts.length !== 2) {
144
+ throw new CatalogError(`Invalid repository "${trimmed}". Use owner/repo format.`, "INVALID_REPOSITORY");
145
+ }
146
+ return { owner: parts[0], repo: parts[1], ref: null };
147
+ }
148
+ /**
149
+ * Builds a raw GitHub content URL for a repository file.
150
+ *
151
+ * @param repo - Repository in `owner/name` format.
152
+ * @param ref - Branch, tag, or commit.
153
+ * @param filePath - Repository-relative file path.
154
+ * @returns Raw GitHub content URL.
155
+ */
156
+ export function buildRawGitHubUrl(repo, ref, filePath) {
157
+ return `https://raw.githubusercontent.com/${repo}/${encodeRef(ref)}/${stripLeadingSlash(filePath)}`;
158
+ }
159
+ /**
160
+ * Builds the GitHub tree API URL for a repository reference.
161
+ *
162
+ * @param repo - Repository in `owner/name` format.
163
+ * @param ref - Branch, tag, or commit.
164
+ * @returns GitHub API URL for recursive tree inspection.
165
+ */
166
+ export function buildGitHubApiUrl(repo, ref) {
167
+ return `https://api.github.com/repos/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=1`;
168
+ }
169
+ async function loadCatalogFromTree(source) {
170
+ const tree = await fetchJson(buildGitHubApiUrl(source.repo, source.ref));
171
+ const files = Array.isArray(tree.tree) ? tree.tree : [];
172
+ const manifests = files.filter((item) => {
173
+ if (item.type !== "blob") {
174
+ return false;
175
+ }
176
+ return item.path.startsWith(`${source.skillsDir}/`) && item.path.endsWith("/skill.json");
177
+ });
178
+ if (manifests.length > 0) {
179
+ const skills = await Promise.all(manifests.map(async (manifest) => {
180
+ const manifestJson = await fetchJson(buildRawGitHubUrl(source.repo, source.ref, manifest.path), { headers: { Accept: "application/json" } });
181
+ const skillPath = manifest.path.slice(0, -"/skill.json".length);
182
+ const skillId = skillPath.split("/").pop();
183
+ return normalizeSkill({
184
+ id: skillId,
185
+ path: skillPath,
186
+ files: collectFilesUnderPath(files, skillPath),
187
+ ...manifestJson,
188
+ }, source);
189
+ }));
190
+ return {
191
+ formatVersion: 1,
192
+ repo: source.repo,
193
+ ref: source.ref,
194
+ skills,
195
+ };
196
+ }
197
+ const skillFiles = files.filter((item) => {
198
+ if (item.type !== "blob") {
199
+ return false;
200
+ }
201
+ if (!item.path.endsWith("/SKILL.md")) {
202
+ return false;
203
+ }
204
+ return !item.path.split("/").some((segment) => segment.startsWith("."));
205
+ });
206
+ if (skillFiles.length === 0) {
207
+ throw new CatalogError(`No catalog found at ${source.repo}@${source.ref}. Expected: ${source.catalogPath}, manifests at ${source.skillsDir}/*/skill.json, or folders containing SKILL.md.`, "CATALOG_NOT_FOUND");
208
+ }
209
+ const skills = await Promise.all(skillFiles.map(async (skillFile) => {
210
+ const skillPath = skillFile.path.slice(0, -"/SKILL.md".length);
211
+ const skillId = skillPath.split("/").pop();
212
+ const skillBody = await fetchJsonLikeText(buildRawGitHubUrl(source.repo, source.ref, skillFile.path));
213
+ const metadata = extractSkillMetadata(skillBody);
214
+ return normalizeSkill({
215
+ id: skillId,
216
+ path: skillPath,
217
+ name: metadata.name || skillId,
218
+ description: metadata.description || "",
219
+ files: collectFilesUnderPath(files, skillPath),
220
+ }, source);
221
+ }));
222
+ return {
223
+ formatVersion: 1,
224
+ repo: source.repo,
225
+ ref: source.ref,
226
+ skills: sortSkills(skills),
227
+ };
228
+ }
229
+ async function fetchJsonLikeText(url) {
230
+ return fetchText(url, { headers: { Accept: "text/plain" } });
231
+ }
232
+ function extractSkillMetadata(content) {
233
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
234
+ if (!match) {
235
+ return {};
236
+ }
237
+ const frontmatter = match[1];
238
+ if (frontmatter === undefined) {
239
+ return {};
240
+ }
241
+ const name = extractFrontmatterValue(frontmatter, "name");
242
+ const description = extractFrontmatterValue(frontmatter, "description");
243
+ return {
244
+ ...(name !== null ? { name } : {}),
245
+ ...(description !== null ? { description } : {}),
246
+ };
247
+ }
248
+ function extractFrontmatterValue(frontmatter, key) {
249
+ const expression = new RegExp(`^${key}:\\s*(.+)$`, "m");
250
+ const match = frontmatter.match(expression);
251
+ if (!match) {
252
+ return null;
253
+ }
254
+ const value = match[1];
255
+ if (value === undefined) {
256
+ return null;
257
+ }
258
+ return value.trim().replace(/^["']|["']$/g, "");
259
+ }
260
+ function collectFilesUnderPath(treeFiles, skillPath) {
261
+ return treeFiles
262
+ .filter((item) => item.type === "blob" && item.path.startsWith(`${skillPath}/`))
263
+ .map((item) => assertSafeRelativePath(item.path.slice(skillPath.length + 1)));
264
+ }
265
+ function normalizeCatalog(remoteCatalog, source) {
266
+ const remoteSkills = Array.isArray(remoteCatalog.skills) ? remoteCatalog.skills : [];
267
+ if (remoteSkills.length === 0) {
268
+ throw new CatalogError("catalog.json found but the skills array is empty.", "CATALOG_EMPTY");
269
+ }
270
+ return {
271
+ formatVersion: Number(remoteCatalog.formatVersion || 1),
272
+ repo: remoteCatalog.repo || source.repo,
273
+ ref: remoteCatalog.ref || source.ref,
274
+ skills: sortSkills(remoteSkills.map((skill) => normalizeSkill(skill, source))),
275
+ };
276
+ }
277
+ function normalizeSkill(skill, source) {
278
+ const id = skill.id || skill.slug;
279
+ if (!id) {
280
+ throw new CatalogError("Every skill must have an id field.", "MALFORMED_SKILL");
281
+ }
282
+ const skillPath = skill.path || `${source.skillsDir}/${id}`;
283
+ const files = Array.isArray(skill.files) ? skill.files.map(assertSafeRelativePath) : ["SKILL.md"];
284
+ const uniqueFiles = [...new Set(files)];
285
+ return {
286
+ id,
287
+ name: skill.name || id,
288
+ version: skill.version || "0.1.0",
289
+ description: skill.description || "",
290
+ author: skill.author || null,
291
+ tags: Array.isArray(skill.tags) ? skill.tags : [],
292
+ compatibility: normalizeAdapterList(skill.compatibility),
293
+ entry: skill.entry || "SKILL.md",
294
+ path: stripLeadingSlash(skillPath),
295
+ files: uniqueFiles,
296
+ };
297
+ }
298
+ /**
299
+ * Filters a list of skills using text, compatibility, and tag criteria.
300
+ *
301
+ * @param skills - Skills to search.
302
+ * @param options - Search filters.
303
+ * @returns Matching skills ordered by id.
304
+ */
305
+ export function searchCatalogSkills(skills, options = {}) {
306
+ const query = String(options.query || "").trim().toLowerCase();
307
+ const compatibility = normalizeAdapterList(options.compatibility);
308
+ const tags = normalizeFilterList(options.tags);
309
+ return sortSkills(skills.filter((skill) => {
310
+ if (compatibility.length > 0) {
311
+ const supported = new Set(normalizeAdapterList(skill.compatibility));
312
+ if (!compatibility.every((item) => supported.has(item))) {
313
+ return false;
314
+ }
315
+ }
316
+ if (tags.length > 0) {
317
+ const availableTags = new Set((skill.tags || []).map((item) => item.toLowerCase()));
318
+ if (!tags.every((item) => availableTags.has(item))) {
319
+ return false;
320
+ }
321
+ }
322
+ if (!query) {
323
+ return true;
324
+ }
325
+ const haystack = [
326
+ skill.id,
327
+ skill.name,
328
+ skill.description,
329
+ ...(skill.tags || []),
330
+ ...(skill.compatibility || []),
331
+ ]
332
+ .filter(Boolean)
333
+ .join(" ")
334
+ .toLowerCase();
335
+ return haystack.includes(query);
336
+ }));
337
+ }
338
+ function stripLeadingSlash(value) {
339
+ return value.replace(/^\/+/, "");
340
+ }
341
+ function encodeRef(ref) {
342
+ return ref
343
+ .split("/")
344
+ .map((segment) => encodeURIComponent(segment))
345
+ .join("/");
346
+ }
347
+ function normalizeFilterList(value) {
348
+ if (!value) {
349
+ return [];
350
+ }
351
+ const items = Array.isArray(value) ? value : String(value).split(",");
352
+ return [...new Set(items.map((item) => item.trim().toLowerCase()).filter(Boolean))];
353
+ }
354
+ function sortSkills(skills) {
355
+ return [...skills].sort((left, right) => left.id.localeCompare(right.id));
356
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Runs the Skillex CLI entrypoint.
3
+ *
4
+ * @param argv - Raw CLI arguments without the Node executable prefix.
5
+ * @throws {CliError} When the command or flag values are invalid.
6
+ */
7
+ export declare function main(argv: string[]): Promise<void>;