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 +51 -0
- package/bin/meow.js +8 -0
- package/package.json +31 -0
- package/src/cli.js +574 -0
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
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
|
+
}
|