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.
package/dist/cli.js ADDED
@@ -0,0 +1,885 @@
1
+ import * as path from "node:path";
2
+ import { listAdapters } from "./adapters.js";
3
+ import { computeCatalogCacheKey, loadCatalog, readCatalogCache, searchCatalogSkills, } from "./catalog.js";
4
+ import { DEFAULT_AGENT_SKILLS_DIR, getStatePaths } from "./config.js";
5
+ import { addProjectSource, getInstalledSkills, initProject, installSkills, listProjectSources, loadProjectCatalogs, removeProjectSource, removeSkills, resolveProjectSource, syncInstalledSkills, updateInstalledSkills, } from "./install.js";
6
+ import * as output from "./output.js";
7
+ import { setVerbose } from "./output.js";
8
+ import { parseSkillCommandReference, runSkillScript } from "./runner.js";
9
+ import { runInteractiveUi } from "./ui.js";
10
+ import { CliError } from "./types.js";
11
+ import { VALID_CONFIG_KEYS, readUserConfig, writeUserConfig } from "./user-config.js";
12
+ // ---------------------------------------------------------------------------
13
+ // Per-command help text
14
+ // ---------------------------------------------------------------------------
15
+ const COMMAND_HELP = {
16
+ init: `Usage: skillex init [--repo <owner/repo>] [options]
17
+
18
+ Initialize the local Skillex workspace.
19
+
20
+ Options:
21
+ --repo <owner/repo> GitHub repository with skills (default: lgili/skillex)
22
+ --ref <ref> Branch, tag, or commit (default: main)
23
+ --adapter <id> Force a specific adapter
24
+ --auto-sync Enable auto-sync after install/update/remove
25
+ --cwd <path> Target project directory (default: current directory)
26
+
27
+ Example:
28
+ skillex init
29
+ skillex init --repo myorg/my-skills`,
30
+ list: `Usage: skillex list [options]
31
+
32
+ List all skills in the configured sources.
33
+
34
+ Options:
35
+ --repo <owner/repo> GitHub repository (limits this command to one source)
36
+ --ref <ref> Branch, tag, or commit
37
+ --no-cache Bypass local catalog cache
38
+ --json Output results as JSON
39
+
40
+ Example:
41
+ skillex list
42
+ skillex list --repo myorg/my-skills --json`,
43
+ search: `Usage: skillex search [query] [options]
44
+
45
+ Search skills by text, compatibility, or tags.
46
+
47
+ Options:
48
+ --repo <owner/repo> GitHub repository (limits this command to one source)
49
+ --compatibility <id> Filter by adapter compatibility
50
+ --tag <tag> Filter by tag
51
+ --no-cache Bypass local catalog cache
52
+ --json Output results as JSON
53
+
54
+ Example:
55
+ skillex search git --compatibility claude`,
56
+ install: `Usage: skillex install <skill-id...> [options]
57
+ skillex install --all [options]
58
+ skillex install <owner/repo[@ref]> [options]
59
+
60
+ Install one or more skills from the catalog or directly from GitHub.
61
+
62
+ Options:
63
+ --all Install all skills from the catalog
64
+ --repo <owner/repo> GitHub repository (limits this command to one source)
65
+ --ref <ref> Branch, tag, or commit
66
+ --trust Skip confirmation for direct GitHub installs
67
+ --adapter <id> Target adapter
68
+ --no-cache Bypass local catalog cache
69
+
70
+ Example:
71
+ skillex install git-master code-review
72
+ skillex install --all --repo myorg/my-skills
73
+ skillex install octocat/my-skill@main --trust`,
74
+ update: `Usage: skillex update [skill-id...] [options]
75
+
76
+ Update installed skills to the latest catalog version.
77
+
78
+ Options:
79
+ --repo <owner/repo> GitHub repository
80
+ --no-cache Bypass local catalog cache
81
+
82
+ Example:
83
+ skillex update
84
+ skillex update git-master`,
85
+ remove: `Usage: skillex remove <skill-id...>
86
+
87
+ Remove installed skills from the local workspace.
88
+
89
+ Example:
90
+ skillex remove git-master code-review`,
91
+ sync: `Usage: skillex sync [options]
92
+
93
+ Synchronize installed skills to adapter target files.
94
+
95
+ Options:
96
+ --adapter <id> Target adapter (overrides saved config)
97
+ --dry-run Preview changes without writing to disk
98
+ --mode <symlink|copy> Sync write mode (default: symlink)
99
+
100
+ Example:
101
+ skillex sync
102
+ skillex sync --adapter cursor --dry-run`,
103
+ run: `Usage: skillex run <skill-id:command> [options]
104
+
105
+ Execute a script bundled inside an installed skill.
106
+
107
+ Options:
108
+ --yes Skip confirmation prompt
109
+ --timeout <seconds> Script timeout in seconds (default: 30)
110
+
111
+ Example:
112
+ skillex run git-master:cleanup --yes`,
113
+ ui: `Usage: skillex ui [options]
114
+
115
+ Open the interactive terminal browser to browse and install skills.
116
+
117
+ Options:
118
+ --repo <owner/repo> GitHub repository
119
+ --no-cache Bypass local catalog cache
120
+
121
+ Example:
122
+ skillex ui`,
123
+ status: `Usage: skillex status [options]
124
+
125
+ Show the installation status of the current workspace.
126
+
127
+ Options:
128
+ --cwd <path> Target project directory
129
+ --json Output as JSON
130
+
131
+ Example:
132
+ skillex status
133
+ skillex status --json`,
134
+ doctor: `Usage: skillex doctor [options]
135
+
136
+ Run environment and configuration checks to diagnose issues.
137
+
138
+ Options:
139
+ --json Output results as JSON
140
+
141
+ Example:
142
+ skillex doctor`,
143
+ config: `Usage: skillex config set <key> <value>
144
+ skillex config get <key>
145
+
146
+ Manage global Skillex preferences stored in ~/.askillrc.json.
147
+ CLI flags always take precedence over these values.
148
+
149
+ Valid keys: ${VALID_CONFIG_KEYS.join(", ")}
150
+
151
+ Precedence order: CLI flag > GITHUB_TOKEN env > global config > default
152
+
153
+ Example:
154
+ skillex config set defaultRepo myorg/my-skills
155
+ skillex config get defaultRepo`,
156
+ source: `Usage: skillex source <add|remove|list> [repo] [options]
157
+
158
+ Manage the workspace source list.
159
+
160
+ Commands:
161
+ skillex source list
162
+ skillex source add <owner/repo> [--ref main] [--label work]
163
+ skillex source remove <owner/repo>
164
+
165
+ Options:
166
+ --ref <ref> Branch, tag, or commit (default: main)
167
+ --label <label> Human-readable source label
168
+
169
+ Example:
170
+ skillex source add myorg/my-skills --label work`,
171
+ };
172
+ // ---------------------------------------------------------------------------
173
+ // Entrypoint
174
+ // ---------------------------------------------------------------------------
175
+ /**
176
+ * Runs the Skillex CLI entrypoint.
177
+ *
178
+ * @param argv - Raw CLI arguments without the Node executable prefix.
179
+ * @throws {CliError} When the command or flag values are invalid.
180
+ */
181
+ export async function main(argv) {
182
+ const { command, positionals, flags } = parseArgs(argv);
183
+ // Apply verbose flag early so debug output works from the start
184
+ if (flags.verbose === true || flags.v === true) {
185
+ setVerbose(true);
186
+ }
187
+ // Load global user config once at startup
188
+ const userConfig = await readUserConfig();
189
+ // Apply githubToken from user config as env fallback (env always wins)
190
+ if (userConfig.githubToken && !process.env.GITHUB_TOKEN) {
191
+ process.env.GITHUB_TOKEN = userConfig.githubToken;
192
+ }
193
+ // Per-command --help
194
+ if (flags.help === true && command && command !== "help") {
195
+ const helpText = COMMAND_HELP[command];
196
+ if (helpText) {
197
+ output.info(helpText);
198
+ }
199
+ else {
200
+ printHelp();
201
+ }
202
+ return;
203
+ }
204
+ // Resolve command aliases
205
+ const resolvedCommand = resolveAlias(command);
206
+ switch (resolvedCommand) {
207
+ case "help":
208
+ case undefined:
209
+ printHelp();
210
+ return;
211
+ case "init":
212
+ await handleInit(flags, userConfig);
213
+ return;
214
+ case "list":
215
+ await handleList(flags, userConfig);
216
+ return;
217
+ case "search":
218
+ await handleSearch(positionals, flags, userConfig);
219
+ return;
220
+ case "install":
221
+ await handleInstall(positionals, flags, userConfig);
222
+ return;
223
+ case "update":
224
+ await handleUpdate(positionals, flags, userConfig);
225
+ return;
226
+ case "remove":
227
+ await handleRemove(positionals, flags, userConfig);
228
+ return;
229
+ case "sync":
230
+ await handleSync(flags, userConfig);
231
+ return;
232
+ case "run":
233
+ await handleRun(positionals, flags, userConfig);
234
+ return;
235
+ case "ui":
236
+ await handleUi(flags, userConfig);
237
+ return;
238
+ case "status":
239
+ await handleStatus(flags, userConfig);
240
+ return;
241
+ case "doctor":
242
+ await handleDoctor(flags, userConfig);
243
+ return;
244
+ case "config":
245
+ await handleConfig(positionals, flags);
246
+ return;
247
+ case "source":
248
+ await handleSource(positionals, flags, userConfig);
249
+ return;
250
+ default:
251
+ throw new CliError(`Unknown command: ${resolvedCommand}. Run "skillex help" to see available commands.`);
252
+ }
253
+ }
254
+ // ---------------------------------------------------------------------------
255
+ // Command handlers
256
+ // ---------------------------------------------------------------------------
257
+ async function handleInit(flags, userConfig) {
258
+ const repo = asOptionalString(flags.repo) ?? userConfig.defaultRepo;
259
+ const opts = commonOptions(flags, userConfig);
260
+ const result = await initProject({
261
+ ...opts,
262
+ ...(repo ? { repo } : {}),
263
+ });
264
+ if (result.created) {
265
+ output.success(`Initialized at ${result.statePaths.stateDir}`);
266
+ }
267
+ else {
268
+ output.info(`Already configured at ${result.statePaths.stateDir}`);
269
+ }
270
+ const primarySource = result.lockfile.sources[0];
271
+ output.info(` Source : ${primarySource?.repo}@${primarySource?.ref}`);
272
+ if (result.lockfile.sources.length > 1) {
273
+ output.info(` Sources : ${result.lockfile.sources.length}`);
274
+ }
275
+ if (result.lockfile.adapters.active) {
276
+ output.info(` Adapter : ${result.lockfile.adapters.active}`);
277
+ }
278
+ else {
279
+ output.warn("No adapter detected in this directory.\n" +
280
+ ` Use --adapter <id> to specify one. Available: ${listAdapters().map((a) => a.id).join(", ")}`);
281
+ }
282
+ output.info(` Auto-sync: ${result.lockfile.settings.autoSync ? "enabled" : "disabled"}`);
283
+ output.info("\nNext: run 'skillex list' to browse available skills");
284
+ }
285
+ async function handleList(flags, userConfig) {
286
+ const opts = commonOptions(flags, userConfig);
287
+ const aggregated = await loadProjectCatalogs({ ...opts, ...cacheOptions(opts) });
288
+ if (aggregated.skills.length === 0) {
289
+ output.info("No skills found.");
290
+ return;
291
+ }
292
+ if (flags.json === true) {
293
+ output.info(JSON.stringify(aggregated.skills, null, 2));
294
+ return;
295
+ }
296
+ for (const source of aggregated.sources) {
297
+ output.info(`Source: ${source.repo}@${source.ref}${source.label ? ` [${source.label}]` : ""}`);
298
+ printTable(aggregated.skills
299
+ .filter((skill) => skill.source.repo === source.repo && skill.source.ref === source.ref)
300
+ .map((skill) => ({
301
+ id: skill.id,
302
+ version: skill.version,
303
+ name: skill.name,
304
+ description: truncate(skill.description, 96),
305
+ })));
306
+ output.info("");
307
+ }
308
+ }
309
+ async function handleSearch(positionals, flags, userConfig) {
310
+ const opts = commonOptions(flags, userConfig);
311
+ const aggregated = await loadProjectCatalogs({ ...opts, ...cacheOptions(opts) });
312
+ const searchOptions = { query: positionals.join(" ") };
313
+ const compatibility = asOptionalString(flags.compatibility);
314
+ const tag = asOptionalString(flags.tag);
315
+ if (compatibility)
316
+ searchOptions.compatibility = compatibility;
317
+ if (tag)
318
+ searchOptions.tags = tag;
319
+ const filtered = searchCatalogSkills(aggregated.skills, searchOptions);
320
+ if (filtered.length === 0) {
321
+ output.info("No skills match the given filters.");
322
+ return;
323
+ }
324
+ if (flags.json === true) {
325
+ output.info(JSON.stringify(filtered, null, 2));
326
+ return;
327
+ }
328
+ for (const source of aggregated.sources) {
329
+ const sourceMatches = filtered.filter((skill) => skill.source.repo === source.repo && skill.source.ref === source.ref);
330
+ if (sourceMatches.length === 0) {
331
+ continue;
332
+ }
333
+ output.info(`Source: ${source.repo}@${source.ref}${source.label ? ` [${source.label}]` : ""}`);
334
+ printTable(sourceMatches.map((skill) => ({
335
+ id: skill.id,
336
+ version: skill.version,
337
+ compatibility: skill.compatibility.join(","),
338
+ description: truncate(skill.description, 72),
339
+ })));
340
+ output.info("");
341
+ }
342
+ }
343
+ async function handleInstall(positionals, flags, userConfig) {
344
+ const installAll = Boolean(flags.all);
345
+ const opts = commonOptions(flags, userConfig);
346
+ const result = await installSkills(positionals, {
347
+ ...opts,
348
+ installAll,
349
+ onProgress: (current, total, skillId) => {
350
+ output.info(`[${current}/${total}] Installing ${skillId}...`);
351
+ },
352
+ });
353
+ output.success(`Installed ${result.installedCount} skill(s) to ${result.statePaths.stateDir}`);
354
+ for (const skill of result.installedSkills) {
355
+ output.info(` + ${skill.id}@${skill.version}`);
356
+ }
357
+ printAutoSyncResult(result.autoSync);
358
+ }
359
+ async function handleUpdate(positionals, flags, userConfig) {
360
+ const opts = commonOptions(flags, userConfig);
361
+ const result = await updateInstalledSkills(positionals, opts);
362
+ if (result.updatedSkills.length === 0) {
363
+ output.info("No skills updated.");
364
+ }
365
+ else {
366
+ output.success(`Updated ${result.updatedSkills.length} skill(s)`);
367
+ for (const skill of result.updatedSkills) {
368
+ output.info(` ↑ ${skill.id}@${skill.version}`);
369
+ }
370
+ }
371
+ for (const skillId of result.missingFromCatalog) {
372
+ output.warn(`${skillId} no longer exists in the remote catalog`);
373
+ }
374
+ printAutoSyncResult(result.autoSync);
375
+ }
376
+ async function handleRemove(positionals, flags, userConfig) {
377
+ const result = await removeSkills(positionals, commonOptions(flags, userConfig));
378
+ if (result.removedSkills.length > 0) {
379
+ output.success(`Removed ${result.removedSkills.length} skill(s)`);
380
+ for (const skillId of result.removedSkills) {
381
+ output.info(` - ${skillId}`);
382
+ }
383
+ }
384
+ for (const skillId of result.missingSkills) {
385
+ output.warn(`${skillId} is not installed`);
386
+ }
387
+ printAutoSyncResult(result.autoSync);
388
+ }
389
+ async function handleSync(flags, userConfig) {
390
+ const result = await syncInstalledSkills(commonOptions(flags, userConfig));
391
+ if (result.dryRun) {
392
+ output.info(`Preview: ${result.skillCount} skill(s) → ${result.sync.adapter}`);
393
+ output.info(`Target file : ${result.sync.targetPath}`);
394
+ output.info(`Sync mode : ${result.syncMode}`);
395
+ process.stdout.write(result.diff);
396
+ return;
397
+ }
398
+ output.success(`Synced ${result.skillCount} skill(s) → ${result.sync.adapter}`);
399
+ output.info(`Target file : ${result.sync.targetPath}`);
400
+ output.info(`Sync mode : ${result.syncMode}`);
401
+ if (!result.changed) {
402
+ output.info("No changes to the target file.");
403
+ }
404
+ }
405
+ async function handleRun(positionals, flags, userConfig) {
406
+ const target = positionals[0];
407
+ if (!target) {
408
+ throw new CliError('Provide a target in the format "skill-id:command".', "RUN_REQUIRES_TARGET");
409
+ }
410
+ const parsed = parseSkillCommandReference(target);
411
+ const exitCode = await runSkillScript(parsed.skillId, parsed.command, {
412
+ ...commonOptions(flags, userConfig),
413
+ yes: parseBooleanFlag(flags.yes) || false,
414
+ timeout: parsePositiveInt(asOptionalString(flags.timeout)),
415
+ });
416
+ if (exitCode !== 0) {
417
+ process.exitCode = exitCode;
418
+ }
419
+ }
420
+ async function handleUi(flags, userConfig) {
421
+ const options = commonOptions(flags, userConfig);
422
+ const state = await getInstalledSkills(options);
423
+ const source = await resolveProjectSource(options);
424
+ const catalog = await loadCatalog({ ...source, ...cacheOptions(options) });
425
+ if (catalog.skills.length === 0) {
426
+ output.info("No skills available in the catalog.");
427
+ return;
428
+ }
429
+ const installedIds = Object.keys(state?.installed || {});
430
+ const selection = await runInteractiveUi({ skills: catalog.skills, installedIds });
431
+ if (selection.visibleIds.length === 0) {
432
+ output.info(selection.query
433
+ ? `No skills match the filter "${selection.query}".`
434
+ : "No skills available in the catalog.");
435
+ return;
436
+ }
437
+ const installResult = selection.toInstall.length > 0 ? await installSkills(selection.toInstall, options) : null;
438
+ const removeResult = selection.toRemove.length > 0 ? await removeSkills(selection.toRemove, options) : null;
439
+ if (!installResult && !removeResult) {
440
+ output.info("No changes applied.");
441
+ return;
442
+ }
443
+ output.success("UI summary:");
444
+ if (installResult) {
445
+ output.info(` Installed : ${installResult.installedSkills.map((s) => s.id).join(", ")}`);
446
+ }
447
+ if (removeResult) {
448
+ output.info(` Removed : ${removeResult.removedSkills.join(", ")}`);
449
+ }
450
+ }
451
+ async function handleStatus(flags, userConfig) {
452
+ const state = await getInstalledSkills(commonOptions(flags, userConfig));
453
+ if (!state) {
454
+ output.warn("No local installation found. Run: skillex init");
455
+ return;
456
+ }
457
+ if (flags.json === true) {
458
+ output.info(JSON.stringify(state, null, 2));
459
+ return;
460
+ }
461
+ const installedEntries = Object.entries(state.installed || {});
462
+ output.info(`Sources : ${state.sources.map((source) => `${source.repo}@${source.ref}`).join(", ")}`);
463
+ output.info(`Active adapter: ${state.adapters.active || "(none)"}`);
464
+ output.info(`Auto-sync : ${state.settings.autoSync ? "enabled" : "disabled"}`);
465
+ output.info(`Sync mode : ${state.syncMode || "(none)"}`);
466
+ if (state.adapters.detected.length > 0) {
467
+ output.info(`Detected : ${state.adapters.detected.join(", ")}`);
468
+ }
469
+ if (state.sync) {
470
+ output.info(`Last sync : ${state.sync.adapter} → ${state.sync.targetPath} at ${state.sync.syncedAt}`);
471
+ }
472
+ if (installedEntries.length === 0) {
473
+ output.info("No skills installed.");
474
+ return;
475
+ }
476
+ printTable(installedEntries.map(([id, metadata]) => ({
477
+ id,
478
+ version: metadata.version,
479
+ source: metadata.source || "catalog",
480
+ installedAt: metadata.installedAt,
481
+ })));
482
+ }
483
+ async function handleDoctor(flags, userConfig) {
484
+ const opts = commonOptions(flags, userConfig);
485
+ const cwd = opts.cwd ?? process.cwd();
486
+ const statePaths = getStatePaths(cwd, opts.agentSkillsDir ?? DEFAULT_AGENT_SKILLS_DIR);
487
+ const checks = [];
488
+ // 1. Lockfile
489
+ const state = await getInstalledSkills(opts);
490
+ if (state) {
491
+ checks.push({ name: "lockfile", passed: true, message: `Found at ${statePaths.lockfilePath}` });
492
+ }
493
+ else {
494
+ checks.push({
495
+ name: "lockfile",
496
+ passed: false,
497
+ message: "Lockfile not found",
498
+ hint: "Run: skillex init",
499
+ });
500
+ }
501
+ // 2. Sources configured
502
+ const stateSources = state?.sources ?? [];
503
+ if (stateSources.length > 0) {
504
+ checks.push({
505
+ name: "source",
506
+ passed: true,
507
+ message: stateSources.map((source) => `${source.repo}@${source.ref}`).join(", "),
508
+ });
509
+ }
510
+ else {
511
+ checks.push({
512
+ name: "source",
513
+ passed: false,
514
+ message: "No catalog source configured",
515
+ hint: "Run: skillex init",
516
+ });
517
+ }
518
+ // 3. Adapter detected
519
+ const hasAdapter = Boolean(state?.adapters?.active || (state?.adapters?.detected?.length ?? 0) > 0);
520
+ if (hasAdapter) {
521
+ const adapter = state?.adapters?.active ?? state?.adapters?.detected?.[0];
522
+ checks.push({ name: "adapter", passed: true, message: `Active: ${adapter}` });
523
+ }
524
+ else {
525
+ checks.push({
526
+ name: "adapter",
527
+ passed: false,
528
+ message: "No adapter detected",
529
+ hint: `Use --adapter <id>. Available: ${listAdapters().map((a) => a.id).join(", ")}`,
530
+ });
531
+ }
532
+ // 4. GitHub reachable
533
+ try {
534
+ const response = await fetch("https://api.github.com", {
535
+ method: "HEAD",
536
+ headers: { "User-Agent": "skillex" },
537
+ signal: AbortSignal.timeout(5000),
538
+ });
539
+ if (response.status < 500) {
540
+ checks.push({ name: "github", passed: true, message: "GitHub API is reachable" });
541
+ }
542
+ else {
543
+ checks.push({
544
+ name: "github",
545
+ passed: false,
546
+ message: `GitHub API returned ${response.status}`,
547
+ hint: "Try again in a moment.",
548
+ });
549
+ }
550
+ }
551
+ catch {
552
+ checks.push({
553
+ name: "github",
554
+ passed: false,
555
+ message: "GitHub API is unreachable",
556
+ hint: "Check your internet connection or proxy settings.",
557
+ });
558
+ }
559
+ // 5. GitHub token (warning only — never fails)
560
+ const token = process.env.GITHUB_TOKEN;
561
+ if (token) {
562
+ checks.push({ name: "token", passed: true, message: "GitHub token set (authenticated — 5,000 req/hr)" });
563
+ }
564
+ else {
565
+ checks.push({ name: "token", passed: true, message: "No GitHub token (unauthenticated — 60 req/hr)" });
566
+ }
567
+ // 6. Cache
568
+ const cacheDir = path.join(statePaths.stateDir, ".cache");
569
+ if ((state?.sources?.length ?? 0) > 0) {
570
+ const source = await resolveProjectSource(opts);
571
+ const cacheKey = computeCatalogCacheKey(source);
572
+ const cached = await readCatalogCache(cacheDir, cacheKey);
573
+ if (cached) {
574
+ checks.push({ name: "cache", passed: true, message: "Catalog cache is fresh" });
575
+ }
576
+ else {
577
+ checks.push({ name: "cache", passed: true, message: "No cached catalog (will fetch on next command)" });
578
+ }
579
+ }
580
+ else {
581
+ checks.push({ name: "cache", passed: true, message: "Cache not checked (no repo configured)" });
582
+ }
583
+ const anyFailed = checks.some((c) => !c.passed);
584
+ if (flags.json === true) {
585
+ const jsonResult = {};
586
+ for (const check of checks) {
587
+ jsonResult[check.name] = {
588
+ passed: check.passed,
589
+ message: check.message,
590
+ ...(check.hint ? { hint: check.hint } : {}),
591
+ };
592
+ }
593
+ output.info(JSON.stringify(jsonResult, null, 2));
594
+ }
595
+ else {
596
+ for (const check of checks) {
597
+ const symbol = check.passed ? "✓" : "✗";
598
+ const line = `${symbol} ${check.name.padEnd(10)} ${check.message}`;
599
+ if (check.passed) {
600
+ output.info(line);
601
+ }
602
+ else {
603
+ output.error(line);
604
+ if (check.hint) {
605
+ output.info(` Hint: ${check.hint}`);
606
+ }
607
+ }
608
+ }
609
+ }
610
+ if (anyFailed) {
611
+ process.exitCode = 1;
612
+ }
613
+ }
614
+ async function handleSource(positionals, flags, userConfig) {
615
+ const subcommand = positionals[0];
616
+ const options = commonOptions(flags, userConfig);
617
+ if (!subcommand || subcommand === "help" || flags.help === true) {
618
+ output.info(COMMAND_HELP.source ?? "");
619
+ return;
620
+ }
621
+ if (subcommand === "list") {
622
+ const sources = await listProjectSources(options);
623
+ if (flags.json === true) {
624
+ output.info(JSON.stringify(sources, null, 2));
625
+ return;
626
+ }
627
+ printTable(sources.map((source) => ({
628
+ repo: source.repo,
629
+ ref: source.ref,
630
+ label: source.label || "",
631
+ })));
632
+ return;
633
+ }
634
+ if (subcommand === "add") {
635
+ const repo = positionals[1];
636
+ if (!repo) {
637
+ throw new CliError("Usage: skillex source add <owner/repo>", "SOURCE_ADD_REQUIRES_REPO");
638
+ }
639
+ const lockfile = await addProjectSource({
640
+ repo,
641
+ ref: asOptionalString(flags.ref),
642
+ label: asOptionalString(flags.label),
643
+ }, options);
644
+ output.success(`Added source ${repo}`);
645
+ output.info(`Configured sources: ${lockfile.sources.length}`);
646
+ return;
647
+ }
648
+ if (subcommand === "remove") {
649
+ const repo = positionals[1];
650
+ if (!repo) {
651
+ throw new CliError("Usage: skillex source remove <owner/repo>", "SOURCE_REMOVE_REQUIRES_REPO");
652
+ }
653
+ const lockfile = await removeProjectSource(repo, options);
654
+ output.success(`Removed source ${repo}`);
655
+ output.info(`Configured sources: ${lockfile.sources.length}`);
656
+ return;
657
+ }
658
+ throw new CliError(`Unknown source subcommand: ${subcommand}.`, "SOURCE_UNKNOWN_SUBCOMMAND");
659
+ }
660
+ async function handleConfig(positionals, flags) {
661
+ const subcommand = positionals[0];
662
+ if (!subcommand || subcommand === "help" || flags.help === true) {
663
+ output.info(COMMAND_HELP.config ?? "");
664
+ return;
665
+ }
666
+ if (subcommand === "get") {
667
+ const key = positionals[1];
668
+ if (!key) {
669
+ throw new CliError("Usage: skillex config get <key>", "CONFIG_GET_REQUIRES_KEY");
670
+ }
671
+ if (!VALID_CONFIG_KEYS.includes(key)) {
672
+ throw new CliError(`Unknown config key: ${key}. Valid keys: ${VALID_CONFIG_KEYS.join(", ")}`, "CONFIG_INVALID_KEY");
673
+ }
674
+ const config = await readUserConfig();
675
+ const value = config[key];
676
+ output.info(value !== undefined ? String(value) : "(not set)");
677
+ return;
678
+ }
679
+ if (subcommand === "set") {
680
+ const key = positionals[1];
681
+ const value = positionals[2];
682
+ if (!key || value === undefined) {
683
+ throw new CliError("Usage: skillex config set <key> <value>", "CONFIG_SET_REQUIRES_KEY_VALUE");
684
+ }
685
+ if (!VALID_CONFIG_KEYS.includes(key)) {
686
+ throw new CliError(`Unknown config key: ${key}. Valid keys: ${VALID_CONFIG_KEYS.join(", ")}`, "CONFIG_INVALID_KEY");
687
+ }
688
+ // Coerce boolean keys
689
+ const coerced = key === "disableAutoSync" ? value === "true" : value;
690
+ await writeUserConfig({ [key]: coerced });
691
+ output.success(`Set ${key} = ${coerced} in ~/.askillrc.json`);
692
+ return;
693
+ }
694
+ throw new CliError(`Unknown config subcommand: ${subcommand}. Use "get" or "set".`, "CONFIG_UNKNOWN_SUBCOMMAND");
695
+ }
696
+ // ---------------------------------------------------------------------------
697
+ // Helpers
698
+ // ---------------------------------------------------------------------------
699
+ function resolveAlias(command) {
700
+ const ALIASES = {
701
+ ls: "list",
702
+ rm: "remove",
703
+ uninstall: "remove",
704
+ };
705
+ return command !== undefined ? (ALIASES[command] ?? command) : undefined;
706
+ }
707
+ function commonOptions(flags, userConfig = {}) {
708
+ const options = {
709
+ cwd: path.resolve(asOptionalString(flags.cwd) || process.cwd()),
710
+ };
711
+ const repo = asOptionalString(flags.repo) ?? userConfig.defaultRepo;
712
+ const ref = asOptionalString(flags.ref);
713
+ const catalogPath = asOptionalString(flags["catalog-path"]);
714
+ const catalogUrl = asOptionalString(flags["catalog-url"]);
715
+ const skillsDir = asOptionalString(flags["skills-dir"]);
716
+ const agentSkillsDir = asOptionalString(flags["agent-skills-dir"]);
717
+ const adapter = asOptionalString(flags.adapter) ?? userConfig.defaultAdapter;
718
+ const autoSync = parseBooleanFlag(flags["auto-sync"]) ?? (userConfig.disableAutoSync ? false : undefined);
719
+ const dryRun = parseBooleanFlag(flags["dry-run"]);
720
+ const trust = parseBooleanFlag(flags.trust);
721
+ const yes = parseBooleanFlag(flags.yes);
722
+ const mode = parseSyncMode(asOptionalString(flags.mode));
723
+ const timeout = parsePositiveInt(asOptionalString(flags.timeout));
724
+ const noCache = parseBooleanFlag(flags["no-cache"]);
725
+ if (repo)
726
+ options.repo = repo;
727
+ if (ref)
728
+ options.ref = ref;
729
+ if (catalogPath)
730
+ options.catalogPath = catalogPath;
731
+ if (catalogUrl)
732
+ options.catalogUrl = catalogUrl;
733
+ if (skillsDir)
734
+ options.skillsDir = skillsDir;
735
+ if (agentSkillsDir)
736
+ options.agentSkillsDir = agentSkillsDir;
737
+ if (adapter)
738
+ options.adapter = adapter;
739
+ if (autoSync !== undefined)
740
+ options.autoSync = autoSync;
741
+ if (dryRun !== undefined)
742
+ options.dryRun = dryRun;
743
+ if (trust !== undefined)
744
+ options.trust = trust;
745
+ if (yes !== undefined)
746
+ options.yes = yes;
747
+ if (mode)
748
+ options.mode = mode;
749
+ if (timeout !== undefined)
750
+ options.timeout = timeout;
751
+ if (noCache !== undefined)
752
+ options.noCache = noCache;
753
+ return options;
754
+ }
755
+ /** Returns cache-related options to spread into a loadCatalog call. */
756
+ function cacheOptions(opts) {
757
+ const cwd = opts.cwd ?? process.cwd();
758
+ const stateDir = path.join(cwd, opts.agentSkillsDir ?? DEFAULT_AGENT_SKILLS_DIR);
759
+ return {
760
+ cacheDir: path.join(stateDir, ".cache"),
761
+ ...(opts.noCache !== undefined ? { noCache: opts.noCache } : {}),
762
+ };
763
+ }
764
+ function parseArgs(argv) {
765
+ const flags = {};
766
+ const positionals = [];
767
+ let command;
768
+ for (let index = 0; index < argv.length; index += 1) {
769
+ const token = argv[index];
770
+ if (token === undefined)
771
+ continue;
772
+ if (!command && !token.startsWith("-")) {
773
+ command = token;
774
+ continue;
775
+ }
776
+ if (token === "-v") {
777
+ flags.verbose = true;
778
+ continue;
779
+ }
780
+ if (token.startsWith("--")) {
781
+ const [rawKey, inlineValue] = token.slice(2).split("=", 2);
782
+ if (!rawKey)
783
+ continue;
784
+ if (inlineValue !== undefined) {
785
+ flags[rawKey] = inlineValue;
786
+ continue;
787
+ }
788
+ const next = argv[index + 1];
789
+ if (!next || next.startsWith("-")) {
790
+ flags[rawKey] = true;
791
+ continue;
792
+ }
793
+ flags[rawKey] = next;
794
+ index += 1;
795
+ continue;
796
+ }
797
+ positionals.push(token);
798
+ }
799
+ return { command, positionals, flags };
800
+ }
801
+ function printHelp() {
802
+ output.info(`skillex — AI agent skill manager
803
+
804
+ Commands:
805
+ skillex init [--repo owner/repo] [--ref main]
806
+ skillex list [--json]
807
+ skillex search [query] [--compatibility claude] [--tag git]
808
+ skillex install <skill-id... | owner/repo[@ref]> [--trust]
809
+ skillex install --all
810
+ skillex update [skill-id...]
811
+ skillex remove <skill-id...> aliases: rm, uninstall
812
+ skillex source <add|remove|list> [...]
813
+ skillex sync [--adapter id] [--dry-run] [--mode copy]
814
+ skillex run <skill-id:command> [--yes] [--timeout 30]
815
+ skillex ui
816
+ skillex status [--json]
817
+ skillex doctor [--json]
818
+ skillex config set <key> <value>
819
+ skillex config get <key>
820
+
821
+ Global flags:
822
+ --repo <owner/repo> GitHub repository with skills (default: lgili/skillex)
823
+ --ref <ref> Branch, tag, or commit (default: main)
824
+ --adapter <id> Force adapter: ${listAdapters()
825
+ .map((a) => a.id)
826
+ .join(", ")}
827
+ --cwd <path> Project directory (default: current)
828
+ --verbose, -v Enable debug output
829
+ --json Machine-readable JSON output
830
+ --no-cache Bypass local catalog cache
831
+ --dry-run Preview without writing to disk
832
+
833
+ Run "skillex <command> --help" for command-specific usage.`);
834
+ }
835
+ function printTable(rows) {
836
+ const columns = Object.keys(rows[0]);
837
+ const widths = columns.map((column) => Math.max(column.length, ...rows.map((row) => String(row[column] ?? "").length)));
838
+ output.info(columns.map((column, index) => column.padEnd(widths[index] ?? 0)).join(" "));
839
+ output.info(widths.map((size) => "-".repeat(size)).join(" "));
840
+ for (const row of rows) {
841
+ output.info(columns.map((column, index) => String(row[column] ?? "").padEnd(widths[index] ?? 0)).join(" "));
842
+ }
843
+ }
844
+ function truncate(value, maxLength) {
845
+ if (!value || value.length <= maxLength)
846
+ return value;
847
+ return `${value.slice(0, maxLength - 3)}...`;
848
+ }
849
+ function parseBooleanFlag(value) {
850
+ if (value === undefined)
851
+ return undefined;
852
+ if (value === true)
853
+ return true;
854
+ const normalized = String(value).trim().toLowerCase();
855
+ if (["true", "1", "yes", "on"].includes(normalized))
856
+ return true;
857
+ if (["false", "0", "no", "off"].includes(normalized))
858
+ return false;
859
+ throw new CliError(`Invalid boolean value: ${value}`, "INVALID_BOOLEAN_FLAG");
860
+ }
861
+ function parsePositiveInt(value) {
862
+ if (!value)
863
+ return undefined;
864
+ const parsed = Number.parseInt(value, 10);
865
+ if (!Number.isFinite(parsed) || parsed <= 0) {
866
+ throw new CliError(`Invalid numeric value: ${value}`, "INVALID_NUMBER_FLAG");
867
+ }
868
+ return parsed;
869
+ }
870
+ function parseSyncMode(value) {
871
+ if (!value)
872
+ return undefined;
873
+ if (value === "copy" || value === "symlink")
874
+ return value;
875
+ throw new CliError(`Invalid sync mode: ${value}. Use "symlink" or "copy".`, "INVALID_SYNC_MODE");
876
+ }
877
+ function printAutoSyncResult(result) {
878
+ if (!result)
879
+ return;
880
+ const suffix = result.changed ? "" : " (no changes)";
881
+ output.info(`Auto-sync: ${result.sync.adapter} → ${result.sync.targetPath} [${result.syncMode}]${suffix}`);
882
+ }
883
+ function asOptionalString(value) {
884
+ return typeof value === "string" ? value : undefined;
885
+ }