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.
- package/README.md +757 -110
- package/package.json +5 -5
- package/profiles/README.md +12 -12
- package/profiles/SCHEMA.md +31 -3
- package/profiles/_cache/README.md +1 -1
- package/profiles/_types.ts +26 -1
- package/profiles/backend/profile.yaml +1 -0
- package/profiles/career/profile.yaml +13 -0
- package/profiles/core/profile.yaml +76 -9
- package/profiles/creative-media/README.md +1 -1
- package/profiles/cybersecurity/profile.yaml +779 -756
- package/profiles/ecc/profile.yaml +39 -0
- package/profiles/event-design/profile.yaml +10 -0
- package/profiles/fleet-control/README.md +1 -1
- package/profiles/frontend/profile.yaml +14 -0
- package/profiles/full/README.md +1 -1
- package/profiles/go-api/profile.yaml +1 -0
- package/profiles/marketing/profile.yaml +12 -1
- package/profiles/predict-everything/profile.yaml +9 -0
- package/profiles/rust/profile.yaml +22 -3
- package/profiles/rust-cli/profile.yaml +14 -0
- package/profiles/rust-core/profile.yaml +35 -0
- package/profiles/rust-embedded/profile.yaml +11 -0
- package/profiles/rust-ffi/profile.yaml +13 -0
- package/profiles/rust-game/profile.yaml +11 -0
- package/profiles/rust-wasm/profile.yaml +11 -0
- package/profiles/rust-web/profile.yaml +17 -0
- package/profiles/schema.json +44 -4
- package/profiles/trendradar/profile.yaml +11 -0
- package/resources/mcps/README.md +39 -164
- package/resources/mcps/configs/claude.sanitized.json +55 -0
- package/resources/mcps/configs/claude_runtime.sanitized.json +47 -0
- package/resources/skills/README.md +70 -113
- package/resources/skills/skills/event-design/wedding-invitations/SKILL.md +43 -0
- package/resources/skills/skills/meta/acpx/SKILL.md +78 -0
- package/resources/skills/skills/meta/cue-usage/SKILL.md +24 -0
- package/resources/skills/skills/meta/profile-fit-monitor/SKILL.md +24 -0
- package/resources/skills/skills/predict-everything/mirofish/SKILL.md +75 -0
- package/resources/skills/skills/research/trendradar/SKILL.md +88 -0
- package/resources/skills/skills/rust/async-tokio/SKILL.md +27 -0
- package/resources/skills/skills/rust/axum-api/SKILL.md +38 -0
- package/resources/skills/skills/rust/bacon-watch/SKILL.md +24 -0
- package/resources/skills/skills/rust/bevy/SKILL.md +43 -0
- package/resources/skills/skills/rust/bindgen/SKILL.md +39 -0
- package/resources/skills/skills/rust/cargo-audit/SKILL.md +26 -0
- package/resources/skills/skills/rust/cargo-basics/SKILL.md +28 -0
- package/resources/skills/skills/rust/cargo-chef/SKILL.md +43 -0
- package/resources/skills/skills/rust/cargo-edit/SKILL.md +26 -0
- package/resources/skills/skills/rust/cargo-expand/SKILL.md +24 -0
- package/resources/skills/skills/rust/cargo-flamegraph/SKILL.md +26 -0
- package/resources/skills/skills/rust/cargo-fuzz/SKILL.md +34 -0
- package/resources/skills/skills/rust/cargo-hack/SKILL.md +26 -0
- package/resources/skills/skills/rust/cargo-msrv/SKILL.md +30 -0
- package/resources/skills/skills/rust/cargo-mutants/SKILL.md +26 -0
- package/resources/skills/skills/rust/cargo-nextest/SKILL.md +24 -0
- package/resources/skills/skills/rust/cargo-readme/SKILL.md +36 -0
- package/resources/skills/skills/rust/cbindgen/SKILL.md +41 -0
- package/resources/skills/skills/rust/chisel-tool/SKILL.md +32 -0
- package/resources/skills/skills/rust/clap-cli/SKILL.md +44 -0
- package/resources/skills/skills/rust/clippy-and-fmt/SKILL.md +25 -0
- package/resources/skills/skills/rust/cross-compile/SKILL.md +26 -0
- package/resources/skills/skills/rust/embedded/SKILL.md +33 -0
- package/resources/skills/skills/rust/error-handling/SKILL.md +32 -0
- package/resources/skills/skills/rust/just-runner/SKILL.md +26 -0
- package/resources/skills/skills/rust/mdbook/SKILL.md +25 -0
- package/resources/skills/skills/rust/napi-rs/SKILL.md +32 -0
- package/resources/skills/skills/rust/no-std/SKILL.md +42 -0
- package/resources/skills/skills/rust/property-testing/SKILL.md +35 -0
- package/resources/skills/skills/rust/pyo3/SKILL.md +40 -0
- package/resources/skills/skills/rust/ratatui-tui/SKILL.md +36 -0
- package/resources/skills/skills/rust/release-plz/SKILL.md +27 -0
- package/resources/skills/skills/rust/reqwest/SKILL.md +37 -0
- package/resources/skills/skills/rust/sccache/SKILL.md +28 -0
- package/resources/skills/skills/rust/serde/SKILL.md +30 -0
- package/resources/skills/skills/rust/snapshot-testing/SKILL.md +30 -0
- package/resources/skills/skills/rust/sqlx-cli/SKILL.md +33 -0
- package/resources/skills/skills/rust/tracing/SKILL.md +36 -0
- package/resources/skills/skills/rust/typos-spellcheck/SKILL.md +31 -0
- package/resources/skills/skills/rust/uniffi/SKILL.md +38 -0
- package/resources/skills/skills/rust/wasm-rust/SKILL.md +27 -0
- package/resources/skills/skills/security/agentshield/SKILL.md +119 -0
- package/src/commands/_index.ts +47 -3
- package/src/commands/cli.test.ts +192 -0
- package/src/commands/cli.ts +303 -0
- package/src/commands/current.ts +1 -1
- package/src/commands/debug.test.ts +62 -0
- package/src/commands/debug.ts +212 -0
- package/src/commands/discover.scoring.test.ts +216 -0
- package/src/commands/discover.test.ts +145 -0
- package/src/commands/discover.ts +2618 -0
- package/src/commands/eval-behavior.test.ts +56 -0
- package/src/commands/eval-behavior.ts +189 -0
- package/src/commands/eval.test.ts +102 -0
- package/src/commands/eval.ts +348 -0
- package/src/commands/evolve.ts +291 -0
- package/src/commands/failures.test.ts +78 -0
- package/src/commands/failures.ts +393 -0
- package/src/commands/feedback.ts +219 -0
- package/src/commands/init.ts +26 -0
- package/src/commands/launch.e2e.test.ts +9 -1
- package/src/commands/launch.ts +174 -11
- package/src/commands/lint-skill.ts +157 -0
- package/src/commands/marketplace.ts +763 -2
- package/src/commands/new.ts +1 -1
- package/src/commands/optimizer.ts +92 -28
- package/src/commands/profile-draft-skill.test.ts +96 -0
- package/src/commands/profile-draft-skill.ts +287 -0
- package/src/commands/profile-evolve.test.ts +126 -0
- package/src/commands/profile-evolve.ts +0 -0
- package/src/commands/profile-suggest.ts +223 -0
- package/src/commands/profile.ts +41 -0
- package/src/commands/quick.ts +2 -17
- package/src/commands/scan.ts +2 -2
- package/src/commands/score.ts +1 -1
- package/src/commands/share.ts +1 -1
- package/src/commands/sources.ts +2 -2
- package/src/commands/submit-profile.ts +262 -0
- package/src/commands/upgrade.ts +1 -1
- package/src/commands/use.ts +35 -5
- package/src/commands/validate.ts +1 -1
- package/src/index.ts +66 -0
- package/src/lib/analytics.ts +48 -2
- package/src/lib/claude-binary.ts +39 -0
- package/src/lib/cli-extractor.ts +77 -0
- package/src/lib/cluster-skills.test.ts +268 -0
- package/src/lib/cluster-skills.ts +290 -0
- package/src/lib/credentials-sync.test.ts +208 -0
- package/src/lib/credentials-sync.ts +205 -0
- package/src/lib/mcp-materializer.test.ts +1 -1
- package/src/lib/persona-playbooks.test.ts +111 -0
- package/src/lib/pr-poster.test.ts +243 -0
- package/src/lib/pr-poster.ts +285 -0
- package/src/lib/pr-throttle.test.ts +148 -0
- package/src/lib/pr-throttle.ts +209 -0
- package/src/lib/profile-generator.test.ts +1 -1
- package/src/lib/profile-generator.ts +2 -2
- package/src/lib/profile-linter.test.ts +6 -3
- package/src/lib/profile-linter.ts +71 -8
- package/src/lib/profile-loader.test.ts +1 -1
- package/src/lib/profile-loader.ts +16 -0
- package/src/lib/resolver-local.test.ts +1 -1
- package/src/lib/resolver-npx.test.ts +76 -1
- package/src/lib/resolver-npx.ts +35 -3
- package/src/lib/resolver-plugins.test.ts +1 -1
- package/src/lib/runtime-materializer.test.ts +191 -7
- package/src/lib/runtime-materializer.ts +310 -42
- package/src/lib/scan-plugins.test.ts +1 -1
- package/src/lib/skill-linter.test.ts +174 -0
- package/src/lib/skill-linter.ts +507 -0
- package/src/lib/skill-subset.test.ts +95 -0
- package/src/lib/skill-subset.ts +166 -0
- package/src/lib/star-prompt.ts +1 -1
- package/src/lib/uvx-installer.test.ts +229 -0
- 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://
|
|
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);
|