github-search-cli 9.8.0 → 9.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,21 +1,11 @@
1
- import {
2
- cacheDir,
3
- configPath,
4
- loadConfig,
5
- saveConfig,
6
- stateDir
7
- } from "./chunk-GYIL4S5X.js";
8
-
9
1
  // src/errors.ts
10
2
  var SearchCliError = class extends Error {
11
- constructor(message, userMessage, recoverable = true) {
3
+ constructor(message, userMessage) {
12
4
  super(message);
13
5
  this.userMessage = userMessage;
14
- this.recoverable = recoverable;
15
6
  this.name = "SearchCliError";
16
7
  }
17
8
  userMessage;
18
- recoverable;
19
9
  };
20
10
  var NetworkError = class extends SearchCliError {
21
11
  constructor() {
@@ -154,6 +144,7 @@ var MemoryCache = class {
154
144
  return entry.data;
155
145
  }
156
146
  set(key, data, ttlMs) {
147
+ this.cache.delete(key);
157
148
  if (this.cache.size >= MAX_CACHE_SIZE) {
158
149
  const firstKey = this.cache.keys().next().value;
159
150
  if (firstKey !== void 0) this.cache.delete(firstKey);
@@ -1190,13 +1181,199 @@ async function launchPackageBrowser() {
1190
1181
  }
1191
1182
 
1192
1183
  // src/doctor.ts
1193
- import { existsSync, readFileSync, mkdirSync } from "fs";
1184
+ import { existsSync as existsSync2, readFileSync as readFileSync3, mkdirSync as mkdirSync2 } from "fs";
1194
1185
  import { spawnSync } from "child_process";
1195
- import { join, dirname } from "path";
1186
+ import { join as join3, dirname } from "path";
1196
1187
  import { fileURLToPath } from "url";
1197
- import { homedir, platform, arch } from "os";
1188
+ import { homedir as homedir3, platform, arch } from "os";
1189
+
1190
+ // src/config.ts
1191
+ import { homedir } from "os";
1192
+ import { join } from "path";
1193
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "fs";
1194
+ function stateDir() {
1195
+ const xdg = process.env.XDG_STATE_HOME;
1196
+ const base = xdg ? join(xdg, "ghfind") : join(homedir(), ".local", "share", "ghfind");
1197
+ return base;
1198
+ }
1199
+ function cacheDir() {
1200
+ const xdg = process.env.XDG_CACHE_HOME;
1201
+ const base = xdg ? join(xdg, "ghfind") : join(homedir(), ".cache", "ghfind");
1202
+ return base;
1203
+ }
1204
+ var DEFAULTS = {
1205
+ defaultSort: "best-match",
1206
+ defaultLimit: 50,
1207
+ theme: "tokyo-night",
1208
+ cacheTtlSeconds: 300,
1209
+ defaultTab: "search"
1210
+ };
1211
+ function configPath() {
1212
+ const envPath = process.env.GHFIND_CONFIG;
1213
+ if (envPath) return envPath;
1214
+ const xdg = process.env.XDG_CONFIG_HOME;
1215
+ const base = xdg ? join(xdg, "ghfind") : join(homedir(), ".config", "ghfind");
1216
+ return join(base, "config.json");
1217
+ }
1218
+ function loadConfig() {
1219
+ const path = configPath();
1220
+ try {
1221
+ const raw = readFileSync(path, "utf-8");
1222
+ const parsed = JSON.parse(raw);
1223
+ return mergeWithEnv({ ...DEFAULTS, ...parsed });
1224
+ } catch {
1225
+ return mergeWithEnv({ ...DEFAULTS });
1226
+ }
1227
+ }
1228
+ function saveConfig(config) {
1229
+ const path = configPath();
1230
+ const dir = path.slice(
1231
+ 0,
1232
+ Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"))
1233
+ );
1234
+ mkdirSync(dir, { recursive: true });
1235
+ writeFileSync(path, JSON.stringify(config, null, 2), {
1236
+ encoding: "utf-8",
1237
+ mode: 384
1238
+ });
1239
+ try {
1240
+ chmodSync(path, 384);
1241
+ } catch {
1242
+ }
1243
+ }
1244
+ function mergeWithEnv(config) {
1245
+ if (process.env.GITHUB_TOKEN) config.githubToken = process.env.GITHUB_TOKEN;
1246
+ return config;
1247
+ }
1248
+
1249
+ // src/login.ts
1250
+ import { createInterface } from "readline";
1251
+ import { homedir as homedir2 } from "os";
1252
+ import { join as join2 } from "path";
1253
+ import { existsSync, readFileSync as readFileSync2 } from "fs";
1254
+ var TOKEN_PREFIXES = ["ghp_", "gho_", "github_pat_"];
1255
+ function isLikelyGithubToken(token) {
1256
+ return TOKEN_PREFIXES.some((prefix) => token.startsWith(prefix));
1257
+ }
1258
+ function maskToken(token) {
1259
+ if (token.length <= 11) return `${token.slice(0, 4)}\u2026`;
1260
+ return `${token.slice(0, 7)}\u2026${token.slice(-4)}`;
1261
+ }
1262
+ function ghCliConfigDir() {
1263
+ return process.env.GH_CONFIG_DIR || join2(homedir2(), ".config", "gh");
1264
+ }
1265
+ function parseHostsYmlToken(text, host = "github.com") {
1266
+ let inHost = false;
1267
+ for (const line of text.split(/\r?\n/)) {
1268
+ const trimmed = line.trim();
1269
+ if (/^[A-Za-z0-9.-]+:\s*$/.test(trimmed)) {
1270
+ inHost = trimmed.slice(0, -1).trim() === host;
1271
+ continue;
1272
+ }
1273
+ if (inHost) {
1274
+ const match = trimmed.match(/^oauth_token:\s*["']?([^"'\s]+)/);
1275
+ if (match) return match[1];
1276
+ }
1277
+ }
1278
+ return void 0;
1279
+ }
1280
+ function readGhCliToken(ghDir = ghCliConfigDir(), host = process.env.GH_HOST ?? "github.com") {
1281
+ const hostsFile = join2(ghDir, "hosts.yml");
1282
+ if (!existsSync(hostsFile)) return void 0;
1283
+ try {
1284
+ return parseHostsYmlToken(readFileSync2(hostsFile, "utf-8"), host);
1285
+ } catch {
1286
+ return void 0;
1287
+ }
1288
+ }
1289
+ function persistToken(token, deps) {
1290
+ const config = deps.readConfig();
1291
+ config.githubToken = token;
1292
+ deps.writeConfig(config);
1293
+ }
1294
+ function createAsk() {
1295
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1296
+ return (query) => new Promise((resolve) => {
1297
+ rl.question(query, (answer) => {
1298
+ rl.close();
1299
+ resolve(answer.trim());
1300
+ });
1301
+ });
1302
+ }
1303
+ function defaultLoginDeps() {
1304
+ return {
1305
+ ask: createAsk(),
1306
+ readGhCliToken: () => readGhCliToken(),
1307
+ readConfig: loadConfig,
1308
+ writeConfig: (config) => saveConfig(config)
1309
+ };
1310
+ }
1311
+ function successNote() {
1312
+ return [
1313
+ " Authentication raises the search rate limit from 60 to",
1314
+ " 5,000 requests/hour. Remove the token anytime by editing",
1315
+ ` ${configPath()} \u2014 delete the "githubToken" line and save.`
1316
+ ];
1317
+ }
1318
+ async function runLoginWizard(deps = defaultLoginDeps()) {
1319
+ const { ask: ask2 } = deps;
1320
+ console.log("");
1321
+ console.log(" ghfind login");
1322
+ console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
1323
+ console.log("");
1324
+ if (process.env.GITHUB_TOKEN) {
1325
+ console.log(
1326
+ " \u2139 GITHUB_TOKEN is set in the environment and already takes precedence."
1327
+ );
1328
+ console.log("");
1329
+ }
1330
+ const current = deps.readConfig();
1331
+ if (current.githubToken) {
1332
+ console.log(
1333
+ ` \u2713 A token is already saved: ${maskToken(current.githubToken)}`
1334
+ );
1335
+ }
1336
+ const ghToken = deps.readGhCliToken();
1337
+ if (ghToken) {
1338
+ console.log(" \u2713 Found a token managed by the GitHub CLI (gh).");
1339
+ const answer = await ask2(" Import it into ghfind? [Y/n]: ");
1340
+ if (answer.toLowerCase() !== "n") {
1341
+ persistToken(ghToken, deps);
1342
+ finishLogin(ghToken);
1343
+ return;
1344
+ }
1345
+ }
1346
+ const pasted = await ask2(" Paste a GitHub token (Enter to skip): ");
1347
+ if (!pasted) {
1348
+ console.log(" Skipped \u2014 no token saved.");
1349
+ console.log("");
1350
+ return;
1351
+ }
1352
+ if (!isLikelyGithubToken(pasted)) {
1353
+ console.log(
1354
+ ` \u26A0 That doesn't look like a GitHub token (expected one of: ${TOKEN_PREFIXES.join(", ")}).`
1355
+ );
1356
+ const confirm = await ask2(" Save it anyway? [y/N]: ");
1357
+ if (confirm.toLowerCase() !== "y") {
1358
+ console.log(" Cancelled \u2014 nothing saved.");
1359
+ console.log("");
1360
+ return;
1361
+ }
1362
+ }
1363
+ persistToken(pasted, deps);
1364
+ finishLogin(pasted);
1365
+ }
1366
+ function finishLogin(token) {
1367
+ console.log(` \u2713 Token saved to ${configPath()}`);
1368
+ console.log(` (masked: ${maskToken(token)})`);
1369
+ console.log("");
1370
+ for (const line of successNote()) console.log(line);
1371
+ console.log("");
1372
+ }
1373
+
1374
+ // src/doctor.ts
1198
1375
  var __dirname = dirname(fileURLToPath(import.meta.url));
1199
- var PKG_DIR = join(__dirname, "..");
1376
+ var PKG_DIR = join3(__dirname, "..");
1200
1377
  var results = [];
1201
1378
  function add(status, label, detail) {
1202
1379
  results.push({ status, label, detail });
@@ -1222,7 +1399,7 @@ function hasSystemBun() {
1222
1399
  return res.status === 0 ? { ok: true, version: res.stdout.trim() } : { ok: false };
1223
1400
  }
1224
1401
  function bundledBunPath() {
1225
- return join(PKG_DIR, "vendor", platform() === "win32" ? "bun.exe" : "bun");
1402
+ return join3(PKG_DIR, "vendor", platform() === "win32" ? "bun.exe" : "bun");
1226
1403
  }
1227
1404
  function bunRuns(bin) {
1228
1405
  const res = run(bin, ["--version"]);
@@ -1232,7 +1409,7 @@ function bunRuns(bin) {
1232
1409
  function detectLibc() {
1233
1410
  if (platform() !== "linux") return "unknown";
1234
1411
  try {
1235
- const osRelease = readFileSync("/etc/os-release", "utf8");
1412
+ const osRelease = readFileSync3("/etc/os-release", "utf8");
1236
1413
  if (/alpine|musl/i.test(osRelease)) return "musl";
1237
1414
  } catch {
1238
1415
  }
@@ -1253,7 +1430,7 @@ async function checkRuntime() {
1253
1430
  }
1254
1431
  function checkBundledBun() {
1255
1432
  const path = bundledBunPath();
1256
- if (!existsSync(path)) {
1433
+ if (!existsSync2(path)) {
1257
1434
  add(
1258
1435
  "WARN",
1259
1436
  "Bundled Bun",
@@ -1302,8 +1479,8 @@ function checkNodeVersion() {
1302
1479
  }
1303
1480
  }
1304
1481
  function checkBuildOutput() {
1305
- const dist = join(PKG_DIR, "dist", "cli.js");
1306
- if (existsSync(dist)) {
1482
+ const dist = join3(PKG_DIR, "dist", "cli.js");
1483
+ if (existsSync2(dist)) {
1307
1484
  add("PASS", "Build output", `${dist}`);
1308
1485
  } else {
1309
1486
  add(
@@ -1314,8 +1491,8 @@ function checkBuildOutput() {
1314
1491
  }
1315
1492
  }
1316
1493
  function checkSource() {
1317
- const src = join(PKG_DIR, "src", "cli.ts");
1318
- if (existsSync(src)) {
1494
+ const src = join3(PKG_DIR, "src", "cli.ts");
1495
+ if (existsSync2(src)) {
1319
1496
  add("PASS", "Source", `${src}`);
1320
1497
  } else {
1321
1498
  add("FAIL", "Source", "src/cli.ts not found. Reinstall the package.");
@@ -1331,7 +1508,7 @@ function checkTuiBackend() {
1331
1508
  }
1332
1509
  function checkConfig() {
1333
1510
  const path = configPath();
1334
- if (!existsSync(path)) {
1511
+ if (!existsSync2(path)) {
1335
1512
  add(
1336
1513
  "WARN",
1337
1514
  "Config",
@@ -1340,7 +1517,7 @@ function checkConfig() {
1340
1517
  return;
1341
1518
  }
1342
1519
  try {
1343
- const raw = readFileSync(path, "utf-8");
1520
+ const raw = readFileSync3(path, "utf-8");
1344
1521
  JSON.parse(raw);
1345
1522
  add("PASS", "Config", `${path} (valid JSON)`);
1346
1523
  } catch (err) {
@@ -1355,8 +1532,8 @@ function checkGithubToken() {
1355
1532
  const config = loadConfig();
1356
1533
  const token = config.githubToken || process.env.GITHUB_TOKEN;
1357
1534
  if (token) {
1358
- const preview = token.slice(0, 7);
1359
- add("PASS", "GitHub token", `Configured (${preview}\u2026).`);
1535
+ const preview = maskToken(token);
1536
+ add("PASS", "GitHub token", `Configured (${preview}).`);
1360
1537
  } else {
1361
1538
  add(
1362
1539
  "WARN",
@@ -1371,7 +1548,7 @@ function checkDirs() {
1371
1548
  ["Cache dir", cacheDir()]
1372
1549
  ]) {
1373
1550
  try {
1374
- mkdirSync(dir, { recursive: true });
1551
+ mkdirSync2(dir, { recursive: true });
1375
1552
  add("PASS", label, dir);
1376
1553
  } catch (err) {
1377
1554
  add(
@@ -1387,7 +1564,7 @@ var BIN_NAME = "ghfind";
1387
1564
  function runningVersion() {
1388
1565
  try {
1389
1566
  const pkg = JSON.parse(
1390
- readFileSync(join(PKG_DIR, "package.json"), "utf8")
1567
+ readFileSync3(join3(PKG_DIR, "package.json"), "utf8")
1391
1568
  );
1392
1569
  return pkg.version ?? "";
1393
1570
  } catch {
@@ -1397,11 +1574,11 @@ function runningVersion() {
1397
1574
  function installVersionAt(shimPath) {
1398
1575
  const dir = dirname(shimPath);
1399
1576
  for (const ancestor of [dir, dirname(dir)]) {
1400
- for (const sub of ["node_modules", join("lib", "node_modules")]) {
1401
- const pkgJson = join(ancestor, sub, PKG_NAME, "package.json");
1402
- if (existsSync(pkgJson)) {
1577
+ for (const sub of ["node_modules", join3("lib", "node_modules")]) {
1578
+ const pkgJson = join3(ancestor, sub, PKG_NAME, "package.json");
1579
+ if (existsSync2(pkgJson)) {
1403
1580
  try {
1404
- const pkg = JSON.parse(readFileSync(pkgJson, "utf8"));
1581
+ const pkg = JSON.parse(readFileSync3(pkgJson, "utf8"));
1405
1582
  return { version: pkg.version ?? "", dir: dirname(pkgJson) };
1406
1583
  } catch {
1407
1584
  }
@@ -1481,7 +1658,7 @@ function printReport() {
1481
1658
  return `${code}${s}\x1B[0m`;
1482
1659
  };
1483
1660
  console.log("\n ghfind doctor \u2014 environment diagnostics");
1484
- console.log(` ${platform()} ${arch} \xB7 ${homedir()}`);
1661
+ console.log(` ${platform()} ${arch} \xB7 ${homedir3()}`);
1485
1662
  console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
1486
1663
  for (const r of results) {
1487
1664
  console.log(
@@ -1531,20 +1708,20 @@ import {
1531
1708
  import { spawn as spawn2 } from "child_process";
1532
1709
 
1533
1710
  // src/version.ts
1534
- import { readFileSync as readFileSync2 } from "fs";
1535
- import { join as join2, dirname as dirname2 } from "path";
1711
+ import { readFileSync as readFileSync4 } from "fs";
1712
+ import { join as join4, dirname as dirname2 } from "path";
1536
1713
  import { fileURLToPath as fileURLToPath2 } from "url";
1537
1714
  var cached = null;
1538
1715
  function getVersion() {
1539
1716
  if (cached) return cached;
1540
1717
  if (true) {
1541
- cached = "9.8.0";
1718
+ cached = "9.8.2";
1542
1719
  return cached;
1543
1720
  }
1544
1721
  try {
1545
1722
  const here = dirname2(fileURLToPath2(import.meta.url));
1546
1723
  const pkg = JSON.parse(
1547
- readFileSync2(join2(here, "..", "package.json"), "utf-8")
1724
+ readFileSync4(join4(here, "..", "package.json"), "utf-8")
1548
1725
  );
1549
1726
  cached = pkg.version ?? "unknown";
1550
1727
  } catch {
@@ -1556,6 +1733,45 @@ function getVersion() {
1556
1733
  // src/error-report.ts
1557
1734
  var REPO = "codersdfs/search-cli";
1558
1735
  var NEW_ISSUE_URL = `https://github.com/${REPO}/issues/new`;
1736
+ var SECRET_PLACEHOLDER = "***";
1737
+ var SECRET_FLAGS = /^(?:--token|-t)$/;
1738
+ var SECRET_PATTERNS = [
1739
+ /gh[pousr]_[A-Za-z0-9]{16,}/g,
1740
+ /github_pat_[A-Za-z0-9_]{20,}/g
1741
+ ];
1742
+ function redactSecrets(text) {
1743
+ let out = text;
1744
+ for (const pattern of SECRET_PATTERNS) {
1745
+ out = out.replace(pattern, SECRET_PLACEHOLDER);
1746
+ }
1747
+ return out;
1748
+ }
1749
+ function redactArgv(argv) {
1750
+ const out = [];
1751
+ let redactNext = false;
1752
+ for (const arg of argv) {
1753
+ if (redactNext) {
1754
+ out.push(SECRET_PLACEHOLDER);
1755
+ redactNext = false;
1756
+ continue;
1757
+ }
1758
+ if (SECRET_FLAGS.test(arg)) {
1759
+ out.push(arg);
1760
+ redactNext = true;
1761
+ continue;
1762
+ }
1763
+ const eq = arg.indexOf("=");
1764
+ if (eq > 0 && SECRET_FLAGS.test(arg.slice(0, eq))) {
1765
+ out.push(`${arg.slice(0, eq)}=${SECRET_PLACEHOLDER}`);
1766
+ continue;
1767
+ }
1768
+ out.push(redactSecrets(arg));
1769
+ }
1770
+ return out;
1771
+ }
1772
+ function argvHasSecret(argv) {
1773
+ return redactArgv(argv).some((arg, i) => arg !== argv[i]);
1774
+ }
1559
1775
  function getPackageVersion() {
1560
1776
  return getVersion();
1561
1777
  }
@@ -1565,9 +1781,12 @@ function runtimeName() {
1565
1781
  }
1566
1782
  function formatErrorBody(err, context) {
1567
1783
  const e = err instanceof Error ? err : new Error(String(err));
1568
- const stack = (e.stack ?? "(no stack)").replace(/\u001b\[[0-9;]*m/g, "");
1569
- const message = (e.message || "(no message)").slice(0, 500);
1570
- const cmd = `${context.command} ${context.argv.join(" ")}`.trim();
1784
+ const stack = redactSecrets(
1785
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: strips ANSI color escapes from stack traces
1786
+ (e.stack ?? "(no stack)").replace(/\u001b\[[0-9;]*m/g, "")
1787
+ );
1788
+ const message = redactSecrets(e.message || "(no message)").slice(0, 500);
1789
+ const cmd = `${context.command} ${redactArgv(context.argv).join(" ")}`.trim();
1571
1790
  const ghfindVersion = getPackageVersion();
1572
1791
  return [
1573
1792
  "## What happened",
@@ -1601,7 +1820,7 @@ function formatErrorBody(err, context) {
1601
1820
  }
1602
1821
  function buildIssueUrl(err, context) {
1603
1822
  const e = err instanceof Error ? err : new Error(String(err));
1604
- const title = e.message.slice(0, 80).replace(/[\r\n]+/g, " ");
1823
+ const title = redactSecrets(e.message).slice(0, 80).replace(/[\r\n]+/g, " ");
1605
1824
  const body = formatErrorBody(err, context);
1606
1825
  const params = new URLSearchParams({
1607
1826
  title: `[bug] ${title || "Unhandled error"}`,
@@ -1633,15 +1852,25 @@ function reportError(err, context = {
1633
1852
  command: "ghfind",
1634
1853
  argv: process.argv.slice(2)
1635
1854
  }) {
1636
- const url = buildIssueUrl(err, context);
1855
+ const hadSecret = argvHasSecret(context.argv);
1856
+ const url = buildIssueUrl(err, {
1857
+ command: context.command,
1858
+ argv: redactArgv(context.argv)
1859
+ });
1637
1860
  console.error("");
1638
1861
  console.error(" \u26A0 ghfind encountered an unexpected error.");
1639
1862
  console.error("");
1640
- console.error(" Help us fix it by opening a bug report with the");
1641
- console.error(" details pre-filled. Your browser will open the new-issue");
1642
- console.error(" page; review and submit (or close the tab to skip).");
1863
+ if (hadSecret) {
1864
+ console.error(" A token was found in the command line and has been");
1865
+ console.error(" redacted. Your browser will NOT be opened \u2014 review the");
1866
+ console.error(" scrubbed URL below before sharing it anywhere.");
1867
+ } else {
1868
+ console.error(" Help us fix it by opening a bug report with the");
1869
+ console.error(" details pre-filled. Your browser will open the new-issue");
1870
+ console.error(" page; review and submit (or close the tab to skip).");
1871
+ }
1643
1872
  console.error("");
1644
- const opened = tryOpenUrl(url);
1873
+ const opened = hadSecret ? false : tryOpenUrl(url);
1645
1874
  if (opened) {
1646
1875
  console.error(` \u2192 Opened: ${url}`);
1647
1876
  } else {
@@ -1654,7 +1883,7 @@ function reportError(err, context = {
1654
1883
  }
1655
1884
 
1656
1885
  // src/output.ts
1657
- import { writeFileSync } from "fs";
1886
+ import { writeFileSync as writeFileSync2 } from "fs";
1658
1887
  var FORMATS = /* @__PURE__ */ new Map([
1659
1888
  // Export formats
1660
1889
  ["json", formatJson],
@@ -1722,7 +1951,7 @@ function exportToFile(repos, fmt) {
1722
1951
  const ext = fmt === "markdown" ? "md" : fmt;
1723
1952
  const ts = Date.now();
1724
1953
  const path = `./ghfind-export-${ts}.${ext}`;
1725
- writeFileSync(path, format(repos, fmt), "utf-8");
1954
+ writeFileSync2(path, format(repos, fmt), "utf-8");
1726
1955
  return path;
1727
1956
  }
1728
1957
  async function pipeExec(repos, target) {
@@ -1880,17 +2109,18 @@ async function fetchOrgProfile(org, options = {}) {
1880
2109
  if (orgPayload.public_repos > 0) {
1881
2110
  const perPage = Math.min(limit, 100);
1882
2111
  const maxPages = Math.ceil(limit / perPage);
1883
- for (let page = 1; page <= maxPages; page++) {
1884
- const url = `https://api.github.com/orgs/${encodeURIComponent(name)}/repos?per_page=${perPage}&sort=pushed&page=${page}`;
1885
- const res = await fetch(url, { headers: apiHeaders(token) });
1886
- if (!res.ok) {
1887
- break;
1888
- }
1889
- const items = await res.json();
1890
- if (!Array.isArray(items)) break;
1891
- repos.push(...items);
1892
- if (repos.length >= limit || items.length < perPage) break;
1893
- }
2112
+ const pages = await Promise.all(
2113
+ Array.from({ length: maxPages }, (_, i) => i + 1).map(async (page) => {
2114
+ const url = `https://api.github.com/orgs/${encodeURIComponent(name)}/repos?per_page=${perPage}&sort=pushed&page=${page}`;
2115
+ const res = await fetch(url, { headers: apiHeaders(token) });
2116
+ if (!res.ok) {
2117
+ return [];
2118
+ }
2119
+ const items = await res.json();
2120
+ return Array.isArray(items) ? items : [];
2121
+ })
2122
+ );
2123
+ repos.push(...pages.flat());
1894
2124
  }
1895
2125
  return normalizeOrg(orgPayload, repos.slice(0, limit));
1896
2126
  }
@@ -2094,17 +2324,18 @@ async function fetchUserProfile(user, options = {}) {
2094
2324
  if (userPayload.public_repos > 0) {
2095
2325
  const perPage = Math.min(limit, 100);
2096
2326
  const maxPages = Math.ceil(limit / perPage);
2097
- for (let page = 1; page <= maxPages; page++) {
2098
- const url = `https://api.github.com/users/${encodeURIComponent(name)}/repos?per_page=${perPage}&sort=pushed&page=${page}`;
2099
- const res = await fetch(url, { headers: apiHeaders2(token) });
2100
- if (!res.ok) {
2101
- break;
2102
- }
2103
- const items = await res.json();
2104
- if (!Array.isArray(items)) break;
2105
- repos.push(...items);
2106
- if (repos.length >= limit || items.length < perPage) break;
2107
- }
2327
+ const pages = await Promise.all(
2328
+ Array.from({ length: maxPages }, (_, i) => i + 1).map(async (page) => {
2329
+ const url = `https://api.github.com/users/${encodeURIComponent(name)}/repos?per_page=${perPage}&sort=pushed&page=${page}`;
2330
+ const res = await fetch(url, { headers: apiHeaders2(token) });
2331
+ if (!res.ok) {
2332
+ return [];
2333
+ }
2334
+ const items = await res.json();
2335
+ return Array.isArray(items) ? items : [];
2336
+ })
2337
+ );
2338
+ repos.push(...pages.flat());
2108
2339
  }
2109
2340
  return normalizeUser(userPayload, repos.slice(0, limit));
2110
2341
  }
@@ -2220,34 +2451,48 @@ function formatUserMarkdown(p) {
2220
2451
 
2221
2452
  // src/storage.ts
2222
2453
  import {
2223
- existsSync as existsSync2,
2224
- mkdirSync as mkdirSync2,
2225
- readFileSync as readFileSync3,
2226
- writeFileSync as writeFileSync2,
2454
+ mkdirSync as mkdirSync3,
2455
+ readFileSync as readFileSync5,
2456
+ writeFileSync as writeFileSync3,
2227
2457
  appendFileSync,
2228
- unlinkSync
2458
+ unlinkSync,
2459
+ renameSync
2229
2460
  } from "fs";
2230
- import { join as join3 } from "path";
2461
+ import { join as join5 } from "path";
2231
2462
  function ensureDir() {
2232
- mkdirSync2(stateDir(), { recursive: true });
2463
+ mkdirSync3(stateDir(), { recursive: true });
2233
2464
  }
2234
2465
  function readJSON(filename, fallback) {
2235
2466
  ensureDir();
2236
2467
  try {
2237
- return JSON.parse(readFileSync3(join3(stateDir(), filename), "utf-8"));
2468
+ return JSON.parse(readFileSync5(join5(stateDir(), filename), "utf-8"));
2238
2469
  } catch {
2239
2470
  return fallback;
2240
2471
  }
2241
2472
  }
2242
- function writeJSON(filename, data) {
2473
+ function writeFileAtomic(filename, data) {
2243
2474
  ensureDir();
2244
- writeFileSync2(join3(stateDir(), filename), JSON.stringify(data, null, 2));
2475
+ const target = join5(stateDir(), filename);
2476
+ const tmp = `${target}.tmp-${process.pid}`;
2477
+ try {
2478
+ writeFileSync3(tmp, data, "utf-8");
2479
+ renameSync(tmp, target);
2480
+ } catch (e) {
2481
+ try {
2482
+ unlinkSync(tmp);
2483
+ } catch {
2484
+ }
2485
+ throw e;
2486
+ }
2487
+ }
2488
+ function writeJSON(filename, data) {
2489
+ writeFileAtomic(filename, JSON.stringify(data, null, 2));
2245
2490
  }
2246
2491
  function appendJSONL(filename, obj) {
2247
2492
  ensureDir();
2248
2493
  try {
2249
2494
  appendFileSync(
2250
- join3(stateDir(), filename),
2495
+ join5(stateDir(), filename),
2251
2496
  `${JSON.stringify(obj)}
2252
2497
  `,
2253
2498
  "utf-8"
@@ -2258,7 +2503,7 @@ function appendJSONL(filename, obj) {
2258
2503
  function readJSONL(filename) {
2259
2504
  ensureDir();
2260
2505
  try {
2261
- const raw = readFileSync3(join3(stateDir(), filename), "utf-8").trim();
2506
+ const raw = readFileSync5(join5(stateDir(), filename), "utf-8").trim();
2262
2507
  if (!raw) return [];
2263
2508
  return raw.split("\n").filter(Boolean).map((l) => JSON.parse(l));
2264
2509
  } catch {
@@ -2266,14 +2511,13 @@ function readJSONL(filename) {
2266
2511
  }
2267
2512
  }
2268
2513
  function writeRaw(filename, data) {
2269
- ensureDir();
2270
- writeFileSync2(join3(stateDir(), filename), data);
2514
+ writeFileAtomic(filename, data);
2271
2515
  }
2272
2516
  function debugLog(msg) {
2273
2517
  if (!process.env.DEBUG) return;
2274
2518
  try {
2275
2519
  appendFileSync(
2276
- join3(stateDir(), "ghfind-debug.log"),
2520
+ join5(stateDir(), "ghfind-debug.log"),
2277
2521
  `[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}
2278
2522
  `,
2279
2523
  "utf-8"
@@ -2309,6 +2553,12 @@ function removeBookmark(fullName) {
2309
2553
  const bookmarks = loadRaw().filter((b) => b.repo.fullName !== fullName);
2310
2554
  saveRaw(bookmarks);
2311
2555
  }
2556
+ function searchBookmarks(query) {
2557
+ const q = query.toLowerCase();
2558
+ return getBookmarks().filter(
2559
+ (b) => b.repo.fullName.toLowerCase().includes(q) || (b.repo.description ?? "").toLowerCase().includes(q) || b.tags.some((t3) => t3.toLowerCase().includes(q))
2560
+ );
2561
+ }
2312
2562
  function getLastSeen(fullName) {
2313
2563
  return loadRaw().find((b) => b.repo.fullName === fullName)?.lastSeenAt ?? 0;
2314
2564
  }
@@ -2324,9 +2574,12 @@ function markSeen(fullName) {
2324
2574
  // src/notifications.ts
2325
2575
  var nextId = 1;
2326
2576
  var notifs = [];
2577
+ var loaded = false;
2327
2578
  var MAX_NOTIFS = 50;
2328
2579
  var FILE = "notifications.json";
2329
- function load() {
2580
+ function ensureLoaded() {
2581
+ if (loaded) return;
2582
+ loaded = true;
2330
2583
  notifs = readJSON(FILE, []);
2331
2584
  nextId = Math.max(...notifs.map((n) => n.id), 0) + 1;
2332
2585
  }
@@ -2334,7 +2587,7 @@ function save() {
2334
2587
  writeJSON(FILE, notifs);
2335
2588
  }
2336
2589
  function addNotification(type, message) {
2337
- if (notifs.length === 0) load();
2590
+ ensureLoaded();
2338
2591
  const n = {
2339
2592
  id: nextId++,
2340
2593
  type,
@@ -2364,14 +2617,16 @@ function iconFor(type) {
2364
2617
  }
2365
2618
  }
2366
2619
  function getNotifications() {
2367
- if (notifs.length === 0) load();
2620
+ ensureLoaded();
2368
2621
  return [...notifs];
2369
2622
  }
2370
2623
  function dismissNotification(id) {
2624
+ ensureLoaded();
2371
2625
  notifs = notifs.filter((n) => n.id !== id);
2372
2626
  save();
2373
2627
  }
2374
2628
  function dismissAll() {
2629
+ ensureLoaded();
2375
2630
  notifs = [];
2376
2631
  save();
2377
2632
  }
@@ -2379,6 +2634,7 @@ function dismissAll() {
2379
2634
  // src/releases.ts
2380
2635
  var RELEASES_FILE = "release-cache.json";
2381
2636
  var PER_REPO = 5;
2637
+ var CONCURRENCY = 6;
2382
2638
  function loadCache() {
2383
2639
  return readJSON(RELEASES_FILE, {});
2384
2640
  }
@@ -2418,43 +2674,31 @@ async function checkReleases(opts = {}) {
2418
2674
  const config = loadConfig();
2419
2675
  const token = opts.token ?? config.githubToken ?? process.env.GITHUB_TOKEN;
2420
2676
  const results2 = [];
2421
- for (const bm of bookmarks) {
2677
+ const checkOne = async (fullName) => {
2422
2678
  try {
2423
- const prev = cache2[bm.repo.fullName];
2424
- const lastSeen = getLastSeen(bm.repo.fullName);
2679
+ const prev = cache2[fullName];
2680
+ const lastSeen = getLastSeen(fullName);
2425
2681
  const isNew = (r) => !r.publishedAt || new Date(r.publishedAt).getTime() > lastSeen;
2426
2682
  const { status, etag, releases } = await fetchReleases(
2427
- bm.repo.fullName,
2683
+ fullName,
2428
2684
  prev?.etag,
2429
2685
  token
2430
2686
  );
2431
2687
  if (status === 304 && prev) {
2432
- results2.push({
2433
- fullName: bm.repo.fullName,
2434
- newReleases: prev.releases.filter(isNew)
2435
- });
2436
- continue;
2688
+ return { fullName, newReleases: prev.releases.filter(isNew) };
2437
2689
  }
2438
2690
  if (status !== 200 || !releases) {
2439
- results2.push({
2440
- fullName: bm.repo.fullName,
2441
- newReleases: [],
2442
- error: `HTTP ${status}`
2443
- });
2444
- continue;
2691
+ return { fullName, newReleases: [], error: `HTTP ${status}` };
2445
2692
  }
2446
- cache2[bm.repo.fullName] = { checkedAt: Date.now(), etag, releases };
2447
- results2.push({
2448
- fullName: bm.repo.fullName,
2449
- newReleases: releases.filter(isNew)
2450
- });
2693
+ cache2[fullName] = { checkedAt: Date.now(), etag, releases };
2694
+ return { fullName, newReleases: releases.filter(isNew) };
2451
2695
  } catch (err) {
2452
- results2.push({
2453
- fullName: bm.repo.fullName,
2454
- newReleases: [],
2455
- error: err.message
2456
- });
2696
+ return { fullName, newReleases: [], error: err.message };
2457
2697
  }
2698
+ };
2699
+ for (let i = 0; i < bookmarks.length; i += CONCURRENCY) {
2700
+ const batch = bookmarks.slice(i, i + CONCURRENCY).map((bm) => checkOne(bm.repo.fullName));
2701
+ results2.push(...await Promise.all(batch));
2458
2702
  }
2459
2703
  saveCache(cache2);
2460
2704
  return results2;
@@ -2474,144 +2718,17 @@ async function checkAndNotify(opts = {}) {
2474
2718
  }
2475
2719
  function allCachedReleases() {
2476
2720
  const cache2 = loadCache();
2477
- const bookmarks = getBookmarks();
2478
- return Object.entries(cache2).filter(([fullName]) => bookmarks.some((b) => b.repo.fullName === fullName)).flatMap(([fullName, s]) => s.releases.map((r) => ({ ...r, fullName }))).sort(
2721
+ const bookmarked = new Set(getBookmarks().map((b) => b.repo.fullName));
2722
+ return Object.entries(cache2).filter(([fullName]) => bookmarked.has(fullName)).flatMap(([fullName, s]) => s.releases.map((r) => ({ ...r, fullName }))).sort(
2479
2723
  (a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()
2480
2724
  );
2481
2725
  }
2482
2726
 
2483
2727
  // src/init.ts
2484
2728
  import { createInterface as createInterface2 } from "readline";
2485
-
2486
- // src/login.ts
2487
- import { createInterface } from "readline";
2488
- import { homedir as homedir2 } from "os";
2489
- import { join as join4 } from "path";
2490
- import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
2491
- var TOKEN_PREFIXES = ["ghp_", "gho_", "github_pat_"];
2492
- function isLikelyGithubToken(token) {
2493
- return TOKEN_PREFIXES.some((prefix) => token.startsWith(prefix));
2494
- }
2495
- function maskToken(token) {
2496
- if (token.length <= 11) return `${token.slice(0, 4)}\u2026`;
2497
- return `${token.slice(0, 7)}\u2026${token.slice(-4)}`;
2498
- }
2499
- function ghCliConfigDir() {
2500
- return process.env.GH_CONFIG_DIR || join4(homedir2(), ".config", "gh");
2501
- }
2502
- function parseHostsYmlToken(text, host = "github.com") {
2503
- let inHost = false;
2504
- for (const line of text.split(/\r?\n/)) {
2505
- const trimmed = line.trim();
2506
- if (/^[A-Za-z0-9.-]+:\s*$/.test(trimmed)) {
2507
- inHost = trimmed.slice(0, -1).trim() === host;
2508
- continue;
2509
- }
2510
- if (inHost) {
2511
- const match = trimmed.match(/^oauth_token:\s*["']?([^"'\s]+)/);
2512
- if (match) return match[1];
2513
- }
2514
- }
2515
- return void 0;
2516
- }
2517
- function readGhCliToken(ghDir = ghCliConfigDir(), host = process.env.GH_HOST ?? "github.com") {
2518
- const hostsFile = join4(ghDir, "hosts.yml");
2519
- if (!existsSync3(hostsFile)) return void 0;
2520
- try {
2521
- return parseHostsYmlToken(readFileSync4(hostsFile, "utf-8"), host);
2522
- } catch {
2523
- return void 0;
2524
- }
2525
- }
2526
- function persistToken(token, deps) {
2527
- const config = deps.readConfig();
2528
- config.githubToken = token;
2529
- deps.writeConfig(config);
2530
- }
2531
- function createAsk() {
2532
- const rl = createInterface({ input: process.stdin, output: process.stdout });
2533
- return (query) => new Promise((resolve) => {
2534
- rl.question(query, (answer) => {
2535
- rl.close();
2536
- resolve(answer.trim());
2537
- });
2538
- });
2539
- }
2540
- function defaultLoginDeps() {
2541
- return {
2542
- ask: createAsk(),
2543
- readGhCliToken: () => readGhCliToken(),
2544
- readConfig: loadConfig,
2545
- writeConfig: (config) => saveConfig(config)
2546
- };
2547
- }
2548
- function successNote() {
2549
- return [
2550
- " Authentication raises the search rate limit from 60 to",
2551
- " 5,000 requests/hour. Remove the token anytime by editing",
2552
- ` ${configPath()} \u2014 delete the "githubToken" line and save.`
2553
- ];
2554
- }
2555
- async function runLoginWizard(deps = defaultLoginDeps()) {
2556
- const { ask: ask2 } = deps;
2557
- console.log("");
2558
- console.log(" ghfind login");
2559
- console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
2560
- console.log("");
2561
- if (process.env.GITHUB_TOKEN) {
2562
- console.log(
2563
- " \u2139 GITHUB_TOKEN is set in the environment and already takes precedence."
2564
- );
2565
- console.log("");
2566
- }
2567
- const current = deps.readConfig();
2568
- if (current.githubToken) {
2569
- console.log(
2570
- ` \u2713 A token is already saved: ${maskToken(current.githubToken)}`
2571
- );
2572
- }
2573
- const ghToken = deps.readGhCliToken();
2574
- if (ghToken) {
2575
- console.log(" \u2713 Found a token managed by the GitHub CLI (gh).");
2576
- const answer = await ask2(" Import it into ghfind? [Y/n]: ");
2577
- if (answer.toLowerCase() !== "n") {
2578
- persistToken(ghToken, deps);
2579
- finishLogin(ghToken);
2580
- return;
2581
- }
2582
- }
2583
- const pasted = await ask2(" Paste a GitHub token (Enter to skip): ");
2584
- if (!pasted) {
2585
- console.log(" Skipped \u2014 no token saved.");
2586
- console.log("");
2587
- return;
2588
- }
2589
- if (!isLikelyGithubToken(pasted)) {
2590
- console.log(
2591
- ` \u26A0 That doesn't look like a GitHub token (expected one of: ${TOKEN_PREFIXES.join(", ")}).`
2592
- );
2593
- const confirm = await ask2(" Save it anyway? [y/N]: ");
2594
- if (confirm.toLowerCase() !== "y") {
2595
- console.log(" Cancelled \u2014 nothing saved.");
2596
- console.log("");
2597
- return;
2598
- }
2599
- }
2600
- persistToken(pasted, deps);
2601
- finishLogin(pasted);
2602
- }
2603
- function finishLogin(token) {
2604
- console.log(` \u2713 Token saved to ${configPath()}`);
2605
- console.log(` (masked: ${maskToken(token)})`);
2606
- console.log("");
2607
- for (const line of successNote()) console.log(line);
2608
- console.log("");
2609
- }
2610
-
2611
- // src/init.ts
2612
- function ask(query) {
2613
- const rl = createInterface2({ input: process.stdin, output: process.stdout });
2614
- return new Promise((resolve) => {
2729
+ function ask(query) {
2730
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
2731
+ return new Promise((resolve) => {
2615
2732
  rl.question(query, (answer) => {
2616
2733
  rl.close();
2617
2734
  resolve(answer.trim());
@@ -2644,8 +2761,7 @@ async function runInitWizard() {
2644
2761
  };
2645
2762
  if (token) config.githubToken = token;
2646
2763
  saveConfig(config);
2647
- const { configPath: configPath2 } = await import("./config-OCIPF2IF.js");
2648
- console.log(` \u2713 Config saved to ${configPath2()}`);
2764
+ console.log(` \u2713 Config saved to ${configPath()}`);
2649
2765
  console.log(" Run 'ghfind' to start browsing!");
2650
2766
  console.log("");
2651
2767
  }
@@ -2749,6 +2865,203 @@ function comparisonMarkdown(repos) {
2749
2865
  return [header, sep, ...rows].join("\n");
2750
2866
  }
2751
2867
 
2868
+ // src/image-loader.ts
2869
+ var MAX_IMAGE_BYTES = 6 * 1024 * 1024;
2870
+ var BADGE_HOST = /(^|\.)(shields\.io|badgen\.net|badge\.fury\.io|codecov\.io|coveralls\.io|travis-ci\.(org|com)|snyk\.io|sonarcloud\.io|david-dm\.(org|io))$/i;
2871
+ var RAW_HOSTS = /* @__PURE__ */ new Set([
2872
+ "raw.githubusercontent.com",
2873
+ "camo.githubusercontent.com",
2874
+ "user-images.githubusercontent.com",
2875
+ "private-user-images.githubusercontent.com",
2876
+ "avatars.githubusercontent.com",
2877
+ "objects.githubusercontent.com"
2878
+ ]);
2879
+ var RASTER_EXT = /\.(png|jpe?g|webp|gif|bmp|avif|heic|tiff?)(?:[?#]|$)/i;
2880
+ var CACHE_LIMIT = 24;
2881
+ var MAX_CONCURRENT_IMAGE_FETCHES = 4;
2882
+ var cache = /* @__PURE__ */ new Map();
2883
+ var activeFetches = 0;
2884
+ var waiting = [];
2885
+ function releaseSlot() {
2886
+ activeFetches--;
2887
+ waiting.shift()?.();
2888
+ }
2889
+ async function withSlot(job) {
2890
+ if (activeFetches >= MAX_CONCURRENT_IMAGE_FETCHES) {
2891
+ await new Promise((resolve) => waiting.push(resolve));
2892
+ }
2893
+ activeFetches++;
2894
+ try {
2895
+ return await job();
2896
+ } finally {
2897
+ releaseSlot();
2898
+ }
2899
+ }
2900
+ function decodeDataUri(uri) {
2901
+ const match = /^data:([^;,]+)?(;base64)?,(.*)$/is.exec(uri);
2902
+ if (!match) return null;
2903
+ const mime = (match[1] ?? "").toLowerCase();
2904
+ if (mime.includes("svg")) return null;
2905
+ const payload = match[3];
2906
+ try {
2907
+ if (match[2]) return new Uint8Array(Buffer.from(payload, "base64"));
2908
+ return new Uint8Array(Buffer.from(decodeURIComponent(payload), "binary"));
2909
+ } catch {
2910
+ return null;
2911
+ }
2912
+ }
2913
+ function toRawGitHubUrl(url) {
2914
+ if (url.hostname.toLowerCase() !== "github.com") return url;
2915
+ const match = /^\/([^/]+)\/([^/]+)\/(?:blob|raw)\/(.+)$/.exec(url.pathname);
2916
+ if (!match) return url;
2917
+ const [, owner, repo, rest] = match;
2918
+ return new URL(
2919
+ `https://raw.githubusercontent.com/${owner}/${repo}/${rest}${url.search}`
2920
+ );
2921
+ }
2922
+ function resolveRootRelative(path, base) {
2923
+ let parsed;
2924
+ try {
2925
+ parsed = new URL(base);
2926
+ } catch {
2927
+ return null;
2928
+ }
2929
+ if (parsed.hostname.toLowerCase() !== "raw.githubusercontent.com") {
2930
+ return null;
2931
+ }
2932
+ const segments = parsed.pathname.split("/").filter(Boolean);
2933
+ if (segments.length < 3) return null;
2934
+ return new URL(`${parsed.origin}/${segments.slice(0, 3).join("/")}${path}`);
2935
+ }
2936
+ function resolveReadmeImageUrl(raw, base) {
2937
+ const cleaned = raw.trim();
2938
+ if (!cleaned) return null;
2939
+ if (/^data:/i.test(cleaned)) {
2940
+ return isRenderableImageUrl(cleaned) ? cleaned : null;
2941
+ }
2942
+ let url = null;
2943
+ try {
2944
+ url = cleaned.startsWith("/") ? resolveRootRelative(cleaned, base) : new URL(cleaned, base);
2945
+ } catch {
2946
+ return null;
2947
+ }
2948
+ if (!url) return null;
2949
+ url = toRawGitHubUrl(url);
2950
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
2951
+ url.hash = "";
2952
+ const resolved = url.toString();
2953
+ return isRenderableImageUrl(resolved) ? resolved : null;
2954
+ }
2955
+ function isRenderableImageUrl(url) {
2956
+ if (/^data:/i.test(url)) return !/svg/i.test(url.slice(0, 64));
2957
+ let parsed;
2958
+ try {
2959
+ parsed = new URL(url);
2960
+ } catch {
2961
+ return false;
2962
+ }
2963
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
2964
+ if (BADGE_HOST.test(parsed.hostname)) return false;
2965
+ const path = parsed.pathname.toLowerCase();
2966
+ if (path.endsWith(".svg") || path.endsWith(".svgz")) return false;
2967
+ if (parsed.hostname.toLowerCase().endsWith("github.com")) {
2968
+ if (/^\/user-attachments\//.test(path)) return true;
2969
+ if (!RAW_HOSTS.has(parsed.hostname)) {
2970
+ return /\.(png|jpe?g|webp|gif|bmp|avif|heic|tiff?)$/i.test(path);
2971
+ }
2972
+ }
2973
+ if (/\.[a-z0-9]{1,5}$/.test(path) && !RASTER_EXT.test(path)) return false;
2974
+ return true;
2975
+ }
2976
+ function isGitHubHost(hostname) {
2977
+ return /(^|\.)githubusercontent\.com$|(^|\.)github\.com$/i.test(hostname);
2978
+ }
2979
+ async function fetchImageBytes(url, opts = {}) {
2980
+ const cached2 = cache.get(url);
2981
+ if (cached2) return cached2;
2982
+ const pending = withSlot(() => loadImageBytes(url, opts));
2983
+ cache.set(url, pending);
2984
+ while (cache.size > CACHE_LIMIT) {
2985
+ const oldest = cache.keys().next().value;
2986
+ if (oldest === void 0) break;
2987
+ cache.delete(oldest);
2988
+ }
2989
+ return pending;
2990
+ }
2991
+ async function loadImageBytes(url, opts) {
2992
+ if (/^data:/i.test(url)) return decodeDataUri(url);
2993
+ const fetchImpl = opts.fetchImpl ?? fetch;
2994
+ const timeoutMs = opts.timeoutMs ?? 1e4;
2995
+ const controller = new AbortController();
2996
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
2997
+ try {
2998
+ const headers = {
2999
+ "User-Agent": "ghfind/1.0",
3000
+ Accept: "image/*,*/*;q=0.5"
3001
+ };
3002
+ let host = "";
3003
+ try {
3004
+ host = new URL(url).hostname;
3005
+ } catch {
3006
+ return null;
3007
+ }
3008
+ if (opts.token && isGitHubHost(host)) {
3009
+ headers.Authorization = `Bearer ${opts.token}`;
3010
+ }
3011
+ const res = await fetchImpl(url, { headers, signal: controller.signal });
3012
+ if (!res.ok) return null;
3013
+ const contentType = res.headers.get("content-type") ?? "";
3014
+ if (contentType && !/^image\//i.test(contentType)) {
3015
+ const githubHost = isGitHubHost(host);
3016
+ if (!githubHost || !/octet-stream/i.test(contentType)) return null;
3017
+ }
3018
+ if (/svg/i.test(contentType)) return null;
3019
+ const declared = Number(res.headers.get("content-length") ?? "0");
3020
+ if (declared > MAX_IMAGE_BYTES) return null;
3021
+ const buffer = new Uint8Array(await res.arrayBuffer());
3022
+ if (buffer.byteLength === 0 || buffer.byteLength > MAX_IMAGE_BYTES) {
3023
+ return null;
3024
+ }
3025
+ return buffer;
3026
+ } catch {
3027
+ return null;
3028
+ } finally {
3029
+ clearTimeout(timer);
3030
+ }
3031
+ }
3032
+ function readmeBaseUrl(owner, repo, branch, path = "README.md") {
3033
+ return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`;
3034
+ }
3035
+
3036
+ // src/readme.ts
3037
+ var CANDIDATES = [
3038
+ ["main", "README.md"],
3039
+ ["master", "README.md"],
3040
+ ["main", "README.rst"]
3041
+ ];
3042
+ function buildReadmeHeaders(url, token, isPrivate = false) {
3043
+ const headers = { "User-Agent": "ghfind/1.0" };
3044
+ if (token && isPrivate && new URL(url).origin === "https://api.github.com") {
3045
+ headers.Authorization = `Bearer ${token}`;
3046
+ }
3047
+ return headers;
3048
+ }
3049
+ async function fetchReadme(owner, repo, opts = {}) {
3050
+ const fetchImpl = opts.fetchImpl ?? fetch;
3051
+ const attempts = CANDIDATES.map(async ([branch, file]) => {
3052
+ const url = readmeBaseUrl(owner, repo, branch, file);
3053
+ const headers = buildReadmeHeaders(url, opts.token, opts.private);
3054
+ const res = await fetchImpl(url, { headers });
3055
+ if (!res.ok) throw new Error(`no readme at ${url}`);
3056
+ return { text: await res.text(), sourceUrl: url };
3057
+ });
3058
+ try {
3059
+ return await Promise.any(attempts);
3060
+ } catch {
3061
+ return null;
3062
+ }
3063
+ }
3064
+
2752
3065
  // src/deepdive.ts
2753
3066
  var USER_AGENT = "ghfind/1.0";
2754
3067
  function buildHeaders(token) {
@@ -2793,42 +3106,46 @@ function repoFromRef(fullName) {
2793
3106
  async function resolveRepoFromRef(fullName, token) {
2794
3107
  const ref = parseRepoRef(fullName);
2795
3108
  const canonical = `${ref.owner}/${ref.name}`;
3109
+ let res;
2796
3110
  try {
2797
- const res = await fetch(`https://api.github.com/repos/${canonical}`, {
3111
+ res = await fetch(`https://api.github.com/repos/${canonical}`, {
2798
3112
  headers: buildHeaders(token)
2799
3113
  });
2800
- if (res.status === 404) {
2801
- throw new Error(`Repo not found: ${canonical}`);
2802
- }
2803
- if (res.ok) {
2804
- const api = await res.json();
2805
- return {
2806
- id: 0,
2807
- fullName: canonical,
2808
- name: ref.name,
2809
- owner: ref.owner,
2810
- description: api.description,
2811
- url: api.html_url,
2812
- stars: api.stargazers_count ?? 0,
2813
- forks: api.forks_count ?? 0,
2814
- watchers: api.subscribers_count ?? 0,
2815
- language: api.language,
2816
- topics: api.topics ?? [],
2817
- archived: api.archived ?? false,
2818
- isFork: api.fork ?? false,
2819
- private: false,
2820
- createdAt: api.created_at ?? "",
2821
- updatedAt: api.updated_at ?? "",
2822
- pushedAt: api.pushed_at ?? "",
2823
- score: 0
2824
- };
2825
- }
2826
- } catch (err) {
2827
- if (err instanceof Error && err.message.startsWith("Repo not found")) {
2828
- throw err;
2829
- }
3114
+ } catch {
3115
+ return repoFromRef(canonical);
3116
+ }
3117
+ if (res.status === 404) {
3118
+ throw new Error(`Repo not found: ${canonical}`);
2830
3119
  }
2831
- return repoFromRef(canonical);
3120
+ if (!res.ok) {
3121
+ throw new Error(`GitHub API error ${res.status} for ${canonical}`);
3122
+ }
3123
+ let api;
3124
+ try {
3125
+ api = await res.json();
3126
+ } catch {
3127
+ return repoFromRef(canonical);
3128
+ }
3129
+ return {
3130
+ id: 0,
3131
+ fullName: canonical,
3132
+ name: ref.name,
3133
+ owner: ref.owner,
3134
+ description: api.description,
3135
+ url: api.html_url,
3136
+ stars: api.stargazers_count ?? 0,
3137
+ forks: api.forks_count ?? 0,
3138
+ watchers: api.subscribers_count ?? 0,
3139
+ language: api.language,
3140
+ topics: api.topics ?? [],
3141
+ archived: api.archived ?? false,
3142
+ isFork: api.fork ?? false,
3143
+ private: api.private ?? false,
3144
+ createdAt: api.created_at ?? "",
3145
+ updatedAt: api.updated_at ?? "",
3146
+ pushedAt: api.pushed_at ?? "",
3147
+ score: 0
3148
+ };
2832
3149
  }
2833
3150
  var README_MAX_CHARS = 5e3;
2834
3151
  async function fetchDeepDiveRaw(repo, token) {
@@ -2842,20 +3159,11 @@ async function fetchDeepDiveRaw(repo, token) {
2842
3159
  { headers }
2843
3160
  )
2844
3161
  ]);
2845
- let readmeText = "";
2846
- for (const path of [
2847
- `${repo.owner}/${repo.name}/main/README.md`,
2848
- `${repo.owner}/${repo.name}/master/README.md`,
2849
- `${repo.owner}/${repo.name}/main/README.rst`
2850
- ]) {
2851
- const r = await fetch(`https://raw.githubusercontent.com/${path}`, {
2852
- headers
2853
- });
2854
- if (r.ok) {
2855
- readmeText = await r.text();
2856
- break;
2857
- }
2858
- }
3162
+ const readme = await fetchReadme(repo.owner, repo.name, {
3163
+ token: repo.private ? token : void 0,
3164
+ private: repo.private
3165
+ });
3166
+ const readmeText = readme?.text ?? "";
2859
3167
  const langEntries = languagesRes.ok ? Object.entries(
2860
3168
  await languagesRes.json().catch(() => ({}))
2861
3169
  ) : [];
@@ -2946,8 +3254,8 @@ function buildDeepDiveText(data) {
2946
3254
  }
2947
3255
 
2948
3256
  // src/cli.ts
2949
- import { readFileSync as readFileSync6 } from "fs";
2950
- import { join as join6, dirname as dirname4 } from "path";
3257
+ import { readFileSync as readFileSync8 } from "fs";
3258
+ import { join as join8, dirname as dirname4 } from "path";
2951
3259
  import { fileURLToPath as fileURLToPath4 } from "url";
2952
3260
 
2953
3261
  // src/tui.ts
@@ -3170,13 +3478,10 @@ function readHistory() {
3170
3478
  function deleteHistoryEntry(index) {
3171
3479
  const entries = readHistory();
3172
3480
  if (index < 0 || index >= entries.length) return;
3173
- const removed = entries[index];
3174
- const remaining = entries.filter(
3175
- (e) => e.timestamp !== removed.timestamp || e.query !== removed.query
3176
- );
3481
+ entries.splice(index, 1);
3177
3482
  writeRaw(
3178
3483
  HISTORY_FILE,
3179
- `${remaining.reverse().map((e) => JSON.stringify(e)).join("\n")}
3484
+ `${entries.reverse().map((e) => JSON.stringify(e)).join("\n")}
3180
3485
  `
3181
3486
  );
3182
3487
  }
@@ -3564,156 +3869,6 @@ async function rasterizeImage(bytes, opts) {
3564
3869
  }
3565
3870
  }
3566
3871
 
3567
- // src/image-loader.ts
3568
- var MAX_IMAGE_BYTES = 6 * 1024 * 1024;
3569
- var BADGE_HOST = /(^|\.)(shields\.io|badgen\.net|badge\.fury\.io|codecov\.io|coveralls\.io|travis-ci\.(org|com)|snyk\.io|sonarcloud\.io|david-dm\.(org|io))$/i;
3570
- var RAW_HOSTS = /* @__PURE__ */ new Set([
3571
- "raw.githubusercontent.com",
3572
- "camo.githubusercontent.com",
3573
- "user-images.githubusercontent.com",
3574
- "private-user-images.githubusercontent.com",
3575
- "avatars.githubusercontent.com",
3576
- "objects.githubusercontent.com"
3577
- ]);
3578
- var RASTER_EXT = /\.(png|jpe?g|webp|gif|bmp|avif|heic|tiff?)(?:[?#]|$)/i;
3579
- var CACHE_LIMIT = 24;
3580
- var cache = /* @__PURE__ */ new Map();
3581
- function decodeDataUri(uri) {
3582
- const match = /^data:([^;,]+)?(;base64)?,(.*)$/is.exec(uri);
3583
- if (!match) return null;
3584
- const mime = (match[1] ?? "").toLowerCase();
3585
- if (mime.includes("svg")) return null;
3586
- const payload = match[3];
3587
- try {
3588
- if (match[2]) return new Uint8Array(Buffer.from(payload, "base64"));
3589
- return new Uint8Array(Buffer.from(decodeURIComponent(payload), "binary"));
3590
- } catch {
3591
- return null;
3592
- }
3593
- }
3594
- function toRawGitHubUrl(url) {
3595
- if (url.hostname.toLowerCase() !== "github.com") return url;
3596
- const match = /^\/([^/]+)\/([^/]+)\/(?:blob|raw)\/(.+)$/.exec(url.pathname);
3597
- if (!match) return url;
3598
- const [, owner, repo, rest] = match;
3599
- return new URL(
3600
- `https://raw.githubusercontent.com/${owner}/${repo}/${rest}${url.search}`
3601
- );
3602
- }
3603
- function resolveRootRelative(path, base) {
3604
- let parsed;
3605
- try {
3606
- parsed = new URL(base);
3607
- } catch {
3608
- return null;
3609
- }
3610
- if (parsed.hostname.toLowerCase() !== "raw.githubusercontent.com") {
3611
- return null;
3612
- }
3613
- const segments = parsed.pathname.split("/").filter(Boolean);
3614
- if (segments.length < 3) return null;
3615
- return new URL(`${parsed.origin}/${segments.slice(0, 3).join("/")}${path}`);
3616
- }
3617
- function resolveReadmeImageUrl(raw, base) {
3618
- const cleaned = raw.trim();
3619
- if (!cleaned) return null;
3620
- if (/^data:/i.test(cleaned)) {
3621
- return isRenderableImageUrl(cleaned) ? cleaned : null;
3622
- }
3623
- let url = null;
3624
- try {
3625
- url = cleaned.startsWith("/") ? resolveRootRelative(cleaned, base) : new URL(cleaned, base);
3626
- } catch {
3627
- return null;
3628
- }
3629
- if (!url) return null;
3630
- url = toRawGitHubUrl(url);
3631
- if (url.protocol !== "http:" && url.protocol !== "https:") return null;
3632
- url.hash = "";
3633
- const resolved = url.toString();
3634
- return isRenderableImageUrl(resolved) ? resolved : null;
3635
- }
3636
- function isRenderableImageUrl(url) {
3637
- if (/^data:/i.test(url)) return !/svg/i.test(url.slice(0, 64));
3638
- let parsed;
3639
- try {
3640
- parsed = new URL(url);
3641
- } catch {
3642
- return false;
3643
- }
3644
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
3645
- if (BADGE_HOST.test(parsed.hostname)) return false;
3646
- const path = parsed.pathname.toLowerCase();
3647
- if (path.endsWith(".svg") || path.endsWith(".svgz")) return false;
3648
- if (parsed.hostname.toLowerCase().endsWith("github.com")) {
3649
- if (/^\/user-attachments\//.test(path)) return true;
3650
- if (!RAW_HOSTS.has(parsed.hostname)) {
3651
- return /\.(png|jpe?g|webp|gif|bmp|avif|heic|tiff?)$/i.test(path);
3652
- }
3653
- }
3654
- if (/\.[a-z0-9]{1,5}$/.test(path) && !RASTER_EXT.test(path)) return false;
3655
- return true;
3656
- }
3657
- function isGitHubHost(hostname) {
3658
- return /(^|\.)githubusercontent\.com$|(^|\.)github\.com$/i.test(hostname);
3659
- }
3660
- async function fetchImageBytes(url, opts = {}) {
3661
- const cached2 = cache.get(url);
3662
- if (cached2) return cached2;
3663
- const pending = loadImageBytes(url, opts);
3664
- cache.set(url, pending);
3665
- while (cache.size > CACHE_LIMIT) {
3666
- const oldest = cache.keys().next().value;
3667
- if (oldest === void 0) break;
3668
- cache.delete(oldest);
3669
- }
3670
- return pending;
3671
- }
3672
- async function loadImageBytes(url, opts) {
3673
- if (/^data:/i.test(url)) return decodeDataUri(url);
3674
- const fetchImpl = opts.fetchImpl ?? fetch;
3675
- const timeoutMs = opts.timeoutMs ?? 1e4;
3676
- const controller = new AbortController();
3677
- const timer = setTimeout(() => controller.abort(), timeoutMs);
3678
- try {
3679
- const headers = {
3680
- "User-Agent": "ghfind/1.0",
3681
- Accept: "image/*,*/*;q=0.5"
3682
- };
3683
- let host = "";
3684
- try {
3685
- host = new URL(url).hostname;
3686
- } catch {
3687
- return null;
3688
- }
3689
- if (opts.token && isGitHubHost(host)) {
3690
- headers.Authorization = `Bearer ${opts.token}`;
3691
- }
3692
- const res = await fetchImpl(url, { headers, signal: controller.signal });
3693
- if (!res.ok) return null;
3694
- const contentType = res.headers.get("content-type") ?? "";
3695
- if (contentType && !/^image\//i.test(contentType)) {
3696
- const githubHost = isGitHubHost(host);
3697
- if (!githubHost || !/octet-stream/i.test(contentType)) return null;
3698
- }
3699
- if (/svg/i.test(contentType)) return null;
3700
- const declared = Number(res.headers.get("content-length") ?? "0");
3701
- if (declared > MAX_IMAGE_BYTES) return null;
3702
- const buffer = new Uint8Array(await res.arrayBuffer());
3703
- if (buffer.byteLength === 0 || buffer.byteLength > MAX_IMAGE_BYTES) {
3704
- return null;
3705
- }
3706
- return buffer;
3707
- } catch {
3708
- return null;
3709
- } finally {
3710
- clearTimeout(timer);
3711
- }
3712
- }
3713
- function readmeBaseUrl(owner, repo, branch, path = "README.md") {
3714
- return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`;
3715
- }
3716
-
3717
3872
  // src/markdown-images.ts
3718
3873
  var IMG_TAG = /<img\b[^>]*?src\s*=\s*["']([^"']+)["'][^>]*>/gi;
3719
3874
  var ALT_ATTR = /\balt\s*=\s*["']([^"']*)["']/i;
@@ -3793,6 +3948,7 @@ function extractImageRefs(token) {
3793
3948
 
3794
3949
  // src/markdown-view.ts
3795
3950
  var MAX_IMAGE_ROWS = 24;
3951
+ var RASTER_CACHE_LIMIT = 32;
3796
3952
  function createMarkdownStyle(colors2) {
3797
3953
  const surface = colors2.surface ?? colors2.bg;
3798
3954
  return SyntaxStyle.fromStyles({
@@ -3887,6 +4043,11 @@ function createMarkdownView(opts) {
3887
4043
  });
3888
4044
  })();
3889
4045
  rasterCache.set(key, pending);
4046
+ while (rasterCache.size > RASTER_CACHE_LIMIT) {
4047
+ const oldest = rasterCache.keys().next().value;
4048
+ if (oldest === void 0) break;
4049
+ rasterCache.delete(oldest);
4050
+ }
3890
4051
  return pending;
3891
4052
  };
3892
4053
  const imageNode = (image) => {
@@ -3902,9 +4063,6 @@ function createMarkdownView(opts) {
3902
4063
  container.add(placeholder);
3903
4064
  void rasterize(url).then(
3904
4065
  (cells) => {
3905
- console.error(
3906
- `[dbg] image resolved: cells=${cells ? `${cells.cols}x${cells.rows}` : "null"} destroyed=${container.isDestroyed} parent=${!!container.parent} enabled=${imagesEnabled}`
3907
- );
3908
4066
  if (container.isDestroyed || !container.parent) return;
3909
4067
  container.remove(placeholder);
3910
4068
  placeholder.destroy();
@@ -3923,7 +4081,7 @@ function createMarkdownView(opts) {
3923
4081
  renderable.requestRender();
3924
4082
  },
3925
4083
  (err) => {
3926
- console.error("[dbg] image promise rejected:", err);
4084
+ void err;
3927
4085
  }
3928
4086
  );
3929
4087
  return container;
@@ -3978,6 +4136,8 @@ function createMarkdownView(opts) {
3978
4136
  renderable.fg = text();
3979
4137
  renderable.bg = bg();
3980
4138
  renderable.content = markdown;
4139
+ renderable.streaming = false;
4140
+ renderable.requestRender();
3981
4141
  },
3982
4142
  setImagesEnabled(enabled) {
3983
4143
  if (imagesEnabled === enabled) return;
@@ -4137,23 +4297,23 @@ function nextTip() {
4137
4297
  }
4138
4298
 
4139
4299
  // src/update-check.ts
4140
- import { readFileSync as readFileSync5 } from "fs";
4141
- import { join as join5, dirname as dirname3, posix } from "path";
4300
+ import { readFileSync as readFileSync6 } from "fs";
4301
+ import { join as join6, dirname as dirname3, posix } from "path";
4142
4302
  import { fileURLToPath as fileURLToPath3 } from "url";
4143
4303
  var REGISTRY_URL = "https://registry.npmjs.org/github-search-cli/latest";
4144
4304
  var PKG_NAME2 = "github-search-cli";
4145
4305
  var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
4146
4306
  var REQUEST_TIMEOUT_MS = 5e3;
4147
4307
  var __dirname2 = dirname3(fileURLToPath3(import.meta.url));
4148
- var PKG_DIR2 = join5(__dirname2, "..");
4308
+ var PKG_DIR2 = join6(__dirname2, "..");
4149
4309
  var STATE_FILE = "update-state.json";
4150
- var DEFAULTS = {
4310
+ var DEFAULTS2 = {
4151
4311
  lastCheck: 0,
4152
4312
  snoozeUntil: 0,
4153
4313
  suppressed: false
4154
4314
  };
4155
4315
  function readUpdateState() {
4156
- return { ...DEFAULTS, ...readJSON(STATE_FILE, {}) };
4316
+ return { ...DEFAULTS2, ...readJSON(STATE_FILE, {}) };
4157
4317
  }
4158
4318
  function writeUpdateState(state) {
4159
4319
  writeJSON(STATE_FILE, state);
@@ -4286,14 +4446,14 @@ function recordPreUpdateState(currentVersion) {
4286
4446
  }
4287
4447
  function fetchReleaseNotes(version) {
4288
4448
  try {
4289
- const notesPath = join5(PKG_DIR2, "release-notes", `${version}.md`);
4290
- const md = readFileSync5(notesPath, "utf-8");
4449
+ const notesPath = join6(PKG_DIR2, "release-notes", `${version}.md`);
4450
+ const md = readFileSync6(notesPath, "utf-8");
4291
4451
  if (md.trim().length > 0) return md;
4292
4452
  } catch {
4293
4453
  }
4294
4454
  try {
4295
- const changelogPath = join5(PKG_DIR2, "CHANGELOG.md");
4296
- const changelog = readFileSync5(changelogPath, "utf-8");
4455
+ const changelogPath = join6(PKG_DIR2, "CHANGELOG.md");
4456
+ const changelog = readFileSync6(changelogPath, "utf-8");
4297
4457
  const header = `## [${version}]`;
4298
4458
  const start = changelog.indexOf(header);
4299
4459
  if (start === -1) return null;
@@ -5889,27 +6049,14 @@ async function launchBrowser(theme_override) {
5889
6049
  readmeScroll.scrollTop = 0;
5890
6050
  showOverlay("readme");
5891
6051
  try {
5892
- const headers = { "User-Agent": "ghfind/1.0" };
5893
- if (githubToken) headers.Authorization = `Bearer ${githubToken}`;
5894
- let text = "";
5895
- let sourceUrl = "";
5896
- for (const [branch, file] of [
5897
- ["main", "README.md"],
5898
- ["master", "README.md"],
5899
- ["main", "README.rst"]
5900
- ]) {
5901
- const url = readmeBaseUrl(repo.owner, repo.name, branch, file);
5902
- const r = await fetch(url, { headers });
5903
- if (r.ok) {
5904
- text = await r.text();
5905
- sourceUrl = url;
5906
- break;
5907
- }
5908
- }
5909
- if (!text) {
5910
- readmeView.setContent(`_(no README found for ${repo.fullName})_`);
6052
+ const readme = await fetchReadme(repo.owner, repo.name, {
6053
+ token: githubToken,
6054
+ private: repo.private
6055
+ });
6056
+ if (readme) {
6057
+ readmeView.setContent(readme.text, readme.sourceUrl);
5911
6058
  } else {
5912
- readmeView.setContent(text, sourceUrl);
6059
+ readmeView.setContent(`_(no README found for ${repo.fullName})_`);
5913
6060
  }
5914
6061
  } catch {
5915
6062
  readmeView.setContent("_Failed to load README._");
@@ -6336,10 +6483,12 @@ async function launchBrowser(theme_override) {
6336
6483
  }
6337
6484
  }
6338
6485
  } catch (err) {
6339
- currentRepos = [];
6340
- resultsSelect.options = [
6341
- { name: " (error)", description: "", value: null }
6342
- ];
6486
+ if (!append) {
6487
+ currentRepos = [];
6488
+ resultsSelect.options = [
6489
+ { name: " (error)", description: "", value: null }
6490
+ ];
6491
+ }
6343
6492
  const msg = err instanceof SearchCliError ? err.userMessage : err instanceof Error ? err.message : String(err);
6344
6493
  statusMgr.set("error", msg.slice(0, 60));
6345
6494
  }
@@ -6857,16 +7006,11 @@ ${pack.description ?? ""}`;
6857
7006
  renderer.requestRender();
6858
7007
  }
6859
7008
  function formatResultLine(repo) {
6860
- const stars = formatStars(repo.stars);
7009
+ const stars = `\u2605 ${fmtStars(repo.stars)}`;
6861
7010
  const lang = (repo.language ?? "?").padEnd(12).slice(0, 12);
6862
7011
  const name = repo.fullName.padEnd(40).slice(0, 40);
6863
7012
  return `${name} ${lang} ${stars}`;
6864
7013
  }
6865
- function formatStars(n) {
6866
- if (n >= 1e6) return `\u2605 ${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M`;
6867
- if (n >= 1e3) return `\u2605 ${(n / 1e3).toFixed(1).replace(/\.0$/, "")}k`;
6868
- return `\u2605 ${n}`;
6869
- }
6870
7014
  function formatToolbar(sort, limit, totalCount) {
6871
7015
  const sortLabel = SORT_MODES.find((m) => m.key === sort)?.label ?? sort;
6872
7016
  return ` sort: ${sortLabel} limit: ${limit} results: ${totalCount.toLocaleString()}`;
@@ -6931,6 +7075,12 @@ reported in text output and via the error message.
6931
7075
  ghfind deep-dive owner/repo --json
6932
7076
  Text output (no flag) prints the same sections formatted for the terminal.
6933
7077
 
7078
+ ## Finding skills
7079
+ ghfind skill search QUERY # skills installed on this machine
7080
+ ghfind skill search QUERY --remote # the public skills.sh ecosystem
7081
+ Results carry path+description (local) or install command (registry, read-only --
7082
+ nothing is installed). The MCP server exposes the same search as
7083
+ ghfind_skill_search.
6934
7084
  ## Rate limits
6935
7085
 
6936
7086
  60 req/hr unauthenticated, 5,000 with GITHUB_TOKEN (env var or \`ghfind login\`).
@@ -6942,6 +7092,14 @@ Bookmarks live in ghfind's local state, not GitHub. An agent can read the
6942
7092
  release feed with:
6943
7093
  ghfind --releases --json
6944
7094
  Marking releases seen and editing bookmarks is a TUI action; skip it.
7095
+ ## Local state (read-only)
7096
+ ghfind bookmarks [QUERY] --json # saved repos, newest first
7097
+ ghfind history [QUERY] --json # past searches
7098
+ ghfind saved --json # named searches
7099
+ ghfind topics --json # popular GitHub topics
7100
+ ghfind readme owner/repo # print a repo README
7101
+ ghfind share owner/repo --as gh-cli|markdown|plain|short
7102
+ These never write local state; skip them when nothing needs reading.
6945
7103
 
6946
7104
  ## Token efficiency
6947
7105
 
@@ -6950,35 +7108,272 @@ you only need fullNames, and --limit to cap payload size (default 50, max 100).
6950
7108
  `
6951
7109
  };
6952
7110
  }
6953
- var registry = /* @__PURE__ */ new Map();
6954
- function registerSkill(skill) {
6955
- registry.set(skill.name, skill);
7111
+ var registry = /* @__PURE__ */ new Map();
7112
+ function registerSkill(skill) {
7113
+ registry.set(skill.name, skill);
7114
+ }
7115
+ function getSkill(name) {
7116
+ return registry.get(name);
7117
+ }
7118
+ function listSkills() {
7119
+ return [...registry.values()].sort((a, b) => {
7120
+ if (a.builtin !== b.builtin) return a.builtin ? -1 : 1;
7121
+ return a.name.localeCompare(b.name);
7122
+ });
7123
+ }
7124
+ function ensureBuiltinSkill() {
7125
+ if (!registry.has("ghfind-cli")) {
7126
+ registerSkill(buildGhfindSkill());
7127
+ }
7128
+ }
7129
+ function formatSkillCatalog() {
7130
+ ensureBuiltinSkill();
7131
+ const lines = listSkills().map(
7132
+ (s) => ` ${s.name.padEnd(16)} ${s.description}`
7133
+ );
7134
+ return [
7135
+ "Available agent skills:",
7136
+ ...lines,
7137
+ "",
7138
+ `Run \`ghfind skill <name>\` to print one (ghfind v${getVersion()}).`
7139
+ ].join("\n");
7140
+ }
7141
+
7142
+ // src/skill-finder.ts
7143
+ import { readdirSync, readFileSync as readFileSync7 } from "fs";
7144
+ import { homedir as homedir4 } from "os";
7145
+ import { delimiter, join as join7 } from "path";
7146
+ var REGISTRY_SEARCH_ENDPOINT = "https://skills.sh/api/search";
7147
+ var MIN_QUERY_LENGTH = 2;
7148
+ var MAX_SKILL_RESULTS = 100;
7149
+ var SAFE_ID_RE = /^[A-Za-z0-9._/-]+$/;
7150
+ function displayPath(path) {
7151
+ const home = homedir4();
7152
+ const normalized = path.replace(/\\/g, "/");
7153
+ const homeNorm = home.replace(/\\/g, "/").replace(/\/$/, "");
7154
+ if (normalized === homeNorm) return "~";
7155
+ if (normalized.startsWith(`${homeNorm}/`)) {
7156
+ return `~${normalized.slice(homeNorm.length)}`;
7157
+ }
7158
+ return normalized;
7159
+ }
7160
+ function defaultSkillRoots(cwd = process.cwd()) {
7161
+ const home = homedir4();
7162
+ return [
7163
+ join7(home, ".codex", "skills"),
7164
+ join7(home, ".agents", "skills"),
7165
+ join7(cwd, ".codex", "skills"),
7166
+ join7(cwd, ".github", "skills")
7167
+ ];
7168
+ }
7169
+ function resolveSkillRoots(cwd = process.cwd()) {
7170
+ const override = process.env.GHFIND_SKILL_ROOTS;
7171
+ const paths = override?.trim() ? override.split(delimiter).filter((p) => p.trim()).map((p) => p.trim()) : defaultSkillRoots(cwd);
7172
+ return paths.map((path) => ({ path, label: displayPath(path) }));
7173
+ }
7174
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git"]);
7175
+ var MAX_DEPTH = 4;
7176
+ function unquote(value) {
7177
+ const trimmed = value.trim();
7178
+ if (trimmed.length >= 2 && (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'"))) {
7179
+ return trimmed.slice(1, -1).trim();
7180
+ }
7181
+ return trimmed;
7182
+ }
7183
+ function parseSkillFrontmatter(text) {
7184
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
7185
+ if (!match) return null;
7186
+ const lines = match[1].split(/\r?\n/);
7187
+ const fields = {};
7188
+ for (let i = 0; i < lines.length; i++) {
7189
+ const field = /^([A-Za-z_][\w-]*):[ \t]*(.*)$/.exec(lines[i]);
7190
+ if (!field) continue;
7191
+ const key = field[1].toLowerCase();
7192
+ const inline = field[2].trim();
7193
+ if (inline === ">" || inline === "|" || /^[>|][-+]?$/.test(inline)) {
7194
+ const literal = inline.startsWith("|");
7195
+ const parts = [];
7196
+ while (i + 1 < lines.length && /^[ \t]+\S/.test(lines[i + 1])) {
7197
+ parts.push(lines[++i].trim());
7198
+ }
7199
+ fields[key] = literal ? parts.join("\n") : parts.join(" ");
7200
+ } else {
7201
+ fields[key] = unquote(inline);
7202
+ }
7203
+ }
7204
+ const name = fields.name ?? "";
7205
+ if (!name) return null;
7206
+ return { name, description: fields.description ?? "" };
7207
+ }
7208
+ var scanCache = /* @__PURE__ */ new Map();
7209
+ function scanLocalSkills(roots = resolveSkillRoots()) {
7210
+ const key = roots.map((r) => `${r.path}\0${r.label}`).join("");
7211
+ const cached2 = scanCache.get(key);
7212
+ if (cached2) return cached2;
7213
+ const byName = /* @__PURE__ */ new Map();
7214
+ for (const root of roots) {
7215
+ for (const dir of walkDirs(root.path)) {
7216
+ const entries = listDir(dir);
7217
+ if (!entries) continue;
7218
+ if (!entries.some((e) => e.isFile() && e.name === "SKILL.md")) continue;
7219
+ const file = join7(dir, "SKILL.md");
7220
+ try {
7221
+ const parsed = parseSkillFrontmatter(readFileSync7(file, "utf-8"));
7222
+ if (!parsed || byName.has(parsed.name)) continue;
7223
+ byName.set(parsed.name, {
7224
+ name: parsed.name,
7225
+ description: parsed.description.replace(/\s+/g, " ").trim(),
7226
+ path: file,
7227
+ origin: root.label
7228
+ });
7229
+ } catch {
7230
+ }
7231
+ }
7232
+ }
7233
+ const hits = [...byName.values()];
7234
+ scanCache.set(key, hits);
7235
+ return hits;
7236
+ }
7237
+ function listDir(dir) {
7238
+ try {
7239
+ return readdirSync(dir, { withFileTypes: true });
7240
+ } catch {
7241
+ return null;
7242
+ }
7243
+ }
7244
+ function* walkDirs(root) {
7245
+ const queue = [[root, 0]];
7246
+ while (queue.length > 0) {
7247
+ const [dir, depth] = queue.shift();
7248
+ const entries = listDir(dir);
7249
+ if (!entries) continue;
7250
+ yield dir;
7251
+ if (depth >= MAX_DEPTH) continue;
7252
+ for (const entry of entries) {
7253
+ if (!entry.isDirectory() || SKIP_DIRS.has(entry.name)) continue;
7254
+ queue.push([join7(dir, entry.name), depth + 1]);
7255
+ }
7256
+ }
7257
+ }
7258
+ function localMatchTier(hit, needle) {
7259
+ const name = hit.name.toLowerCase();
7260
+ if (name === needle) return 0;
7261
+ if (name.startsWith(needle)) return 1;
7262
+ if (name.includes(needle)) return 2;
7263
+ if (hit.description.toLowerCase().includes(needle)) return 3;
7264
+ return null;
7265
+ }
7266
+ function rankLocalSkills(hits, query, limit = MAX_SKILL_RESULTS) {
7267
+ const needle = query.trim().toLowerCase();
7268
+ return hits.map((hit) => ({ hit, tier: localMatchTier(hit, needle) })).filter(
7269
+ (entry) => entry.tier !== null
7270
+ ).sort((a, b) => a.tier - b.tier || a.hit.name.localeCompare(b.hit.name)).slice(0, clampLimit(limit)).map((entry) => entry.hit);
7271
+ }
7272
+ function clampLimit(limit) {
7273
+ const n = Math.trunc(Number(limit));
7274
+ if (!Number.isFinite(n)) return MAX_SKILL_RESULTS;
7275
+ return Math.min(Math.max(n, 1), MAX_SKILL_RESULTS);
7276
+ }
7277
+ function assertSearchableQuery(query) {
7278
+ const trimmed = query.trim();
7279
+ if (trimmed.length < MIN_QUERY_LENGTH) {
7280
+ throw new Error(
7281
+ `Query must be at least ${MIN_QUERY_LENGTH} characters (got "${trimmed}").`
7282
+ );
7283
+ }
7284
+ return trimmed;
7285
+ }
7286
+ function searchLocalSkills(query, opts = {}) {
7287
+ const trimmed = assertSearchableQuery(query);
7288
+ const skills = rankLocalSkills(
7289
+ scanLocalSkills(opts.roots ?? resolveSkillRoots()),
7290
+ trimmed,
7291
+ opts.limit ?? MAX_SKILL_RESULTS
7292
+ );
7293
+ return { query: trimmed, source: "local", count: skills.length, skills };
7294
+ }
7295
+ function normalizeRegistrySkill(raw) {
7296
+ if (typeof raw !== "object" || raw === null) return null;
7297
+ const obj = raw;
7298
+ const source = typeof obj.source === "string" ? obj.source : "";
7299
+ const skillId = typeof obj.skillId === "string" ? obj.skillId : "";
7300
+ const hasTraversal = (s) => s === ".." || s.includes("..");
7301
+ if (!SAFE_ID_RE.test(source) || !SAFE_ID_RE.test(skillId) || hasTraversal(source) || hasTraversal(skillId)) {
7302
+ return null;
7303
+ }
7304
+ const name = typeof obj.name === "string" && obj.name.trim() ? obj.name : skillId;
7305
+ const installs = typeof obj.installs === "number" ? obj.installs : 0;
7306
+ const id = typeof obj.id === "string" && obj.id ? obj.id : `${source}/${skillId}`;
7307
+ return {
7308
+ id,
7309
+ source,
7310
+ skillId,
7311
+ name,
7312
+ installs,
7313
+ installCommand: `npx skills add ${source}@${skillId}`,
7314
+ url: `https://skills.sh/${source}/${skillId}`
7315
+ };
7316
+ }
7317
+ async function searchRegistrySkills(query, opts = {}) {
7318
+ const trimmed = assertSearchableQuery(query);
7319
+ const limit = clampLimit(opts.limit ?? MAX_SKILL_RESULTS);
7320
+ const fetchImpl = opts.fetchImpl ?? fetch;
7321
+ const url = `${REGISTRY_SEARCH_ENDPOINT}?q=${encodeURIComponent(trimmed)}&limit=${limit}`;
7322
+ let res;
7323
+ try {
7324
+ res = await fetchImpl(url, {
7325
+ headers: { Accept: "application/json", "User-Agent": "ghfind/1.0" }
7326
+ });
7327
+ } catch (err) {
7328
+ throw new Error(
7329
+ `Could not reach skills.sh: ${err instanceof Error ? err.message : String(err)}`
7330
+ );
7331
+ }
7332
+ if (!res.ok) {
7333
+ throw new Error(
7334
+ `skills.sh returned HTTP ${res.status} for "${trimmed}". Retry, or search locally with --local.`
7335
+ );
7336
+ }
7337
+ let payload;
7338
+ try {
7339
+ payload = await res.json();
7340
+ } catch {
7341
+ throw new Error("skills.sh returned a response that was not JSON.");
7342
+ }
7343
+ const items = payload?.skills ?? [];
7344
+ const skills = items.map(normalizeRegistrySkill).filter((s) => s !== null).slice(0, limit);
7345
+ return { query: trimmed, source: "registry", count: skills.length, skills };
6956
7346
  }
6957
- function getSkill(name) {
6958
- return registry.get(name);
7347
+ async function searchSkills(query, opts = {}) {
7348
+ if ((opts.source ?? "local") === "registry") {
7349
+ return searchRegistrySkills(query, opts);
7350
+ }
7351
+ return searchLocalSkills(query, opts);
6959
7352
  }
6960
- function listSkills() {
6961
- return [...registry.values()].sort((a, b) => {
6962
- if (a.builtin !== b.builtin) return a.builtin ? -1 : 1;
6963
- return a.name.localeCompare(b.name);
6964
- });
7353
+ function formatSkillSearchJson(result) {
7354
+ return JSON.stringify(result, null, 2);
6965
7355
  }
6966
- function ensureBuiltinSkill() {
6967
- if (!registry.has("ghfind-cli")) {
6968
- registerSkill(buildGhfindSkill());
7356
+ function shortDescription(description, width = 100) {
7357
+ if (description.length <= width) return description;
7358
+ return `${description.slice(0, width - 1).trimEnd()}\u2026`;
7359
+ }
7360
+ function formatSkillSearchText(result) {
7361
+ if (result.count === 0) {
7362
+ return result.source === "local" ? `No installed skills match "${result.query}". Try --remote to search the skills.sh ecosystem.` : `No skills.sh entries match "${result.query}".`;
6969
7363
  }
7364
+ if (result.source === "local") {
7365
+ return result.skills.map(
7366
+ (s) => `${s.name} (${s.origin})
7367
+ ${shortDescription(s.description)}`
7368
+ ).join("\n");
7369
+ }
7370
+ return result.skills.map(
7371
+ (s) => `${s.name} ${s.installs.toLocaleString()} installs [${s.source}]
7372
+ ${s.installCommand}`
7373
+ ).join("\n");
6970
7374
  }
6971
- function formatSkillCatalog() {
6972
- ensureBuiltinSkill();
6973
- const lines = listSkills().map(
6974
- (s) => ` ${s.name.padEnd(16)} ${s.description}`
6975
- );
6976
- return [
6977
- "Available agent skills:",
6978
- ...lines,
6979
- "",
6980
- `Run \`ghfind skill <name>\` to print one (ghfind v${getVersion()}).`
6981
- ].join("\n");
7375
+ function formatSkillSearchNames(result) {
7376
+ return result.skills.map((s) => s.name).join("\n");
6982
7377
  }
6983
7378
 
6984
7379
  // src/mcp-server.ts
@@ -7043,6 +7438,7 @@ function asMcpToolError(err) {
7043
7438
  }
7044
7439
  var TRENDING_QUERY = { keywords: [], qualifiers: [], raw: "trending" };
7045
7440
  var REPO_SORTS = ["best-match", "stars", "updated", "forks"];
7441
+ var SKILL_SOURCES = ["local", "registry"];
7046
7442
  var TRENDING_SINCE = ["daily", "weekly", "monthly"];
7047
7443
  var PKG_SORTS = ["best-match", "score", "downloads", "name"];
7048
7444
  var TOOLS = [
@@ -7300,19 +7696,14 @@ var TOOLS = [
7300
7696
  throw new McpToolError("repos must contain 2-5 repo fullNames.");
7301
7697
  }
7302
7698
  const token = resolveToken(state, args.token);
7303
- const provider = createGitHubSearch(void 0, token ? [token] : []);
7304
7699
  const found = [];
7305
7700
  const missing = [];
7306
7701
  for (const fullName of names) {
7307
- const res = await provider.search(parseQuery(fullName), {
7308
- limit: 1,
7309
- sort: "stars",
7310
- json: false,
7311
- verbose: false,
7312
- token
7313
- });
7314
- if (res.repos.length > 0) found.push(res.repos[0]);
7315
- else missing.push(fullName);
7702
+ try {
7703
+ found.push(await resolveRepoFromRef(fullName, token));
7704
+ } catch {
7705
+ missing.push(fullName);
7706
+ }
7316
7707
  }
7317
7708
  if (found.length < 2) {
7318
7709
  throw new McpToolError(
@@ -7387,6 +7778,52 @@ Not found: ${missing.join(", ")}` : "");
7387
7778
  data: { name: "ghfind-cli" }
7388
7779
  };
7389
7780
  }
7781
+ },
7782
+ {
7783
+ name: "ghfind_skill_search",
7784
+ title: "Search agent skills",
7785
+ description: "Search installed agent skills (SKILL.md files under the user's skill directories) or the public skills.sh ecosystem by keyword, and return matching skills with their descriptions. Read-only: it never installs anything - registry hits include the `npx skills add` command for the user to run deliberately. Use when the user asks to find a skill for a task, or asks what skills are available.",
7786
+ inputSchema: {
7787
+ type: "object",
7788
+ properties: {
7789
+ query: {
7790
+ type: "string",
7791
+ description: 'Keyword to match against skill names and descriptions, e.g. "changelog" or "testing" (2+ characters).'
7792
+ },
7793
+ source: {
7794
+ type: "string",
7795
+ description: "Where to search: 'local' for skills installed on this machine (default), 'registry' for the public skills.sh ecosystem.",
7796
+ enum: [...SKILL_SOURCES]
7797
+ },
7798
+ limit: {
7799
+ type: "number",
7800
+ description: "Max results, 1-100 (default 20).",
7801
+ minimum: 1,
7802
+ maximum: 100
7803
+ }
7804
+ },
7805
+ required: ["query"]
7806
+ },
7807
+ async run(args) {
7808
+ const source = coerceEnum(
7809
+ args.source,
7810
+ SKILL_SOURCES,
7811
+ "local"
7812
+ );
7813
+ const limit = coerceInt(args.limit, 20, 1, 100);
7814
+ const result = await searchSkills(
7815
+ String(args.query ?? ""),
7816
+ { source, limit }
7817
+ );
7818
+ const where = result.source === "local" ? "installed skills on this machine" : "the public skills.sh ecosystem";
7819
+ const header = `${result.count} match(es) for "${result.query}" in ${where}:`;
7820
+ return {
7821
+ text: `${header}
7822
+
7823
+ ${formatSkillSearchText(result)}`,
7824
+ data: { ...result }
7825
+ };
7826
+ }
7390
7827
  }
7391
7828
  ];
7392
7829
  function toolSummaries() {
@@ -7465,7 +7902,7 @@ async function processServerLine(line, state, config) {
7465
7902
  title: "ghfind \u2014 GitHub search for agents",
7466
7903
  version: getVersion()
7467
7904
  },
7468
- instructions: "ghfind gives agents GitHub search: repositories, trending, npm packages, org/user profiles, repo comparison and deep-dives. Call ghfind_skill for the full CLI guide. Unauthenticated requests are rate-limited to 60/hr; pass a token or set GITHUB_TOKEN for 5,000/hr."
7905
+ instructions: "ghfind gives agents GitHub search: repositories, trending, npm packages, org/user profiles, repo comparison and deep-dives. ghfind_skill_search finds agent skills, either installed on this machine or in the public skills.sh ecosystem. Call ghfind_skill for the full CLI guide. Unauthenticated requests are rate-limited to 60/hr; pass a token or set GITHUB_TOKEN for 5,000/hr."
7469
7906
  })
7470
7907
  );
7471
7908
  return;
@@ -7569,7 +8006,16 @@ async function runMcpServer(options = {}) {
7569
8006
  // src/cli.ts
7570
8007
  process.env.GHFIND_CLI_RUN = "1";
7571
8008
  var __dirname3 = dirname4(fileURLToPath4(import.meta.url));
7572
- var PKG_DIR3 = join6(__dirname3, "..");
8009
+ var PKG_DIR3 = join8(__dirname3, "..");
8010
+ var SORT_VALUES = [
8011
+ "best-match",
8012
+ "stars",
8013
+ "updated",
8014
+ "forks",
8015
+ "score",
8016
+ "downloads",
8017
+ "name"
8018
+ ];
7573
8019
  function parseArgs(args) {
7574
8020
  const flags = {
7575
8021
  query: "",
@@ -7593,7 +8039,16 @@ function parseArgs(args) {
7593
8039
  registry: "npm",
7594
8040
  compare: [],
7595
8041
  doctor: false,
7596
- mcp: false
8042
+ mcp: false,
8043
+ bookmarks: false,
8044
+ history: false,
8045
+ topics: false,
8046
+ saved: false,
8047
+ remote: false,
8048
+ local: false,
8049
+ copy: false,
8050
+ raw: false,
8051
+ limitExplicit: false
7597
8052
  };
7598
8053
  const queryParts = [];
7599
8054
  for (let i = 0; i < args.length; i++) {
@@ -7650,6 +8105,7 @@ function parseArgs(args) {
7650
8105
  break;
7651
8106
  case "--limit": {
7652
8107
  const parsed = parseInt(args[++i], 10);
8108
+ flags.limitExplicit = true;
7653
8109
  if (Number.isNaN(parsed) || parsed < 1 || parsed > 100) {
7654
8110
  console.error(`Invalid limit: must be 1-100. Using default 50.`);
7655
8111
  flags.limit = 50;
@@ -7658,9 +8114,17 @@ function parseArgs(args) {
7658
8114
  }
7659
8115
  break;
7660
8116
  }
7661
- case "--sort":
7662
- flags.sort = args[++i];
8117
+ case "--sort": {
8118
+ const sort = args[++i];
8119
+ if (!sort || !SORT_VALUES.includes(sort)) {
8120
+ console.error(
8121
+ `Invalid sort: ${sort ?? "(missing)"} (use best-match|stars|updated|forks).`
8122
+ );
8123
+ process.exit(1);
8124
+ }
8125
+ flags.sort = sort;
7663
8126
  break;
8127
+ }
7664
8128
  case "--token":
7665
8129
  flags.token = args[++i];
7666
8130
  break;
@@ -7682,9 +8146,56 @@ function parseArgs(args) {
7682
8146
  case "--completion":
7683
8147
  flags.completion = args[++i];
7684
8148
  break;
8149
+ case "--remote":
8150
+ flags.remote = true;
8151
+ break;
8152
+ case "--local":
8153
+ flags.local = true;
8154
+ break;
8155
+ case "--copy":
8156
+ flags.copy = true;
8157
+ break;
8158
+ case "--raw":
8159
+ flags.raw = true;
8160
+ break;
8161
+ case "--as":
8162
+ flags.sharedAs = args[++i];
8163
+ break;
7685
8164
  case "mcp":
7686
8165
  flags.mcp = true;
7687
8166
  break;
8167
+ case "bookmarks":
8168
+ flags.bookmarks = true;
8169
+ break;
8170
+ case "history":
8171
+ flags.history = true;
8172
+ break;
8173
+ case "topics":
8174
+ flags.topics = true;
8175
+ break;
8176
+ case "saved":
8177
+ flags.saved = true;
8178
+ break;
8179
+ case "readme": {
8180
+ const next = args[i + 1];
8181
+ if (next && !next.startsWith("-")) {
8182
+ flags.readme = next;
8183
+ i++;
8184
+ } else {
8185
+ flags.readme = "";
8186
+ }
8187
+ break;
8188
+ }
8189
+ case "share": {
8190
+ const next = args[i + 1];
8191
+ if (next && !next.startsWith("-")) {
8192
+ flags.share = next;
8193
+ i++;
8194
+ } else {
8195
+ flags.share = "";
8196
+ }
8197
+ break;
8198
+ }
7688
8199
  case "deep-dive": {
7689
8200
  const next = args[i + 1];
7690
8201
  if (next && !next.startsWith("-")) {
@@ -7695,6 +8206,17 @@ function parseArgs(args) {
7695
8206
  }
7696
8207
  case "skill": {
7697
8208
  const next = args[i + 1];
8209
+ if (next === "search") {
8210
+ i++;
8211
+ const query = args[i + 1];
8212
+ if (query && !query.startsWith("-")) {
8213
+ flags.skillSearch = query;
8214
+ i++;
8215
+ } else {
8216
+ flags.skillSearch = "";
8217
+ }
8218
+ break;
8219
+ }
7698
8220
  if (next && !next.startsWith("-")) {
7699
8221
  flags.skill = next;
7700
8222
  i++;
@@ -7773,6 +8295,13 @@ Usage:
7773
8295
  ghfind pkg <query> Search npm packages, text list
7774
8296
  ghfind mcp Run the MCP server for AI agents (stdio)
7775
8297
  ghfind skill [name] Print the agent skill guide (or catalog)
8298
+ ghfind bookmarks [query] Saved repos, newest first
8299
+ ghfind history [query] Past searches, newest first
8300
+ ghfind topics Browse popular GitHub topics
8301
+ ghfind saved Named searches saved in the TUI
8302
+ ghfind readme <owner/repo> Print a repository README
8303
+ ghfind share a/b --as gh-cli Share snippet (--copy to clipboard)
8304
+ ghfind skill search <query> Find skills locally, or --remote
7776
8305
  ghfind --watch <query> Watch mode (poll every Ns)
7777
8306
  ghfind init Run setup wizard
7778
8307
  ghfind login Import the gh CLI token or paste one
@@ -7797,14 +8326,19 @@ Options:
7797
8326
  --releases New-release feed for bookmarks
7798
8327
  --interval <s> Watch interval in seconds (default: 300)
7799
8328
  --completion <shell> Generate completions
8329
+ --remote skill search: query skills.sh instead of this machine
8330
+ --local skill search: force the local scan (default)
8331
+ --as <fmt> share format: markdown|plain|gh-cli|short
8332
+ --copy share: also copy the snippet to the clipboard
8333
+ --raw readme: print the raw markdown with no header
7800
8334
  `);
7801
8335
  return;
7802
8336
  }
7803
8337
  if (flags.completion) {
7804
8338
  const shell = flags.completion;
7805
8339
  try {
7806
- const content = readFileSync6(
7807
- join6(PKG_DIR3, "completions", `ghfind.${shell}`),
8340
+ const content = readFileSync8(
8341
+ join8(PKG_DIR3, "completions", `ghfind.${shell}`),
7808
8342
  "utf-8"
7809
8343
  );
7810
8344
  console.log(content);
@@ -7826,10 +8360,38 @@ Options:
7826
8360
  await runMcpServer({ token: flags.token });
7827
8361
  return;
7828
8362
  }
8363
+ if (flags.skillSearch !== void 0) {
8364
+ await runSkillSearch(flags);
8365
+ return;
8366
+ }
7829
8367
  if (flags.skill !== void 0) {
7830
8368
  printAgentSkill(flags.skill);
7831
8369
  return;
7832
8370
  }
8371
+ if (flags.bookmarks) {
8372
+ runBookmarks(flags);
8373
+ return;
8374
+ }
8375
+ if (flags.history) {
8376
+ runHistory(flags);
8377
+ return;
8378
+ }
8379
+ if (flags.saved) {
8380
+ runSavedSearches(flags);
8381
+ return;
8382
+ }
8383
+ if (flags.topics) {
8384
+ await runTopics(flags);
8385
+ return;
8386
+ }
8387
+ if (flags.readme !== void 0) {
8388
+ await runReadme(flags);
8389
+ return;
8390
+ }
8391
+ if (flags.share !== void 0) {
8392
+ await runShare(flags);
8393
+ return;
8394
+ }
7833
8395
  if (flags.pkg) {
7834
8396
  const interactive = !flags.query && !flags.json && !flags.csv && !flags.markdown && !flags.count && !flags.format;
7835
8397
  if (interactive) {
@@ -7963,6 +8525,239 @@ async function runDeepDive(flags, outputFormat) {
7963
8525
  process.exit(1);
7964
8526
  }
7965
8527
  }
8528
+ async function runSkillSearch(flags) {
8529
+ const query = flags.skillSearch ?? "";
8530
+ if (!query.trim()) {
8531
+ console.error(
8532
+ "Usage: ghfind skill search <query> [--remote] [--limit <n>] [--json]"
8533
+ );
8534
+ process.exit(1);
8535
+ }
8536
+ if (flags.remote && flags.local) {
8537
+ console.error(
8538
+ "Contradictory flags: pass either --remote or --local, not both."
8539
+ );
8540
+ process.exit(1);
8541
+ }
8542
+ const source = flags.remote ? "registry" : "local";
8543
+ try {
8544
+ const result = await searchSkills(query, {
8545
+ source,
8546
+ limit: flags.limit
8547
+ });
8548
+ if (flags.json) {
8549
+ console.log(formatSkillSearchJson(result));
8550
+ } else if (flags.format === "names") {
8551
+ console.log(formatSkillSearchNames(result));
8552
+ } else {
8553
+ console.log(formatSkillSearchText(result));
8554
+ }
8555
+ } catch (err) {
8556
+ console.error(err instanceof Error ? err.message : String(err));
8557
+ process.exit(1);
8558
+ }
8559
+ }
8560
+ function runBookmarks(flags) {
8561
+ const query = flags.query.trim();
8562
+ const bookmarks = query ? searchBookmarks(query) : getBookmarks();
8563
+ if (flags.json) {
8564
+ console.log(JSON.stringify(bookmarks, null, 2));
8565
+ return;
8566
+ }
8567
+ if (bookmarks.length === 0) {
8568
+ console.log(
8569
+ query ? `No bookmarks match "${query}".` : "No bookmarks yet. Press Space \u2192 Bookmark in the TUI to save one."
8570
+ );
8571
+ return;
8572
+ }
8573
+ if (flags.csv || flags.markdown) {
8574
+ const header = ["full_name", "stars", "language", "tags", "saved_at"];
8575
+ const rows = bookmarks.map((b) => [
8576
+ b.repo.fullName,
8577
+ String(b.repo.stars),
8578
+ b.repo.language ?? "",
8579
+ b.tags.join(" "),
8580
+ new Date(b.savedAt).toISOString().slice(0, 10)
8581
+ ]);
8582
+ console.log(
8583
+ flags.csv ? rowsToCsv(header, rows) : rowsToMarkdown(header, rows)
8584
+ );
8585
+ return;
8586
+ }
8587
+ for (const b of bookmarks) {
8588
+ const tags = b.tags.length > 0 ? ` [${b.tags.join(", ")}]` : "";
8589
+ console.log(
8590
+ `${b.repo.fullName} \u2014 ${b.repo.stars.toLocaleString()} ${b.repo.language ?? "n/a"}${tags}`
8591
+ );
8592
+ }
8593
+ }
8594
+ function runHistory(flags) {
8595
+ const limit = flags.limitExplicit ? flags.limit : 20;
8596
+ const query = flags.query.trim().toLowerCase();
8597
+ const entries = readHistory().filter((e) => !query || e.query.toLowerCase().includes(query)).slice(0, limit);
8598
+ if (flags.json) {
8599
+ console.log(JSON.stringify(entries, null, 2));
8600
+ return;
8601
+ }
8602
+ if (entries.length === 0) {
8603
+ console.log(
8604
+ query ? `No history matches "${query}".` : "No search history yet."
8605
+ );
8606
+ return;
8607
+ }
8608
+ if (flags.csv || flags.markdown) {
8609
+ const header = ["query", "mode", "results", "when"];
8610
+ const rows = entries.map((e) => [
8611
+ e.query,
8612
+ e.mode,
8613
+ String(e.resultCount),
8614
+ new Date(e.timestamp).toISOString()
8615
+ ]);
8616
+ console.log(
8617
+ flags.csv ? rowsToCsv(header, rows) : rowsToMarkdown(header, rows)
8618
+ );
8619
+ return;
8620
+ }
8621
+ for (const e of entries) {
8622
+ const when = new Date(e.timestamp).toISOString().slice(0, 10);
8623
+ console.log(
8624
+ `${e.query} \u2014 ${e.mode} \u2014 ${e.resultCount} results \u2014 ${when}`
8625
+ );
8626
+ }
8627
+ }
8628
+ function runSavedSearches(flags) {
8629
+ const saved = getSavedSearches();
8630
+ if (flags.json) {
8631
+ console.log(JSON.stringify(saved, null, 2));
8632
+ return;
8633
+ }
8634
+ if (saved.length === 0) {
8635
+ console.log("No saved searches yet. Save one from the TUI command menu.");
8636
+ return;
8637
+ }
8638
+ if (flags.csv || flags.markdown) {
8639
+ const header = ["name", "query", "mode", "limit"];
8640
+ const rows = saved.map((s) => [s.name, s.query, s.mode, String(s.limit)]);
8641
+ console.log(
8642
+ flags.csv ? rowsToCsv(header, rows) : rowsToMarkdown(header, rows)
8643
+ );
8644
+ return;
8645
+ }
8646
+ for (const s of saved) {
8647
+ console.log(`${s.name} \u2014 ${s.query} \u2014 ${s.mode} \u2014 limit ${s.limit}`);
8648
+ }
8649
+ }
8650
+ function rowsToCsv(header, rows) {
8651
+ const quote = (value) => /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
8652
+ return [header, ...rows].map((row) => row.map(quote).join(",")).join("\n");
8653
+ }
8654
+ function rowsToMarkdown(header, rows) {
8655
+ const cell = (value) => value.replace(/\|/g, "\\|");
8656
+ return [
8657
+ `| ${header.map(cell).join(" | ")} |`,
8658
+ `|${header.map(() => "---").join("|")}|`,
8659
+ ...rows.map((row) => `| ${row.map(cell).join(" | ")} |`)
8660
+ ].join("\n");
8661
+ }
8662
+ function truncate2(text, width) {
8663
+ if (text.length <= width) return text;
8664
+ return `${text.slice(0, width - 1).trimEnd()}\u2026`;
8665
+ }
8666
+ function plainText(html) {
8667
+ return html.replace(/<\/p>|<br\s*\/?>/gi, " ").replace(/<[^>]*>/g, "").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
8668
+ }
8669
+ async function runTopics(flags) {
8670
+ try {
8671
+ const topics = (await fetchTopics()).slice(0, flags.limit);
8672
+ if (flags.json) {
8673
+ console.log(JSON.stringify(topics, null, 2));
8674
+ return;
8675
+ }
8676
+ if (topics.length === 0) {
8677
+ console.log("No topics returned.");
8678
+ return;
8679
+ }
8680
+ for (const t3 of topics) {
8681
+ const description = truncate2(plainText(t3.description), 140);
8682
+ console.log(description ? `${t3.name} ${description}` : t3.name);
8683
+ }
8684
+ } catch (err) {
8685
+ console.error(err instanceof Error ? err.message : String(err));
8686
+ process.exit(1);
8687
+ }
8688
+ }
8689
+ async function runReadme(flags) {
8690
+ const ref = flags.readme ?? "";
8691
+ if (!ref) {
8692
+ console.error(
8693
+ "Usage: ghfind readme <owner/repo> [--json] [--raw] [--token <t>]"
8694
+ );
8695
+ process.exit(1);
8696
+ }
8697
+ try {
8698
+ const { owner, name } = repoFromRef(ref);
8699
+ const readme = await fetchReadme(owner, name, {
8700
+ // A bare ref carries no visibility info, so the token is never
8701
+ // forwarded to raw.githubusercontent.com.
8702
+ token: flags.token,
8703
+ private: false
8704
+ });
8705
+ if (!readme) {
8706
+ console.error(`No README found for ${owner}/${name}.`);
8707
+ process.exit(1);
8708
+ }
8709
+ if (flags.json) {
8710
+ console.log(
8711
+ JSON.stringify(
8712
+ { owner, name, sourceUrl: readme.sourceUrl, text: readme.text },
8713
+ null,
8714
+ 2
8715
+ )
8716
+ );
8717
+ return;
8718
+ }
8719
+ if (!flags.raw) {
8720
+ console.log(`# ${owner}/${name} README (${readme.sourceUrl})`);
8721
+ console.log("");
8722
+ }
8723
+ console.log(readme.text);
8724
+ } catch (err) {
8725
+ console.error(err instanceof Error ? err.message : String(err));
8726
+ process.exit(1);
8727
+ }
8728
+ }
8729
+ async function runShare(flags) {
8730
+ const ref = flags.share ?? "";
8731
+ if (!ref) {
8732
+ console.error(
8733
+ "Usage: ghfind share <owner/repo> [--as markdown|plain|gh-cli|short] [--copy]"
8734
+ );
8735
+ process.exit(1);
8736
+ }
8737
+ const SHARE_FORMATS = ["markdown", "plain", "gh-cli", "short"];
8738
+ const as = flags.sharedAs ?? "markdown";
8739
+ if (!SHARE_FORMATS.includes(as)) {
8740
+ console.error(
8741
+ `Invalid --as value: "${flags.sharedAs}" (use markdown|plain|gh-cli|short).`
8742
+ );
8743
+ process.exit(1);
8744
+ }
8745
+ try {
8746
+ repoFromRef(ref);
8747
+ const repo = await resolveRepoFromRef(ref, flags.token);
8748
+ const text = formatShare(repo, as);
8749
+ console.log(text);
8750
+ if (flags.copy) {
8751
+ const copied = await copyToClipboard(text);
8752
+ console.error(
8753
+ copied ? "Copied to clipboard." : "Could not copy to the clipboard on this platform."
8754
+ );
8755
+ }
8756
+ } catch (err) {
8757
+ console.error(err instanceof Error ? err.message : String(err));
8758
+ process.exit(1);
8759
+ }
8760
+ }
7966
8761
  async function runPackageSearch(flags) {
7967
8762
  if (flags.registry !== "npm") {
7968
8763
  console.error(`Registry "${flags.registry}" not yet supported (only npm).`);
@@ -8155,22 +8950,14 @@ async function runNonInteractive(flags, outputFormat) {
8155
8950
  }
8156
8951
  },
8157
8952
  compare: async (_ctx) => {
8158
- const search = createGitHubSearch(void 0, [flags.token ?? ""]);
8159
8953
  const repos = [];
8160
8954
  const missing = [];
8161
8955
  for (const fullName of flags.compare) {
8162
- const res = await search.search(
8163
- { keywords: [fullName], qualifiers: [], raw: fullName },
8164
- {
8165
- limit: 1,
8166
- sort: "stars",
8167
- json: false,
8168
- verbose: false,
8169
- token: flags.token
8170
- }
8171
- );
8172
- if (res.repos.length > 0) repos.push(...res.repos);
8173
- else missing.push(fullName);
8956
+ try {
8957
+ repos.push(await resolveRepoFromRef(fullName, flags.token));
8958
+ } catch {
8959
+ missing.push(fullName);
8960
+ }
8174
8961
  }
8175
8962
  if (repos.length < 2) {
8176
8963
  console.error(
@@ -8197,10 +8984,8 @@ Not found: ${missing.join(", ")}` : "";
8197
8984
  await handlers[key](ctx);
8198
8985
  }
8199
8986
  main().catch((err) => {
8200
- if (err instanceof Error && err.name === "SearchCliError") {
8201
- console.error(
8202
- err instanceof SearchCliError ? err.userMessage : err.message
8203
- );
8987
+ if (err instanceof SearchCliError) {
8988
+ console.error(err.userMessage);
8204
8989
  } else {
8205
8990
  console.error(err instanceof Error ? err.message : String(err));
8206
8991
  reportError(err);