cue-ai 0.5.0 → 0.7.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.
Files changed (154) hide show
  1. package/README.md +757 -110
  2. package/package.json +5 -5
  3. package/profiles/README.md +12 -12
  4. package/profiles/SCHEMA.md +31 -3
  5. package/profiles/_cache/README.md +1 -1
  6. package/profiles/_types.ts +26 -1
  7. package/profiles/backend/profile.yaml +1 -0
  8. package/profiles/career/profile.yaml +13 -0
  9. package/profiles/core/profile.yaml +76 -9
  10. package/profiles/creative-media/README.md +1 -1
  11. package/profiles/cybersecurity/profile.yaml +779 -756
  12. package/profiles/ecc/profile.yaml +39 -0
  13. package/profiles/event-design/profile.yaml +10 -0
  14. package/profiles/fleet-control/README.md +1 -1
  15. package/profiles/frontend/profile.yaml +14 -0
  16. package/profiles/full/README.md +1 -1
  17. package/profiles/go-api/profile.yaml +1 -0
  18. package/profiles/marketing/profile.yaml +12 -1
  19. package/profiles/predict-everything/profile.yaml +9 -0
  20. package/profiles/rust/profile.yaml +22 -3
  21. package/profiles/rust-cli/profile.yaml +14 -0
  22. package/profiles/rust-core/profile.yaml +35 -0
  23. package/profiles/rust-embedded/profile.yaml +11 -0
  24. package/profiles/rust-ffi/profile.yaml +13 -0
  25. package/profiles/rust-game/profile.yaml +11 -0
  26. package/profiles/rust-wasm/profile.yaml +11 -0
  27. package/profiles/rust-web/profile.yaml +17 -0
  28. package/profiles/schema.json +44 -4
  29. package/profiles/trendradar/profile.yaml +11 -0
  30. package/resources/mcps/README.md +39 -164
  31. package/resources/mcps/configs/claude.sanitized.json +55 -0
  32. package/resources/mcps/configs/claude_runtime.sanitized.json +47 -0
  33. package/resources/skills/README.md +70 -113
  34. package/resources/skills/skills/event-design/wedding-invitations/SKILL.md +43 -0
  35. package/resources/skills/skills/meta/acpx/SKILL.md +78 -0
  36. package/resources/skills/skills/meta/cue-usage/SKILL.md +24 -0
  37. package/resources/skills/skills/meta/profile-fit-monitor/SKILL.md +24 -0
  38. package/resources/skills/skills/predict-everything/mirofish/SKILL.md +75 -0
  39. package/resources/skills/skills/research/trendradar/SKILL.md +88 -0
  40. package/resources/skills/skills/rust/async-tokio/SKILL.md +27 -0
  41. package/resources/skills/skills/rust/axum-api/SKILL.md +38 -0
  42. package/resources/skills/skills/rust/bacon-watch/SKILL.md +24 -0
  43. package/resources/skills/skills/rust/bevy/SKILL.md +43 -0
  44. package/resources/skills/skills/rust/bindgen/SKILL.md +39 -0
  45. package/resources/skills/skills/rust/cargo-audit/SKILL.md +26 -0
  46. package/resources/skills/skills/rust/cargo-basics/SKILL.md +28 -0
  47. package/resources/skills/skills/rust/cargo-chef/SKILL.md +43 -0
  48. package/resources/skills/skills/rust/cargo-edit/SKILL.md +26 -0
  49. package/resources/skills/skills/rust/cargo-expand/SKILL.md +24 -0
  50. package/resources/skills/skills/rust/cargo-flamegraph/SKILL.md +26 -0
  51. package/resources/skills/skills/rust/cargo-fuzz/SKILL.md +34 -0
  52. package/resources/skills/skills/rust/cargo-hack/SKILL.md +26 -0
  53. package/resources/skills/skills/rust/cargo-msrv/SKILL.md +30 -0
  54. package/resources/skills/skills/rust/cargo-mutants/SKILL.md +26 -0
  55. package/resources/skills/skills/rust/cargo-nextest/SKILL.md +24 -0
  56. package/resources/skills/skills/rust/cargo-readme/SKILL.md +36 -0
  57. package/resources/skills/skills/rust/cbindgen/SKILL.md +41 -0
  58. package/resources/skills/skills/rust/chisel-tool/SKILL.md +32 -0
  59. package/resources/skills/skills/rust/clap-cli/SKILL.md +44 -0
  60. package/resources/skills/skills/rust/clippy-and-fmt/SKILL.md +25 -0
  61. package/resources/skills/skills/rust/cross-compile/SKILL.md +26 -0
  62. package/resources/skills/skills/rust/embedded/SKILL.md +33 -0
  63. package/resources/skills/skills/rust/error-handling/SKILL.md +32 -0
  64. package/resources/skills/skills/rust/just-runner/SKILL.md +26 -0
  65. package/resources/skills/skills/rust/mdbook/SKILL.md +25 -0
  66. package/resources/skills/skills/rust/napi-rs/SKILL.md +32 -0
  67. package/resources/skills/skills/rust/no-std/SKILL.md +42 -0
  68. package/resources/skills/skills/rust/property-testing/SKILL.md +35 -0
  69. package/resources/skills/skills/rust/pyo3/SKILL.md +40 -0
  70. package/resources/skills/skills/rust/ratatui-tui/SKILL.md +36 -0
  71. package/resources/skills/skills/rust/release-plz/SKILL.md +27 -0
  72. package/resources/skills/skills/rust/reqwest/SKILL.md +37 -0
  73. package/resources/skills/skills/rust/sccache/SKILL.md +28 -0
  74. package/resources/skills/skills/rust/serde/SKILL.md +30 -0
  75. package/resources/skills/skills/rust/snapshot-testing/SKILL.md +30 -0
  76. package/resources/skills/skills/rust/sqlx-cli/SKILL.md +33 -0
  77. package/resources/skills/skills/rust/tracing/SKILL.md +36 -0
  78. package/resources/skills/skills/rust/typos-spellcheck/SKILL.md +31 -0
  79. package/resources/skills/skills/rust/uniffi/SKILL.md +38 -0
  80. package/resources/skills/skills/rust/wasm-rust/SKILL.md +27 -0
  81. package/resources/skills/skills/security/agentshield/SKILL.md +119 -0
  82. package/src/commands/_index.ts +47 -3
  83. package/src/commands/cli.test.ts +192 -0
  84. package/src/commands/cli.ts +303 -0
  85. package/src/commands/current.ts +1 -1
  86. package/src/commands/debug.test.ts +62 -0
  87. package/src/commands/debug.ts +212 -0
  88. package/src/commands/discover.scoring.test.ts +216 -0
  89. package/src/commands/discover.test.ts +145 -0
  90. package/src/commands/discover.ts +2618 -0
  91. package/src/commands/eval-behavior.test.ts +56 -0
  92. package/src/commands/eval-behavior.ts +189 -0
  93. package/src/commands/eval.test.ts +102 -0
  94. package/src/commands/eval.ts +348 -0
  95. package/src/commands/evolve.ts +291 -0
  96. package/src/commands/failures.test.ts +78 -0
  97. package/src/commands/failures.ts +393 -0
  98. package/src/commands/feedback.ts +219 -0
  99. package/src/commands/init.ts +26 -0
  100. package/src/commands/launch.e2e.test.ts +9 -1
  101. package/src/commands/launch.ts +174 -11
  102. package/src/commands/lint-skill.ts +157 -0
  103. package/src/commands/marketplace.ts +763 -2
  104. package/src/commands/new.ts +1 -1
  105. package/src/commands/optimizer.ts +92 -28
  106. package/src/commands/profile-draft-skill.test.ts +96 -0
  107. package/src/commands/profile-draft-skill.ts +287 -0
  108. package/src/commands/profile-evolve.test.ts +126 -0
  109. package/src/commands/profile-evolve.ts +0 -0
  110. package/src/commands/profile-suggest.ts +223 -0
  111. package/src/commands/profile.ts +41 -0
  112. package/src/commands/quick.ts +2 -17
  113. package/src/commands/scan.ts +2 -2
  114. package/src/commands/score.ts +1 -1
  115. package/src/commands/share.ts +1 -1
  116. package/src/commands/sources.ts +2 -2
  117. package/src/commands/submit-profile.ts +262 -0
  118. package/src/commands/upgrade.ts +1 -1
  119. package/src/commands/use.ts +35 -5
  120. package/src/commands/validate.ts +1 -1
  121. package/src/index.ts +66 -0
  122. package/src/lib/analytics.ts +48 -2
  123. package/src/lib/claude-binary.ts +39 -0
  124. package/src/lib/cli-extractor.ts +77 -0
  125. package/src/lib/cluster-skills.test.ts +268 -0
  126. package/src/lib/cluster-skills.ts +290 -0
  127. package/src/lib/credentials-sync.test.ts +208 -0
  128. package/src/lib/credentials-sync.ts +205 -0
  129. package/src/lib/mcp-materializer.test.ts +1 -1
  130. package/src/lib/persona-playbooks.test.ts +111 -0
  131. package/src/lib/pr-poster.test.ts +243 -0
  132. package/src/lib/pr-poster.ts +285 -0
  133. package/src/lib/pr-throttle.test.ts +148 -0
  134. package/src/lib/pr-throttle.ts +209 -0
  135. package/src/lib/profile-generator.test.ts +1 -1
  136. package/src/lib/profile-generator.ts +2 -2
  137. package/src/lib/profile-linter.test.ts +6 -3
  138. package/src/lib/profile-linter.ts +71 -8
  139. package/src/lib/profile-loader.test.ts +1 -1
  140. package/src/lib/profile-loader.ts +16 -0
  141. package/src/lib/resolver-local.test.ts +1 -1
  142. package/src/lib/resolver-npx.test.ts +76 -1
  143. package/src/lib/resolver-npx.ts +35 -3
  144. package/src/lib/resolver-plugins.test.ts +1 -1
  145. package/src/lib/runtime-materializer.test.ts +191 -7
  146. package/src/lib/runtime-materializer.ts +310 -42
  147. package/src/lib/scan-plugins.test.ts +1 -1
  148. package/src/lib/skill-linter.test.ts +174 -0
  149. package/src/lib/skill-linter.ts +507 -0
  150. package/src/lib/skill-subset.test.ts +95 -0
  151. package/src/lib/skill-subset.ts +166 -0
  152. package/src/lib/star-prompt.ts +1 -1
  153. package/src/lib/uvx-installer.test.ts +229 -0
  154. package/src/lib/uvx-installer.ts +278 -0
@@ -11,7 +11,7 @@
11
11
  * list-tools [connection] — list tools from connected MCPs
12
12
  */
13
13
 
14
- import { spawnSync } from "node:child_process";
14
+ import { spawn, spawnSync } from "node:child_process";
15
15
  import { resolve, dirname, join } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { readFileSync, existsSync } from "node:fs";
@@ -21,7 +21,7 @@ import { resolveProfileForCwd } from "../lib/cwd-resolver";
21
21
  const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
22
22
  const PROFILES_DIR = process.env.CUE_PROFILES_DIR ?? join(REPO_ROOT, "profiles");
23
23
  const REGISTRY_PATH = join(REPO_ROOT, "docs", "registry", "index.json");
24
- const REGISTRY_URL = "https://recodeee.github.io/cue/registry/index.json";
24
+ const REGISTRY_URL = "https://opencue.github.io/cue/registry/index.json";
25
25
 
26
26
  // ---------------------------------------------------------------------------
27
27
  // Registry
@@ -296,6 +296,727 @@ async function cmdFindTools(query: string, json: boolean): Promise<number> {
296
296
  // Router
297
297
  // ---------------------------------------------------------------------------
298
298
 
299
+ // ---------------------------------------------------------------------------
300
+ // discover — uses GitHub Code Search to find repos containing SKILL.md files.
301
+ // Previously used loose text search ("claude skill agent"), which surfaced
302
+ // awesome-lists and meta-repos that don't actually contain SKILL.md.
303
+ // ---------------------------------------------------------------------------
304
+
305
+ interface SkillRepo {
306
+ repo: string; // owner/name
307
+ paths: string[]; // SKILL.md paths inside the repo (from code search)
308
+ stars: number; // populated by enrichStars
309
+ description: string; // populated by enrichStars
310
+ // populated when --cli-aware fetches body content
311
+ meta?: { description: string; domain: string; tags: string[]; categories: string[]; name: string };
312
+ }
313
+
314
+ // Words that aren't useful as profile-match keywords.
315
+ const STOPWORDS = new Set([
316
+ "the","a","an","and","or","but","for","with","of","to","in","on","by","from",
317
+ "is","are","was","were","be","been","being","this","that","these","those",
318
+ "it","its","as","at","if","not","no","do","does","cli","tool","tools",
319
+ "skill","skills","claude","anthropic","code","using","run","runs","make",
320
+ "your","you","get","one","two","three","new","use","build","build-",
321
+ ]);
322
+
323
+ function tokenize(s: string): string[] {
324
+ return s
325
+ .toLowerCase()
326
+ .split(/[^a-z0-9]+/)
327
+ .filter((w) => w.length >= 3 && !STOPWORDS.has(w));
328
+ }
329
+
330
+ /**
331
+ * Categories inferred from SKILL.md paths inside the repo. The directory
332
+ * name immediately containing the SKILL.md file is usually a category tag
333
+ * (e.g. `skills/marketing/seo-audit/SKILL.md` → "marketing", "seo-audit").
334
+ */
335
+ function categoriesFromPaths(paths: string[]): string[] {
336
+ const seen = new Set<string>();
337
+ for (const p of paths) {
338
+ const parts = p.split("/").filter(Boolean);
339
+ for (const part of parts) {
340
+ const lower = part.toLowerCase();
341
+ if (lower === "skill.md" || lower === "skills" || lower === "src" || lower === "docs") continue;
342
+ if (!STOPWORDS.has(lower)) seen.add(lower);
343
+ }
344
+ }
345
+ return [...seen];
346
+ }
347
+
348
+ /**
349
+ * Build a keyword index for each cue profile. The keywords come from the
350
+ * profile name, its `inherits` ancestor names, and the first path-segment
351
+ * of every local skill (e.g. "security", "marketing", "medusa").
352
+ */
353
+ async function buildProfileKeywordIndex(): Promise<Map<string, Set<string>>> {
354
+ const { loadProfile, listProfiles } = await import("../lib/profile-loader");
355
+ const out = new Map<string, Set<string>>();
356
+ for (const name of await listProfiles()) {
357
+ try {
358
+ const p = await loadProfile(name);
359
+ const kw = new Set<string>();
360
+ kw.add(name.toLowerCase());
361
+ for (const t of tokenize(p.description ?? "")) kw.add(t);
362
+ for (const s of p.skills.local) {
363
+ const seg = s.id.split("/")[0];
364
+ if (seg && seg !== "*") kw.add(seg.toLowerCase());
365
+ }
366
+ for (const m of p.mcps) kw.add(m.id.toLowerCase());
367
+ out.set(name, kw);
368
+ } catch {}
369
+ }
370
+ return out;
371
+ }
372
+
373
+ interface ProfileFit { profile: string; score: number }
374
+
375
+ /** Score a repo's keyword set against each profile; return top matches. */
376
+ function findBestProfiles(repoKeywords: Set<string>, index: Map<string, Set<string>>, top = 2): ProfileFit[] {
377
+ const fits: ProfileFit[] = [];
378
+ for (const [profile, kw] of index) {
379
+ let score = 0;
380
+ for (const w of repoKeywords) if (kw.has(w)) score++;
381
+ if (score > 0) fits.push({ profile, score });
382
+ }
383
+ fits.sort((a, b) => b.score - a.score);
384
+ return fits.slice(0, top);
385
+ }
386
+
387
+ /** Async spawn → Promise<{ stdout, status }>. Lets us run gh calls in parallel. */
388
+ function ghAsync(args: string[], timeoutMs = 8000): Promise<{ stdout: string; status: number }> {
389
+ return new Promise((resolve) => {
390
+ const child = spawn("gh", args, { stdio: ["ignore", "pipe", "pipe"] });
391
+ let stdout = "";
392
+ let resolved = false;
393
+ const done = (status: number) => { if (!resolved) { resolved = true; resolve({ stdout, status }); } };
394
+ child.stdout.on("data", (c) => { stdout += c.toString(); });
395
+ child.on("close", (code) => done(code ?? 1));
396
+ child.on("error", () => done(1));
397
+ setTimeout(() => { try { child.kill("SIGKILL"); } catch {} done(124); }, timeoutMs);
398
+ });
399
+ }
400
+
401
+ /** Run async tasks with a concurrency cap. */
402
+ async function pMap<T, R>(items: T[], concurrency: number, fn: (item: T, i: number) => Promise<R>): Promise<R[]> {
403
+ const out: R[] = new Array(items.length);
404
+ let cursor = 0;
405
+ const worker = async () => {
406
+ while (true) {
407
+ const i = cursor++;
408
+ if (i >= items.length) return;
409
+ out[i] = await fn(items[i]!, i);
410
+ }
411
+ };
412
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
413
+ return out;
414
+ }
415
+
416
+ /**
417
+ * Find repos that demonstrably contain at least one SKILL.md file. Returns
418
+ * a map keyed by `owner/name` with the file paths discovered per repo.
419
+ * Higher `searchLimit` = more raw file matches = more unique repos found.
420
+ */
421
+ async function discoverSkillReposViaCodeSearch(searchLimit: number): Promise<Map<string, SkillRepo>> {
422
+ const queries = [
423
+ ["--filename", "SKILL.md"],
424
+ ["claude", "--filename", "SKILL.md"],
425
+ ["anthropic", "--filename", "SKILL.md"],
426
+ ];
427
+
428
+ const byRepo = new Map<string, SkillRepo>();
429
+ for (const extra of queries) {
430
+ const res = await ghAsync(
431
+ ["search", "code", ...extra, "--limit", String(searchLimit), "--json", "path,repository"],
432
+ 30000,
433
+ );
434
+ if (res.status !== 0 || !res.stdout) continue;
435
+ try {
436
+ const matches = JSON.parse(res.stdout) as Array<{ path: string; repository: { nameWithOwner: string } }>;
437
+ for (const m of matches) {
438
+ const repo = m.repository?.nameWithOwner;
439
+ if (!repo) continue;
440
+ const entry = byRepo.get(repo) ?? { repo, paths: [], stars: 0, description: "" };
441
+ if (m.path.endsWith("SKILL.md") && !entry.paths.includes(m.path)) entry.paths.push(m.path);
442
+ byRepo.set(repo, entry);
443
+ }
444
+ } catch { /* skip */ }
445
+ }
446
+ return byRepo;
447
+ }
448
+
449
+ /** Fill in stars + description via repo metadata API. Concurrent. */
450
+ async function enrichStars(repos: SkillRepo[], concurrency = 6): Promise<void> {
451
+ await pMap(repos, concurrency, async (r) => {
452
+ const res = await ghAsync(
453
+ ["api", `repos/${r.repo}`, "--jq", "{stars: .stargazers_count, description}"],
454
+ 5000,
455
+ );
456
+ if (res.status === 0) {
457
+ try {
458
+ const meta = JSON.parse(res.stdout) as { stars?: number; description?: string };
459
+ r.stars = meta.stars ?? 0;
460
+ r.description = meta.description ?? "";
461
+ } catch {}
462
+ }
463
+ });
464
+ }
465
+
466
+ /** Fetch and concatenate up to N SKILL.md bodies from a repo (paths already known). */
467
+ async function fetchSkillContents(repo: string, paths: string[], cap = 8): Promise<string> {
468
+ const slice = paths.slice(0, cap);
469
+ const results = await pMap(slice, 4, async (path) => {
470
+ const res = await ghAsync(
471
+ ["api", `repos/${repo}/contents/${path}`, "-H", "Accept: application/vnd.github.raw"],
472
+ 5000,
473
+ );
474
+ return res.status === 0 ? res.stdout : "";
475
+ });
476
+ return results.filter(Boolean).join("\n\n");
477
+ }
478
+
479
+ // ---------------------------------------------------------------------------
480
+ // open-pr — actually post a meaningful PR to a target repo. Safe-by-default:
481
+ // dry-run unless --post, interactive confirmation unless --yes, and every
482
+ // preflight check (throttle, opt-out, daily cap) runs *before* any GitHub
483
+ // mutation. Caps at 1 repo per invocation so a typo can't mass-spam.
484
+ // ---------------------------------------------------------------------------
485
+
486
+ async function cmdOpenPr(repo: string, json: boolean, post: boolean, yes: boolean, optInOnly: boolean): Promise<number> {
487
+ if (!repo) {
488
+ process.stderr.write("Usage: cue marketplace open-pr <owner/name> [--post] [--yes] [--opt-in-only]\n");
489
+ return 1;
490
+ }
491
+
492
+ const { lint, applyFixes, buildPrBody } = await import("../lib/skill-linter");
493
+ const throttle = await import("../lib/pr-throttle");
494
+ const poster = await import("../lib/pr-poster");
495
+
496
+ // Preflight #0: gh CLI authenticated? Bail loudly before doing anything else.
497
+ if (post) {
498
+ const auth = await poster.defaultRunner.run("gh", ["auth", "status"], { timeoutMs: 5000 });
499
+ if (auth.status !== 0) {
500
+ process.stderr.write(`\n${red("✗ gh not authenticated")}\n`);
501
+ process.stderr.write(` Run ${bold("gh auth login")} first, then retry.\n\n`);
502
+ return 1;
503
+ }
504
+ }
505
+
506
+ let db = throttle.loadDb();
507
+
508
+ // Preflight #1: per-repo cooldown / opt-out cache
509
+ const throttleReason = throttle.isThrottled(db, repo);
510
+ if (throttleReason) {
511
+ if (json) process.stdout.write(JSON.stringify({ skipped: true, reason: throttleReason }) + "\n");
512
+ else process.stderr.write(`\n${red("skipped:")} ${repo} — ${throttleReason}\n`);
513
+ return 0;
514
+ }
515
+
516
+ // Preflight #2: daily cap
517
+ const cap = throttle.canPostMore(db);
518
+ if (!cap.ok) {
519
+ process.stderr.write(`\n${red("daily cap reached")}: ${cap.cap - cap.remaining}/${cap.cap} PRs in last 24h\n`);
520
+ return 1;
521
+ }
522
+
523
+ // Preflight #3: opt-out / opt-in marker check (network)
524
+ // - Default: post unless README has the opt-out marker.
525
+ // - --opt-in-only: only post if README has the opt-in marker. The opt-out
526
+ // marker still wins if present (a repo with both is treated as opted-out).
527
+ process.stderr.write(`🔎 Checking ${repo} README for cue markers (mode=${optInOnly ? "opt-in-only" : "default"})...\n`);
528
+ const optedOut = await poster.checkOptOutMarker(poster.defaultRunner, repo, throttle.OPT_OUT_MARKER);
529
+ if (optedOut === true) {
530
+ db = throttle.recordOptOut(db, repo);
531
+ throttle.saveDb(db);
532
+ if (json) process.stdout.write(JSON.stringify({ skipped: true, reason: "opt-out marker found" }) + "\n");
533
+ else process.stderr.write(` ${yellow("opted out")}: README has ${throttle.OPT_OUT_MARKER} — recorded permanently.\n`);
534
+ return 0;
535
+ }
536
+ if (optInOnly) {
537
+ const optedIn = await poster.checkOptOutMarker(poster.defaultRunner, repo, throttle.OPT_IN_MARKER);
538
+ if (optedIn !== true) {
539
+ db = throttle.recordSkipped(db, repo, `--opt-in-only mode: README missing ${throttle.OPT_IN_MARKER}`);
540
+ throttle.saveDb(db);
541
+ if (json) process.stdout.write(JSON.stringify({ skipped: true, reason: "no opt-in marker" }) + "\n");
542
+ else process.stderr.write(` ${dim("skipped")}: --opt-in-only is set and ${repo} hasn't opted in.\n`);
543
+ return 0;
544
+ }
545
+ }
546
+ if (optedOut === null) {
547
+ process.stderr.write(` ${dim("(could not fetch README — continuing)")}\n`);
548
+ }
549
+
550
+ // Fetch SKILL.md files
551
+ process.stderr.write(`🔎 Fetching SKILL.md files from ${repo}...\n`);
552
+ const tree = await ghAsync(
553
+ ["api", `repos/${repo}/git/trees/HEAD?recursive=1`, "--jq", '.tree[] | select(.path | endswith("SKILL.md")) | .path'],
554
+ 10000,
555
+ );
556
+ if (tree.status !== 0) {
557
+ process.stderr.write(` could not list tree — is repo public + gh authed?\n`);
558
+ return 1;
559
+ }
560
+ const paths = tree.stdout.split("\n").filter(Boolean).slice(0, 8);
561
+ if (paths.length === 0) {
562
+ process.stderr.write(` no SKILL.md files found\n`);
563
+ return 1;
564
+ }
565
+
566
+ // Lint + collect changes
567
+ const changes: poster.FileChange[] = [];
568
+ const allFixedRules = new Set<string>();
569
+ const allLeftover: Array<{ rule: string; severity: string; message: string }> = [];
570
+
571
+ for (const path of paths) {
572
+ const r = await ghAsync(["api", `repos/${repo}/contents/${path}`, "-H", "Accept: application/vnd.github.raw"], 5000);
573
+ if (r.status !== 0) continue;
574
+ const before = r.stdout;
575
+ const { fixed, applied } = applyFixes(before);
576
+ if (before !== fixed) {
577
+ changes.push({ path, before, after: fixed });
578
+ const beforeDiags = lint(before).diagnostics;
579
+ for (const rule of applied) {
580
+ if (beforeDiags.some((d) => d.rule === rule)) allFixedRules.add(rule);
581
+ }
582
+ }
583
+ for (const d of lint(fixed).diagnostics) allLeftover.push({ rule: d.rule, severity: d.severity, message: d.message });
584
+ }
585
+
586
+ if (changes.length === 0) {
587
+ db = throttle.recordSkipped(db, repo, "no auto-fixable issues found");
588
+ throttle.saveDb(db);
589
+ if (json) process.stdout.write(JSON.stringify({ skipped: true, reason: "no auto-fixable issues" }) + "\n");
590
+ else process.stderr.write(`\n${dim("nothing to fix")} — every SKILL.md is already clean (or only has non-fixable flags). Recorded as skipped so we won't recheck soon.\n`);
591
+ return 0;
592
+ }
593
+
594
+ // Build PR body using the first changed file as exemplar
595
+ const primary = changes[0]!;
596
+ const fixedDiags = lint(primary.before).diagnostics.filter((d) => [...allFixedRules].includes(d.rule));
597
+ const leftoverDiags = lint(primary.after).diagnostics;
598
+ const { title, body } = buildPrBody({
599
+ repo,
600
+ files: changes.map((c) => ({ path: c.path, before: c.before, after: c.after, fixedRules: [...allFixedRules] })),
601
+ diagnosticsFixed: fixedDiags,
602
+ diagnosticsLeft: leftoverDiags,
603
+ });
604
+
605
+ if (json && !post) {
606
+ process.stdout.write(JSON.stringify({ dryRun: true, repo, title, body, files: changes.map((c) => c.path), fixedRules: [...allFixedRules] }, null, 2) + "\n");
607
+ return 0;
608
+ }
609
+
610
+ process.stdout.write(`\n${bold("Repo:")} ${repo}\n`);
611
+ process.stdout.write(`${bold("Files cue would change:")} ${changes.length}/${paths.length}\n`);
612
+ for (const c of changes) process.stdout.write(` ${green("✓")} ${c.path}\n`);
613
+ process.stdout.write(`${bold("Rules auto-fixed:")} ${[...allFixedRules].join(", ")}\n\n`);
614
+ process.stdout.write(`${bold("PR title:")} ${title}\n`);
615
+ process.stdout.write(`${bold("PR body preview (first 60 lines):")}\n${dim("─".repeat(72))}\n`);
616
+ const bodyLines = body.split("\n").slice(0, 60);
617
+ process.stdout.write(bodyLines.join("\n") + (body.split("\n").length > 60 ? "\n... [body truncated]" : "") + "\n");
618
+ process.stdout.write(`${dim("─".repeat(72))}\n\n`);
619
+
620
+ if (!post) {
621
+ process.stdout.write(`${yellow("[dry-run]")} no PR posted. Re-run with ${bold("--post --yes")} to actually open it.\n`);
622
+ process.stdout.write(`${dim(`Daily cap: ${cap.remaining}/${cap.cap} PRs remaining today.`)}\n\n`);
623
+ return 0;
624
+ }
625
+
626
+ // Interactive confirm unless --yes
627
+ if (!yes) {
628
+ process.stdout.write(`Post this PR to ${bold(repo)}? [y/N] `);
629
+ const answer = await readLine();
630
+ if (!/^y(es)?$/i.test(answer.trim())) {
631
+ process.stdout.write(`${dim("aborted (not recorded).")}\n`);
632
+ return 0;
633
+ }
634
+ }
635
+
636
+ process.stderr.write(`\n${bold("→")} Forking, branching, pushing, and opening PR...\n\n`);
637
+ const result = await poster.postPrToRepo({
638
+ upstream: repo,
639
+ changes,
640
+ prTitle: title,
641
+ prBody: body,
642
+ });
643
+
644
+ if (!result.ok) {
645
+ process.stderr.write(`${red("✗ failed at step:")} ${result.step} — ${result.error}\n`);
646
+ if (result.fork) process.stderr.write(` ${dim("(fork at " + result.fork + " left in place for inspection)")}\n`);
647
+ return 1;
648
+ }
649
+
650
+ db = throttle.recordOpened(db, {
651
+ repo,
652
+ rulesFixed: [...allFixedRules],
653
+ prNumber: result.prNumber,
654
+ prUrl: result.prUrl,
655
+ fork: result.fork,
656
+ branch: result.branch,
657
+ });
658
+ throttle.saveDb(db);
659
+
660
+ process.stdout.write(`\n${green("✓ PR opened:")} ${result.prUrl}\n`);
661
+ process.stdout.write(` fork: ${result.fork}\n branch: ${result.branch}\n files: ${result.filesChanged.join(", ")}\n`);
662
+ process.stdout.write(`\n${dim(`Daily count: ${throttle.todayCount(db)}/${throttle.canPostMore(db).cap}.`)}\n\n`);
663
+ return 0;
664
+ }
665
+
666
+ /** Minimal stdin line reader for the confirm prompt. */
667
+ function readLine(): Promise<string> {
668
+ return new Promise((resolve) => {
669
+ let buf = "";
670
+ const onData = (chunk: Buffer) => {
671
+ buf += chunk.toString();
672
+ if (buf.includes("\n")) {
673
+ process.stdin.removeListener("data", onData);
674
+ process.stdin.pause();
675
+ resolve(buf.split("\n")[0]!);
676
+ }
677
+ };
678
+ process.stdin.resume();
679
+ process.stdin.on("data", onData);
680
+ });
681
+ }
682
+
683
+ // ---------------------------------------------------------------------------
684
+ // cleanup-forks — poll PR state for everything we've ever opened; delete the
685
+ // fork once the PR is merged or closed.
686
+ // ---------------------------------------------------------------------------
687
+
688
+ async function cmdCleanupForks(json: boolean, dryRun: boolean): Promise<number> {
689
+ const throttle = await import("../lib/pr-throttle");
690
+ const poster = await import("../lib/pr-poster");
691
+
692
+ let db = throttle.loadDb();
693
+ // Candidates: entries we opened (have a prNumber) that still claim "open" state
694
+ // OR were already marked merged/closed but still have a fork to delete.
695
+ const candidates = db.history.filter((e) => e.fork && !e.cleanedAt && e.prNumber);
696
+
697
+ if (candidates.length === 0) {
698
+ if (json) process.stdout.write("[]\n");
699
+ else process.stdout.write(" no forks to consider for cleanup.\n");
700
+ return 0;
701
+ }
702
+
703
+ process.stderr.write(`🧹 Checking PR state for ${candidates.length} fork(s)...\n`);
704
+
705
+ const results: Array<{ repo: string; fork: string; prNumber: number; state: string; action: string; error?: string }> = [];
706
+ for (const e of candidates) {
707
+ const state = await poster.fetchPrState(poster.defaultRunner, e.repo, e.prNumber!);
708
+ let action = "skip";
709
+ let error: string | undefined;
710
+ if (state === "merged" || state === "closed") {
711
+ if (dryRun) {
712
+ action = "would-delete";
713
+ } else {
714
+ const del = await poster.deleteFork(poster.defaultRunner, e.fork!);
715
+ if ("error" in del) {
716
+ action = "delete-failed";
717
+ error = del.error;
718
+ } else {
719
+ action = "deleted";
720
+ db = throttle.updateEntryState(db, { repo: e.repo, prNumber: e.prNumber }, state, {
721
+ fork: undefined,
722
+ cleanedAt: new Date().toISOString(),
723
+ });
724
+ }
725
+ }
726
+ } else if (state === "open") {
727
+ action = "still-open";
728
+ } else {
729
+ action = "unknown-state";
730
+ }
731
+ results.push({ repo: e.repo, fork: e.fork!, prNumber: e.prNumber!, state, action, error });
732
+ }
733
+
734
+ if (!dryRun) throttle.saveDb(db);
735
+
736
+ if (json) {
737
+ process.stdout.write(JSON.stringify(results, null, 2) + "\n");
738
+ return 0;
739
+ }
740
+
741
+ const counts = { deleted: 0, wouldDelete: 0, stillOpen: 0, failed: 0, other: 0 };
742
+ for (const r of results) {
743
+ const col = r.action === "deleted" ? green : r.action === "delete-failed" ? red : r.action === "still-open" ? dim : yellow;
744
+ process.stdout.write(` ${col(r.action.padEnd(15))} ${r.repo.padEnd(40)} PR #${r.prNumber} ${dim(`(${r.state})`)}\n`);
745
+ if (r.error) process.stdout.write(` ${red("error:")} ${r.error}\n`);
746
+ if (r.action === "deleted") counts.deleted++;
747
+ else if (r.action === "would-delete") counts.wouldDelete++;
748
+ else if (r.action === "still-open") counts.stillOpen++;
749
+ else if (r.action === "delete-failed") counts.failed++;
750
+ else counts.other++;
751
+ }
752
+ process.stdout.write(`\n ${bold("Summary:")} ${counts.deleted} deleted, ${counts.wouldDelete} would-delete, ${counts.stillOpen} still-open, ${counts.failed} failed.\n`);
753
+ if (dryRun) process.stdout.write(` ${dim("(--dry-run — no forks were actually deleted; rerun without --dry-run to clean up)")}\n`);
754
+ process.stdout.write("\n");
755
+ return 0;
756
+ }
757
+
758
+ /**
759
+ * Preview the PR cue would open against `repo`. Fetches every SKILL.md from
760
+ * the repo (tree-based, like discover does), lints + auto-fixes locally, and
761
+ * emits the PR title + body without touching anything on GitHub.
762
+ *
763
+ * No fork. No branch. No push. No PR. Safe to run on strangers' repos.
764
+ */
765
+ async function cmdPrPreview(repo: string, json: boolean): Promise<number> {
766
+ const { lint, applyFixes, buildPrBody } = await import("../lib/skill-linter");
767
+
768
+ process.stderr.write(`🔎 Fetching SKILL.md files from ${repo}...\n`);
769
+ const tree = await ghAsync(
770
+ ["api", `repos/${repo}/git/trees/HEAD?recursive=1`, "--jq", '.tree[] | select(.path | endswith("SKILL.md")) | .path'],
771
+ 10000,
772
+ );
773
+ if (tree.status !== 0) {
774
+ process.stderr.write(` Could not list ${repo} (is the repo public? is gh authed?)\n`);
775
+ return 1;
776
+ }
777
+ const paths = tree.stdout.split("\n").filter(Boolean).slice(0, 8);
778
+ if (paths.length === 0) {
779
+ process.stderr.write(` No SKILL.md files found in ${repo}.\n`);
780
+ return 1;
781
+ }
782
+ process.stderr.write(` Found ${paths.length} SKILL.md file(s). Linting + computing fixes locally...\n`);
783
+
784
+ // Fetch + lint each file in parallel, collect diff + diagnostics
785
+ interface FileReport {
786
+ path: string;
787
+ before: string;
788
+ after: string;
789
+ fixedRules: string[];
790
+ leftover: ReturnType<typeof lint>["diagnostics"];
791
+ }
792
+ const reports: FileReport[] = await pMap(paths, 4, async (path): Promise<FileReport> => {
793
+ const res = await ghAsync(
794
+ ["api", `repos/${repo}/contents/${path}`, "-H", "Accept: application/vnd.github.raw"],
795
+ 5000,
796
+ );
797
+ const before = res.status === 0 ? res.stdout : "";
798
+ const beforeDiags = lint(before).diagnostics;
799
+ const { fixed, applied } = applyFixes(before);
800
+ const afterDiags = lint(fixed).diagnostics;
801
+ const fixedRules = [...new Set(applied)].filter((r) => beforeDiags.some((d) => d.rule === r));
802
+ return { path, before, after: fixed, fixedRules, leftover: afterDiags };
803
+ });
804
+
805
+ // Build one combined PR body (per-file sections)
806
+ const allFixed = reports.flatMap((r) => r.fixedRules.map((rule) => ({ path: r.path, rule })));
807
+ const allLeftover = reports.flatMap((r) => r.leftover.map((d) => ({ path: r.path, ...d })));
808
+
809
+ // PR body: passes every file so the diff blocks come out per-file.
810
+ const primary = reports.find((r) => r.fixedRules.length > 0) ?? reports[0]!;
811
+ const allFixedRulesPrev = [...new Set(reports.flatMap((r) => r.fixedRules))];
812
+ const { title, body } = buildPrBody({
813
+ repo,
814
+ files: reports.map((r) => ({ path: r.path, before: r.before, after: r.after, fixedRules: r.fixedRules })),
815
+ diagnosticsFixed: lint(primary.before).diagnostics.filter((d) => allFixedRulesPrev.includes(d.rule)),
816
+ diagnosticsLeft: primary.leftover,
817
+ });
818
+
819
+ if (json) {
820
+ process.stdout.write(JSON.stringify({
821
+ repo,
822
+ paths,
823
+ title,
824
+ body,
825
+ files: reports.map((r) => ({
826
+ path: r.path,
827
+ wouldChange: r.before !== r.after,
828
+ fixedRules: r.fixedRules,
829
+ leftover: r.leftover.map((d) => ({ rule: d.rule, severity: d.severity, message: d.message })),
830
+ })),
831
+ }, null, 2) + "\n");
832
+ return 0;
833
+ }
834
+
835
+ process.stdout.write(`\n${bold("Repo:")} ${repo}\n${bold("Files scanned:")} ${paths.length}\n\n`);
836
+
837
+ // Per-file diff summary
838
+ for (const r of reports) {
839
+ const changed = r.before !== r.after;
840
+ const marker = changed ? green("✓") : dim("·");
841
+ process.stdout.write(` ${marker} ${r.path}\n`);
842
+ if (r.fixedRules.length > 0) {
843
+ process.stdout.write(` ${green(`would fix: ${r.fixedRules.join(", ")}`)}\n`);
844
+ }
845
+ for (const d of r.leftover.slice(0, 3)) {
846
+ const sev = d.severity === "error" ? red(d.rule) : d.severity === "warning" ? yellow(d.rule) : dim(d.rule);
847
+ process.stdout.write(` ${dim("→")} ${sev} ${d.message.slice(0, 80)}${d.message.length > 80 ? "…" : ""}\n`);
848
+ }
849
+ }
850
+
851
+ process.stdout.write(`\n${bold("PR title:")}\n ${title}\n\n${bold("PR body preview:")}\n${"─".repeat(72)}\n${body}\n${"─".repeat(72)}\n`);
852
+ process.stdout.write(`\n${yellow("[preview only]")} No fork, branch, push, or PR was created.\n`);
853
+ process.stdout.write(`To actually post a PR you'd need: ${dim("gh repo fork " + repo + " --remote=false")}, then commit the after-text and ${dim("gh pr create")}.\n`);
854
+ process.stdout.write(`That flow is deliberately not automated yet — auto-posting needs throttling + an opt-out registry.\n\n`);
855
+ return 0;
856
+ }
857
+
858
+ // Small color helpers reused from earlier blocks (re-declared to keep
859
+ // cmdPrPreview self-contained at the top of the file).
860
+ const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
861
+ const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
862
+ const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
863
+ const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
864
+ const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
865
+
866
+ async function cmdDiscover(
867
+ profileFilter: string,
868
+ json: boolean,
869
+ cliAware: boolean,
870
+ limit: number,
871
+ includeEmpty: boolean,
872
+ ): Promise<number> {
873
+ void profileFilter; // currently unused; reserved for per-profile install hints
874
+
875
+ process.stderr.write("🔍 Searching GitHub Code Search for repos containing SKILL.md...\n");
876
+
877
+ // Cast a wide net via code search (per-query limit), then trim by sort + limit.
878
+ const searchLimit = Math.max(100, limit * 4);
879
+ const byRepo = await discoverSkillReposViaCodeSearch(searchLimit);
880
+ if (byRepo.size === 0) {
881
+ process.stderr.write(" No SKILL.md files found via code search. Is `gh` authed?\n");
882
+ return 1;
883
+ }
884
+
885
+ // Filter to repos that have at least one direct SKILL.md path (drops noise
886
+ // where code search matched a substring elsewhere).
887
+ let candidates = [...byRepo.values()].filter((r) => includeEmpty || r.paths.length > 0);
888
+ process.stderr.write(` Found ${candidates.length} unique repos with SKILL.md files.\n`);
889
+
890
+ // Sort first by # of SKILL.md files (lots = real skill collection), then truncate
891
+ // before enriching stars so we don't fetch metadata for everything.
892
+ candidates.sort((a, b) => b.paths.length - a.paths.length);
893
+ candidates = candidates.slice(0, limit);
894
+
895
+ process.stderr.write(` Enriching star counts for top ${candidates.length}...\n`);
896
+ await enrichStars(candidates);
897
+ candidates.sort((a, b) => b.paths.length - a.paths.length || b.stars - a.stars);
898
+
899
+ // Which ones do we already have wired into a profile?
900
+ const { loadProfile, listProfiles } = await import("../lib/profile-loader");
901
+ const allNpxRepos = new Set<string>();
902
+ for (const name of await listProfiles()) {
903
+ try {
904
+ const p = await loadProfile(name);
905
+ for (const n of p.skills.npx) allNpxRepos.add((n as any).source?.repo ?? n.repo ?? "");
906
+ } catch {}
907
+ }
908
+
909
+ // Build profile keyword index for "best fit" matching (cheap, local).
910
+ const profileIndex = await buildProfileKeywordIndex();
911
+ const fitsByRepo = new Map<string, ProfileFit[]>();
912
+
913
+ // --cli-aware: fetch each repo's SKILL.md content, parse CLIs + metadata.
914
+ // Metadata (tags/domain/description) enriches the profile-match keyword set
915
+ // and powers the "what it does" column.
916
+ const cliInfo = new Map<string, { needed: string[]; missing: string[] }>();
917
+ if (cliAware) {
918
+ const { parseCLIsFromContent, parseMetadataFromContent } = await import("./optimizer");
919
+ process.stderr.write(` Fetching SKILL.md bodies from ${candidates.length} repos (concurrent)...\n`);
920
+ let done = 0;
921
+ await pMap(candidates, 5, async (r) => {
922
+ const content = await fetchSkillContents(r.repo, r.paths);
923
+ const needed = content ? parseCLIsFromContent(content) : [];
924
+ const missing = needed.filter((cli) => spawnSync("which", [cli], { stdio: "ignore", timeout: 500 }).status !== 0);
925
+ cliInfo.set(r.repo, { needed, missing });
926
+ // Pull a description/tags/domain from the first parsed SKILL.md (best effort).
927
+ const meta = content ? parseMetadataFromContent(content) : { description: "", domain: "", tags: [], category: "", name: "" };
928
+ r.meta = {
929
+ description: meta.description,
930
+ domain: meta.domain,
931
+ tags: meta.tags,
932
+ categories: [...new Set([meta.domain, meta.category, ...meta.tags].filter(Boolean).map((s) => s.toLowerCase()))],
933
+ name: meta.name,
934
+ };
935
+ done++;
936
+ if (done % 5 === 0 || done === candidates.length) {
937
+ process.stderr.write(` [${done}/${candidates.length}] fetched\n`);
938
+ }
939
+ });
940
+ }
941
+
942
+ // Build the keyword set per repo: path-derived categories + (when present) metadata.
943
+ for (const r of candidates) {
944
+ const kw = new Set<string>(categoriesFromPaths(r.paths));
945
+ // Also seed from the repo name itself ("openclaw-security-watchdog" → security, watchdog).
946
+ for (const t of tokenize(r.repo.split("/").pop() ?? "")) kw.add(t);
947
+ if (r.meta) {
948
+ for (const t of r.meta.categories) kw.add(t);
949
+ for (const t of tokenize(r.meta.description)) kw.add(t);
950
+ }
951
+ fitsByRepo.set(r.repo, findBestProfiles(kw, profileIndex, 2));
952
+ }
953
+
954
+ if (json) {
955
+ const out = candidates.map((r) => ({
956
+ repo: r.repo,
957
+ stars: r.stars,
958
+ description: r.description,
959
+ skillCount: r.paths.length,
960
+ paths: r.paths,
961
+ installed: allNpxRepos.has(r.repo),
962
+ bestFitProfiles: fitsByRepo.get(r.repo) ?? [],
963
+ keywords: categoriesFromPaths(r.paths).slice(0, 6),
964
+ meta: r.meta ?? null,
965
+ ...(cliAware ? { cli: cliInfo.get(r.repo) ?? { needed: [], missing: [] } } : {}),
966
+ }));
967
+ process.stdout.write(JSON.stringify(out, null, 2) + "\n");
968
+ return 0;
969
+ }
970
+
971
+ process.stdout.write(`\n ${candidates.length} repos with SKILL.md files (sorted by skill count, then stars)\n\n`);
972
+
973
+ // Two-line-per-repo layout — the table got too wide once we added fit + keywords + CLI.
974
+ // Line 1: status, skills, stars, repo, best-fit profiles
975
+ // Line 2 (indented, dim): keywords (path-derived) and either CLI status or description
976
+ for (const r of candidates) {
977
+ const installed = allNpxRepos.has(r.repo) ? "\x1b[32madded\x1b[0m " : " ";
978
+ const skillCount = String(r.paths.length).padStart(3);
979
+ const stars = String(r.stars).padStart(5);
980
+ const name = r.repo.padEnd(42);
981
+ const fits = fitsByRepo.get(r.repo) ?? [];
982
+ const fitStr = fits.length > 0
983
+ ? `\x1b[36m→ ${fits.map((f) => f.profile).join(", ")}\x1b[0m`
984
+ : "\x1b[2m→ (no profile match)\x1b[0m";
985
+
986
+ process.stdout.write(` ${installed} ${skillCount} skills ${stars} ★ ${name} ${fitStr}\n`);
987
+
988
+ // Second line: keywords + CLI status (or description in non-cli-aware mode).
989
+ const keywords = [...new Set([...categoriesFromPaths(r.paths), ...(r.meta?.tags ?? [])])].slice(0, 5);
990
+ const kwStr = keywords.length > 0 ? `\x1b[2mkeywords:\x1b[0m ${keywords.join(", ")}` : "";
991
+ let info = "";
992
+ if (cliAware) {
993
+ const ci = cliInfo.get(r.repo) ?? { needed: [], missing: [] };
994
+ if (ci.needed.length === 0) {
995
+ info = ""; // no extra cli line; the metadata description below will show it
996
+ } else if (ci.missing.length === 0) {
997
+ info = `\x1b[32m✓ no new CLI installs\x1b[0m (${ci.needed.length})`;
998
+ } else {
999
+ info = `\x1b[33m⚠ ${ci.missing.length}/${ci.needed.length} CLIs missing\x1b[0m: ${ci.missing.slice(0, 4).join(", ")}${ci.missing.length > 4 ? "…" : ""}`;
1000
+ }
1001
+ }
1002
+ const desc = r.meta?.description || r.description || "";
1003
+ const descStr = desc ? `\x1b[2m"${desc.slice(0, 90)}${desc.length > 90 ? "…" : ""}"\x1b[0m` : "";
1004
+
1005
+ if (kwStr || info || descStr) {
1006
+ const parts = [kwStr, info, descStr].filter(Boolean).join(" · ");
1007
+ process.stdout.write(` ${parts}\n`);
1008
+ }
1009
+ process.stdout.write("\n");
1010
+ }
1011
+
1012
+ process.stdout.write(`\n Install: cue marketplace install-skill <repo>\n`);
1013
+ if (!cliAware) {
1014
+ process.stdout.write(` ${"\x1b[2mAdd --cli-aware to flag repos whose CLIs you already have installed.\x1b[0m"}\n`);
1015
+ }
1016
+ process.stdout.write(` ${"\x1b[2m--limit <n> (default 30) to fetch more results; --include-empty to keep edge cases.\x1b[0m"}\n\n`);
1017
+ return 0;
1018
+ }
1019
+
299
1020
  export async function run(args: string[]): Promise<number> {
300
1021
  if (args.includes("-h") || args.includes("--help")) {
301
1022
  process.stdout.write(`cue marketplace — search and install MCPs + skills
@@ -306,6 +1027,18 @@ Subcommands:
306
1027
  search <query> Search MCPs (Smithery) + skills
307
1028
  search-mcps <query> Search MCPs only
308
1029
  search-skills <query> Search skills only
1030
+ discover [profile] Find GitHub repos with SKILL.md files via code search.
1031
+ Flags: --limit N (default 30), --cli-aware (annotate
1032
+ each with CLI requirements + missing-on-host),
1033
+ --include-empty (keep candidates that lack direct
1034
+ SKILL.md path matches), --pr-preview <repo> (show
1035
+ the PR cue would post without touching GitHub)
1036
+ open-pr <repo> Actually post a SKILL.md quality-fix PR. Safe by
1037
+ default (dry-run); use --post --yes to execute.
1038
+ Honors throttle DB, opt-out marker, and 25/day cap.
1039
+ --opt-in-only: require <!-- cue: ok --> in README.
1040
+ cleanup-forks Delete cue's forks for PRs that are merged/closed.
1041
+ Use --dry-run to see what would be deleted.
309
1042
  install-mcp <id> Install MCP via Smithery
310
1043
  install-skill <repo> Install skill from GitHub
311
1044
  list-mcps List connected Smithery MCPs
@@ -341,6 +1074,34 @@ Examples:
341
1074
  return cmdListTools(rest[1] ?? "", json);
342
1075
  case "find-tools":
343
1076
  return cmdFindTools(rest.slice(1).join(" ") || "", json);
1077
+ case "discover": {
1078
+ const previewIdx = rest.indexOf("--pr-preview");
1079
+ if (previewIdx >= 0) {
1080
+ const repo = rest[previewIdx + 1];
1081
+ if (!repo || repo.startsWith("-")) {
1082
+ process.stderr.write("Usage: cue marketplace discover --pr-preview <owner/name>\n");
1083
+ return 1;
1084
+ }
1085
+ return cmdPrPreview(repo, json);
1086
+ }
1087
+ const positional = rest.slice(1).filter((a, i) => !a.startsWith("-") && rest[i] !== "--pr-preview");
1088
+ const cliAware = rest.includes("--cli-aware");
1089
+ const includeEmpty = rest.includes("--include-empty");
1090
+ const limitIdx = rest.indexOf("--limit");
1091
+ const limit = limitIdx >= 0 && rest[limitIdx + 1] ? Math.max(1, parseInt(rest[limitIdx + 1]!, 10) || 30) : 30;
1092
+ return cmdDiscover(positional[0] ?? "", json, cliAware, limit, includeEmpty);
1093
+ }
1094
+ case "open-pr": {
1095
+ const repo = rest[1] ?? "";
1096
+ const post = rest.includes("--post");
1097
+ const yes = rest.includes("--yes") || rest.includes("-y");
1098
+ const optInOnly = rest.includes("--opt-in-only");
1099
+ return cmdOpenPr(repo, json, post, yes, optInOnly);
1100
+ }
1101
+ case "cleanup-forks": {
1102
+ const dryRun = rest.includes("--dry-run");
1103
+ return cmdCleanupForks(json, dryRun);
1104
+ }
344
1105
  default:
345
1106
  // If no subcommand matches, treat as search query
346
1107
  return cmdSearch(rest.join(" "), json);