meow-skills-fetch-cli 0.2.1

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/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # meow-skills-fetch-cli
2
+
3
+ Download matching `SKILL.md` files from the terminal.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g meow-skills-fetch-cli
9
+ ```
10
+
11
+ Or run without installing:
12
+
13
+ ```bash
14
+ npx meow-skills-fetch-cli rust
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```bash
20
+ meow rust
21
+ meow cpp --limit 5
22
+ meow kotlin --output ./downloads/kotlin
23
+ meow rust --limit 20 --concurrency 8
24
+ ```
25
+
26
+ ## Output
27
+
28
+ By default, files are saved in:
29
+
30
+ ```bash
31
+ ./<query>_skills
32
+ ```
33
+
34
+ Example:
35
+
36
+ ```bash
37
+ meow rust
38
+ ```
39
+
40
+ This creates:
41
+
42
+ ```bash
43
+ ./rust_skills
44
+ ```
45
+
46
+ ## Options
47
+
48
+ - `--limit` limits how many matching skills are downloaded
49
+ - `--output` sets a custom output folder
50
+ - `--concurrency` controls parallel downloads
51
+ - `--help` shows help
package/bin/meow.js ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from "../src/cli.js";
4
+
5
+ main().catch((error) => {
6
+ console.error(`Error: ${error.message}`);
7
+ process.exitCode = 1;
8
+ });
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "meow-skills-fetch-cli",
3
+ "version": "0.2.1",
4
+ "description": "CLI to search public skill indexes and download matching SKILL.md files with a terminal dashboard.",
5
+ "type": "module",
6
+ "bin": {
7
+ "meow": "./bin/meow.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "start": "node ./bin/meow.js",
16
+ "build:bin": "pkg . --targets node20-macos-arm64,node20-linux-x64,node20-win-x64 --out-path dist"
17
+ },
18
+ "keywords": [
19
+ "cli",
20
+ "skills",
21
+ "markdown",
22
+ "scraper"
23
+ ],
24
+ "license": "MIT",
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
28
+ "devDependencies": {
29
+ "pkg": "^5.8.1"
30
+ }
31
+ }
package/src/cli.js ADDED
@@ -0,0 +1,574 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+
5
+ const COLOR = {
6
+ black: "\u001b[30m",
7
+ green: "\u001b[92m",
8
+ yellow: "\u001b[93m",
9
+ red: "\u001b[91m",
10
+ cyan: "\u001b[96m",
11
+ white: "\u001b[97m",
12
+ reset: "\u001b[0m",
13
+ bold: "\u001b[1m",
14
+ dim: "\u001b[2m"
15
+ };
16
+
17
+ const FRAMES_NORMAL = [
18
+ ["o", "o"],
19
+ ["^", "^"],
20
+ ["-", "-"],
21
+ ["*", "*"],
22
+ ["@", "@"],
23
+ ["o", "O"],
24
+ [">", "<"],
25
+ ["O", "O"]
26
+ ];
27
+
28
+ const FRAMES_SAD = [
29
+ ["T", "T"],
30
+ [";", ";"],
31
+ ["v", "v"],
32
+ ["x", "x"]
33
+ ];
34
+
35
+ const FETCH_TIMEOUT_MS = 15_000;
36
+ const FETCH_RETRIES = 3;
37
+ const DEFAULT_CONCURRENCY = 8;
38
+ const MAX_CONCURRENCY = 16;
39
+
40
+ function sleep(ms) {
41
+ return new Promise((resolve) => setTimeout(resolve, ms));
42
+ }
43
+
44
+ function xorDecode(values, key) {
45
+ return values
46
+ .map((value, index) => String.fromCharCode(value ^ key[index % key.length]))
47
+ .join("");
48
+ }
49
+
50
+ function searchEndpoint() {
51
+ // This only hides the endpoint from simple static greps; runtime traffic still reveals it.
52
+ return xorDecode(
53
+ [99, 105, 115, 103, 112, 41, 36, 50, 116, 124, 106, 127, 103, 110, 41, 100, 107, 60, 106, 109, 110, 56, 112, 118, 106, 111, 100, 127],
54
+ [11, 29, 7, 23, 3, 19]
55
+ );
56
+ }
57
+
58
+ function githubApiBase() {
59
+ return xorDecode(
60
+ [109, 101, 125, 101, 118, 43, 38, 58, 100, 97, 96, 59, 98, 120, 125, 125, 112, 115, 39, 118, 106, 124],
61
+ [5, 17, 9, 21]
62
+ );
63
+ }
64
+
65
+ function githubRawBase() {
66
+ return xorDecode(
67
+ [101, 119, 109, 119, 120, 55, 44, 54, 117, 106, 122, 45, 126, 110, 127, 101, 118, 123, 114, 120, 104, 113, 122, 104, 101, 121, 102, 119, 115, 37, 110, 108, 116],
68
+ [13, 3, 25, 7, 11]
69
+ );
70
+ }
71
+
72
+ function pbar(percent, width = 20) {
73
+ const filled = Math.floor((width * percent) / 100);
74
+ const color = percent === 100 ? COLOR.green : COLOR.cyan;
75
+ return ` ${COLOR.green}${"█".repeat(filled)}${COLOR.dim}${"░".repeat(
76
+ width - filled
77
+ )}${COLOR.reset} ${COLOR.bold}${color}${String(percent).padStart(3, " ")}%${COLOR.reset}`;
78
+ }
79
+
80
+ class CatUI {
81
+ static frameIndex = 0;
82
+ static sad = false;
83
+ static catHeight = 5;
84
+ static enabled = Boolean(process.stdout.isTTY);
85
+ static hiddenCursor = false;
86
+ static reserved = false;
87
+ static linesSinceCat = 0;
88
+
89
+ static init() {
90
+ if (!this.enabled) {
91
+ return;
92
+ }
93
+
94
+ process.stdout.write("\u001b[?25l");
95
+ this.hiddenCursor = true;
96
+ process.stdout.write("\n".repeat(this.catHeight));
97
+ this.reserved = true;
98
+ this.linesSinceCat = 0;
99
+ }
100
+
101
+ static cleanup() {
102
+ if (this.hiddenCursor) {
103
+ process.stdout.write("\u001b[?25h");
104
+ this.hiddenCursor = false;
105
+ }
106
+
107
+ this.reserved = false;
108
+ this.linesSinceCat = 0;
109
+ }
110
+
111
+ static setSad(sad) {
112
+ this.sad = sad;
113
+ }
114
+
115
+ static draw(status, percent = null) {
116
+ if (!this.enabled) {
117
+ return;
118
+ }
119
+
120
+ const pool = this.sad ? FRAMES_SAD : FRAMES_NORMAL;
121
+ this.frameIndex = (this.frameIndex + 1) % pool.length;
122
+ const [leftEye, rightEye] = pool[this.frameIndex];
123
+ const bar = percent === null ? ` ${COLOR.dim}${"░".repeat(20)}${COLOR.reset} -%` : pbar(percent);
124
+ const statusColor = COLOR.black;
125
+
126
+ const totalUp = this.linesSinceCat + this.catHeight;
127
+ process.stdout.write(`\u001b[${totalUp}A\r`);
128
+ process.stdout.write(`\u001b[2K ${COLOR.cyan}/\\_/\\\\${COLOR.reset}\n`);
129
+ process.stdout.write(
130
+ `\u001b[2K ${COLOR.cyan}(${COLOR.reset} ${COLOR.white}${leftEye}${COLOR.cyan}.${COLOR.white}${rightEye}${COLOR.cyan} )${COLOR.reset} ${statusColor}Meowing: ${status}${COLOR.reset}\n`
131
+ );
132
+ process.stdout.write(`\u001b[2K ${COLOR.cyan}> ^ <${COLOR.reset}\n`);
133
+ process.stdout.write(`\u001b[2K${bar}\n`);
134
+ process.stdout.write(`\u001b[2K${COLOR.dim}${"─".repeat(40)}${COLOR.reset}\n`);
135
+
136
+ if (this.linesSinceCat > 0) {
137
+ process.stdout.write(`\u001b[${this.linesSinceCat}B\r`);
138
+ }
139
+ }
140
+
141
+ static log(color, prefix, message) {
142
+ const line = `\r\u001b[2K ${color}${prefix}${COLOR.reset} ${message}\n`;
143
+ process.stdout.write(line);
144
+ if (this.enabled && this.reserved) {
145
+ this.linesSinceCat += 1;
146
+ }
147
+ }
148
+
149
+ static line(message = "") {
150
+ if (this.enabled && this.reserved) {
151
+ this.linesSinceCat += 1;
152
+ }
153
+
154
+ process.stdout.write(`\r\u001b[2K ${message}\n`);
155
+ }
156
+
157
+ static info(message) {
158
+ this.log(COLOR.cyan, "→", message);
159
+ }
160
+
161
+ static error(message) {
162
+ this.log(COLOR.red, "✕", message);
163
+ }
164
+ }
165
+
166
+ function usage() {
167
+ process.stdout.write(`${COLOR.bold}meow${COLOR.reset} search and download skills\n\n`);
168
+ process.stdout.write("Usage:\n");
169
+ process.stdout.write(" meow [query]\n");
170
+ process.stdout.write(" meow [query] --limit 10\n");
171
+ process.stdout.write(" meow [query] --output ./my-folder\n");
172
+ process.stdout.write(" meow [query] --concurrency 8\n\n");
173
+ process.stdout.write("Options:\n");
174
+ process.stdout.write(" -o, --output Output directory (default: <query>_skills in current folder)\n");
175
+ process.stdout.write(` -l, --limit Limit number of skills to fetch\n`);
176
+ process.stdout.write(
177
+ ` -c, --concurrency Parallel downloads (default: ${DEFAULT_CONCURRENCY}, max: ${MAX_CONCURRENCY})\n`
178
+ );
179
+ process.stdout.write(" -h, --help Show help\n");
180
+ }
181
+
182
+ function parseArgs(argv) {
183
+ const args = {
184
+ query: "rust",
185
+ output: "",
186
+ limit: 0,
187
+ help: false,
188
+ concurrency: DEFAULT_CONCURRENCY
189
+ };
190
+ let querySet = false;
191
+
192
+ for (let index = 0; index < argv.length; index += 1) {
193
+ const token = argv[index];
194
+
195
+ if (token === "-h" || token === "--help") {
196
+ args.help = true;
197
+ continue;
198
+ }
199
+
200
+ if (token === "-o" || token === "--output") {
201
+ args.output = argv[index + 1] ?? "";
202
+ if (!args.output || args.output.startsWith("-")) {
203
+ throw new Error("--output requires a value");
204
+ }
205
+ index += 1;
206
+ continue;
207
+ }
208
+
209
+ if (token === "-l" || token === "--limit") {
210
+ const rawLimit = argv[index + 1] ?? "";
211
+ if (!rawLimit || rawLimit.startsWith("-")) {
212
+ throw new Error("--limit requires a value");
213
+ }
214
+ args.limit = Number(rawLimit);
215
+ index += 1;
216
+ continue;
217
+ }
218
+
219
+ if (token === "-c" || token === "--concurrency") {
220
+ const rawConcurrency = argv[index + 1] ?? "";
221
+ if (!rawConcurrency || rawConcurrency.startsWith("-")) {
222
+ throw new Error("--concurrency requires a value");
223
+ }
224
+ args.concurrency = Number(rawConcurrency);
225
+ index += 1;
226
+ continue;
227
+ }
228
+
229
+ if (!token.startsWith("-") && !querySet) {
230
+ args.query = token;
231
+ querySet = true;
232
+ }
233
+ }
234
+
235
+ if (Number.isNaN(args.limit) || args.limit < 0) {
236
+ throw new Error("--limit must be a positive number");
237
+ }
238
+
239
+ if (
240
+ Number.isNaN(args.concurrency) ||
241
+ args.concurrency < 1 ||
242
+ args.concurrency > MAX_CONCURRENCY
243
+ ) {
244
+ throw new Error(`--concurrency must be between 1 and ${MAX_CONCURRENCY}`);
245
+ }
246
+
247
+ return args;
248
+ }
249
+
250
+ function extractQuery(input) {
251
+ if (!input.startsWith("http://") && !input.startsWith("https://")) {
252
+ return input;
253
+ }
254
+
255
+ try {
256
+ const parsed = new URL(input);
257
+ return parsed.searchParams.get("q") || "rust";
258
+ } catch {
259
+ return input;
260
+ }
261
+ }
262
+
263
+ function parseSource(source) {
264
+ const parts = source.split("/");
265
+ if (parts.length >= 2) {
266
+ return {
267
+ owner: parts.slice(0, -1).join("/"),
268
+ repo: parts.at(-1)
269
+ };
270
+ }
271
+
272
+ return {
273
+ owner: source,
274
+ repo: ""
275
+ };
276
+ }
277
+
278
+ function sanitizeFilename(name) {
279
+ return name.replace(/[<>:"/\\|?*]+/g, "_").trim() || "unknown";
280
+ }
281
+
282
+ async function fetchWithRetry(url, accept, { nullable = false } = {}) {
283
+ for (let attempt = 1; attempt <= FETCH_RETRIES; attempt += 1) {
284
+ try {
285
+ const response = await fetch(url, {
286
+ headers: {
287
+ "user-agent": "meow-skills-fetch-cli/0.2.0",
288
+ accept
289
+ },
290
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
291
+ });
292
+
293
+ if (nullable && response.status === 404) {
294
+ return null;
295
+ }
296
+
297
+ if (!response.ok) {
298
+ const retryable = response.status >= 500 || response.status === 429;
299
+ if (!retryable || attempt === FETCH_RETRIES) {
300
+ if (nullable) {
301
+ return null;
302
+ }
303
+ throw new Error(`Request failed with ${response.status}`);
304
+ }
305
+ } else {
306
+ return response;
307
+ }
308
+ } catch (error) {
309
+ const aborted = error?.name === "TimeoutError" || error?.name === "AbortError";
310
+ if (attempt === FETCH_RETRIES) {
311
+ if (nullable) {
312
+ return null;
313
+ }
314
+ throw new Error(aborted ? "request timed out" : error.message);
315
+ }
316
+ }
317
+
318
+ await sleep(220 * attempt);
319
+ }
320
+
321
+ return null;
322
+ }
323
+
324
+ async function getJson(url, options) {
325
+ const response = await fetchWithRetry(url, "application/json", options);
326
+ if (!response) {
327
+ return null;
328
+ }
329
+ return response.json();
330
+ }
331
+
332
+ async function getText(url, options) {
333
+ const response = await fetchWithRetry(url, "text/plain", options);
334
+ if (!response) {
335
+ return null;
336
+ }
337
+ return response.text();
338
+ }
339
+
340
+ async function searchSkills(query) {
341
+ const url = new URL(searchEndpoint());
342
+ url.searchParams.set("q", query);
343
+ const payload = await getJson(url);
344
+ return Array.isArray(payload?.skills) ? payload.skills : [];
345
+ }
346
+
347
+ const repoTreeCache = new Map();
348
+
349
+ async function findSkillInTree(owner, repo, skillName, branch = "main") {
350
+ const cacheKey = `${owner}/${repo}/${branch}`;
351
+
352
+ if (!repoTreeCache.has(cacheKey)) {
353
+ const treeUrl = `${githubApiBase()}/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`;
354
+ repoTreeCache.set(
355
+ cacheKey,
356
+ (async () => {
357
+ try {
358
+ const payload = await getJson(treeUrl, { nullable: true });
359
+ if (!payload?.tree) {
360
+ return [];
361
+ }
362
+ return payload.tree
363
+ .filter((item) => item.type === "blob" && item.path.endsWith("SKILL.md"))
364
+ .map((item) => item.path);
365
+ } catch {
366
+ return [];
367
+ }
368
+ })()
369
+ );
370
+ }
371
+
372
+ const cached = await repoTreeCache.get(cacheKey);
373
+ const paths = Array.isArray(cached) ? cached : [];
374
+ if (paths.length === 0 && branch === "main") {
375
+ return findSkillInTree(owner, repo, skillName, "master");
376
+ }
377
+ const normalized = skillName.toLowerCase();
378
+
379
+ for (const filePath of paths) {
380
+ if (!filePath.toLowerCase().includes(normalized)) {
381
+ continue;
382
+ }
383
+
384
+ const rawUrl = `${githubRawBase()}/${owner}/${repo}/${branch}/${filePath}`;
385
+ const content = await getText(rawUrl, { nullable: true });
386
+ if (content) {
387
+ return content;
388
+ }
389
+ }
390
+
391
+ return null;
392
+ }
393
+
394
+ function candidateSkillUrls(owner, repo, skillName) {
395
+ const base = `${githubRawBase()}/${owner}/${repo}`;
396
+ return [
397
+ `${base}/main/skills/${skillName}/SKILL.md`,
398
+ `${base}/main/.claude/skills/${skillName}/SKILL.md`,
399
+ `${base}/main/${skillName}/SKILL.md`,
400
+ `${base}/HEAD/main/skills/${skillName}/SKILL.md`,
401
+ `${base}/HEAD/.claude/skills/${skillName}/SKILL.md`,
402
+ `${base}/HEAD/${skillName}/SKILL.md`,
403
+ `${base}/master/skills/${skillName}/SKILL.md`,
404
+ `${base}/master/.claude/skills/${skillName}/SKILL.md`,
405
+ `${base}/master/${skillName}/SKILL.md`
406
+ ];
407
+ }
408
+
409
+ async function getSkillDetails(owner, repo, skillName) {
410
+ for (const candidate of candidateSkillUrls(owner, repo, skillName)) {
411
+ const content = await getText(candidate, { nullable: true });
412
+ if (content) {
413
+ return content;
414
+ }
415
+ }
416
+
417
+ return findSkillInTree(owner, repo, skillName);
418
+ }
419
+
420
+ function createPool(limit) {
421
+ let active = 0;
422
+ const queue = [];
423
+
424
+ const next = () => {
425
+ if (active >= limit || queue.length === 0) {
426
+ return;
427
+ }
428
+
429
+ active += 1;
430
+ const task = queue.shift();
431
+ task()
432
+ .catch(() => {})
433
+ .finally(() => {
434
+ active -= 1;
435
+ next();
436
+ });
437
+ };
438
+
439
+ return (runner) =>
440
+ new Promise((resolve, reject) => {
441
+ queue.push(async () => {
442
+ try {
443
+ resolve(await runner());
444
+ } catch (error) {
445
+ reject(error);
446
+ }
447
+ });
448
+ next();
449
+ });
450
+ }
451
+
452
+ function renderProgress(state, status, sad = false) {
453
+ const percent = state.total === 0 ? 0 : Math.floor((state.completed / state.total) * 100);
454
+ CatUI.setSad(sad);
455
+ CatUI.draw(status, percent);
456
+ }
457
+
458
+ async function saveSkills(skills, outputDir, concurrency) {
459
+ const outputPath = path.resolve(outputDir);
460
+ await fs.mkdir(outputPath, { recursive: true });
461
+
462
+ const state = {
463
+ total: skills.length,
464
+ completed: 0,
465
+ saved: 0,
466
+ skipped: 0,
467
+ active: 0
468
+ };
469
+ const pool = createPool(concurrency);
470
+
471
+ renderProgress(state, `Found ${skills.length} skills!`, false);
472
+ CatUI.info(`Downloading skill content with concurrency=${concurrency}...`);
473
+
474
+ const tasks = skills.map((skill, index) =>
475
+ pool(async () => {
476
+ const name = skill?.name || "Unknown";
477
+ const source = skill?.source || "";
478
+ const { owner, repo } = parseSource(source);
479
+ let logColor = COLOR.yellow;
480
+ let logPrefix = "⚠";
481
+ let logMessage = `Skipped: ${name}`;
482
+
483
+ state.active += 1;
484
+ renderProgress(
485
+ state,
486
+ `fetching ${name}... active:${state.active}`,
487
+ false
488
+ );
489
+
490
+ try {
491
+ const content = await getSkillDetails(owner, repo, name);
492
+
493
+ if (content) {
494
+ const fileName = `${sanitizeFilename(name)}.md`;
495
+ await fs.writeFile(path.join(outputPath, fileName), content, "utf8");
496
+ state.saved += 1;
497
+ logColor = COLOR.green;
498
+ logPrefix = "✓";
499
+ logMessage = `Saved: ${name}`;
500
+ } else {
501
+ state.skipped += 1;
502
+ logMessage = `Skipped: ${name}`;
503
+ }
504
+ } catch (error) {
505
+ state.skipped += 1;
506
+ logMessage = `Skipped: ${name} (${error.message})`;
507
+ } finally {
508
+ state.active -= 1;
509
+ state.completed += 1;
510
+ const doneIndex = state.completed;
511
+ CatUI.log(logColor, `${logPrefix} [${doneIndex}/${state.total}]`, logMessage);
512
+ renderProgress(
513
+ state,
514
+ state.completed === state.total
515
+ ? "synced! ✓"
516
+ : `completed ${state.completed}/${state.total} active:${state.active}`,
517
+ state.completed > 0 && state.saved === 0
518
+ );
519
+ }
520
+ })
521
+ );
522
+
523
+ await Promise.all(tasks);
524
+ await sleep(300);
525
+ CatUI.setSad(false);
526
+ return { saved: state.saved, skipped: state.skipped, outputPath };
527
+ }
528
+
529
+ export async function main() {
530
+ const args = parseArgs(process.argv.slice(2));
531
+ if (args.help) {
532
+ usage();
533
+ return;
534
+ }
535
+
536
+ const query = extractQuery(args.query);
537
+ const outputDir = args.output || path.join(process.cwd(), `${query}_skills`);
538
+ let result = null;
539
+ let failure = null;
540
+
541
+ CatUI.init();
542
+
543
+ try {
544
+ renderProgress({ total: 1, completed: 0 }, "Searching...", false);
545
+
546
+ const skills = await searchSkills(query);
547
+ if (!skills.length) {
548
+ renderProgress({ total: 1, completed: 1 }, "No skills found!", true);
549
+ await sleep(900);
550
+ return;
551
+ }
552
+
553
+ const limitedSkills = args.limit > 0 ? skills.slice(0, args.limit) : skills;
554
+ result = await saveSkills(limitedSkills, outputDir, args.concurrency);
555
+ } catch (error) {
556
+ failure = error;
557
+ }
558
+
559
+ if (failure) {
560
+ CatUI.line(`${COLOR.red}Error: ${failure.message}${COLOR.reset}`);
561
+ CatUI.cleanup();
562
+ process.exitCode = 1;
563
+ return;
564
+ }
565
+
566
+ if (result) {
567
+ CatUI.line(`${COLOR.bold}${COLOR.green}DONE${COLOR.reset}`);
568
+ CatUI.line(` Saved: ${COLOR.green}${result.saved}${COLOR.reset}`);
569
+ CatUI.line(` Skipped: ${COLOR.yellow}${result.skipped}${COLOR.reset}`);
570
+ CatUI.line(` Output: ${result.outputPath}/`);
571
+ }
572
+
573
+ CatUI.cleanup();
574
+ }