github-search-cli 9.8.2 → 9.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -16,6 +16,22 @@ var NetworkError = class extends SearchCliError {
16
16
  this.name = "NetworkError";
17
17
  }
18
18
  };
19
+ var AuthError = class extends SearchCliError {
20
+ constructor() {
21
+ const msg = "\u26A0 Bad GitHub token (401). Press [t] to fix it, [u] to search without one";
22
+ super("GitHub API returned 401 (invalid or revoked token)", msg);
23
+ this.name = "AuthError";
24
+ }
25
+ };
26
+ var ForbiddenError = class extends SearchCliError {
27
+ constructor(detail = "GitHub API returned 403") {
28
+ const msg = `\u26A0 Forbidden (403) \u2014 ${detail}. Common causes: org SSO not authorizing the token, or a secondary rate limit. Press [t] to change the token`;
29
+ super(msg, msg);
30
+ this.detail = detail;
31
+ this.name = "ForbiddenError";
32
+ }
33
+ detail;
34
+ };
19
35
  var RateLimitError = class extends SearchCliError {
20
36
  constructor(hasToken, resetSeconds) {
21
37
  const msg = hasToken ? `\u26A0 API rate limit exceeded (5,000/hr). Resets in ${resetSeconds ?? "?"}s. [r]etry [c]hange token` : "\u26A0 Rate limited (60/hr). Set GITHUB_TOKEN for 5,000/hr. [r]etry";
@@ -25,16 +41,36 @@ var RateLimitError = class extends SearchCliError {
25
41
  }
26
42
  hasToken;
27
43
  };
44
+ var BadQueryError = class extends SearchCliError {
45
+ /** A concrete, corrected query the user can retry with. */
46
+ constructor(detail, hint) {
47
+ super(
48
+ `Invalid query: ${detail}`,
49
+ hint ? `\u26A0 Invalid query: ${detail}
50
+ Try: ${hint}` : `\u26A0 Invalid query: ${detail}`
51
+ );
52
+ this.detail = detail;
53
+ this.hint = hint;
54
+ this.name = "BadQueryError";
55
+ }
56
+ detail;
57
+ hint;
58
+ };
28
59
  var NoResultsError = class extends SearchCliError {
29
- constructor(query) {
60
+ /** Concrete loosened queries to retry, most promising first. */
61
+ constructor(query, relaxations = []) {
62
+ const tips = relaxations.length ? relaxations.map((r) => `Try: ${r}`).join("\n ") : "Tips: check spelling, try fewer qualifiers, or [b]rowse trending";
30
63
  super(
31
- `No results for "${query}"`,
32
- `\u{1F50D} No results for "${query}". Tips: check spelling, try fewer qualifiers, or [b]rowse trending`
64
+ relaxations.length > 0 ? `No results for "${query}" \u2014 try loosening: ${relaxations.join(" / ")}` : `No results for "${query}"`,
65
+ `\u{1F50D} No results for "${query}".
66
+ ${tips}`
33
67
  );
34
68
  this.query = query;
69
+ this.relaxations = relaxations;
35
70
  this.name = "NoResultsError";
36
71
  }
37
72
  query;
73
+ relaxations;
38
74
  };
39
75
  var ParseError = class extends SearchCliError {
40
76
  constructor(source, detail) {
@@ -195,9 +231,11 @@ function normalizeEnvelope(env) {
195
231
  var KNOWN_QUALIFIERS = [
196
232
  "language",
197
233
  "stars",
234
+ "forks",
198
235
  "fork",
199
236
  "archived",
200
237
  "topic",
238
+ "topics",
201
239
  "user",
202
240
  "org",
203
241
  "repo",
@@ -207,8 +245,80 @@ var KNOWN_QUALIFIERS = [
207
245
  "in",
208
246
  "size",
209
247
  "license",
210
- "created"
248
+ "created",
249
+ "followers"
211
250
  ];
251
+ var KNOWN_QUALIFIER_SET = new Set(KNOWN_QUALIFIERS);
252
+ var QUALIFIER_ALIASES = {
253
+ // language
254
+ langauge: "language",
255
+ lang: "language",
256
+ languge: "language",
257
+ // stars / forks
258
+ star: "stars",
259
+ strs: "stars",
260
+ forke: "forks",
261
+ // topic
262
+ topc: "topic",
263
+ tpics: "topic",
264
+ // user / org
265
+ usr: "user",
266
+ ogranization: "org",
267
+ orginzation: "org",
268
+ // date qualifiers
269
+ push: "pushed",
270
+ creat: "created",
271
+ updat: "updated",
272
+ // visibility
273
+ visiblity: "visibility",
274
+ visability: "visibility",
275
+ // license
276
+ licence: "license",
277
+ // size
278
+ siz: "size",
279
+ // archived
280
+ archive: "archived"
281
+ };
282
+ function editDistanceWithin(a, b, max) {
283
+ if (a === b) return 0;
284
+ if (Math.abs(a.length - b.length) > max) return max + 1;
285
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
286
+ for (let i = 1; i <= a.length; i++) {
287
+ const curr = [i];
288
+ let rowMin = i;
289
+ for (let j = 1; j <= b.length; j++) {
290
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
291
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
292
+ if (curr[j] < rowMin) rowMin = curr[j];
293
+ }
294
+ if (rowMin > max) return max + 1;
295
+ prev = curr;
296
+ }
297
+ return prev[b.length];
298
+ }
299
+ function resolveQualifierKey(key) {
300
+ const k = key.toLowerCase();
301
+ if (KNOWN_QUALIFIER_SET.has(k)) return k;
302
+ const alias = QUALIFIER_ALIASES[k];
303
+ if (alias) return alias;
304
+ if (k.length < 4) return k;
305
+ const maxDistance = k.length <= 5 ? 1 : 2;
306
+ let best;
307
+ let bestDistance = maxDistance + 1;
308
+ let ties = 0;
309
+ for (const known of KNOWN_QUALIFIERS) {
310
+ const d = editDistanceWithin(k, known, maxDistance);
311
+ if (d < bestDistance) {
312
+ bestDistance = d;
313
+ best = known;
314
+ ties = 1;
315
+ if (d === 0) break;
316
+ } else if (d === bestDistance && best !== void 0) {
317
+ ties++;
318
+ }
319
+ }
320
+ return best !== void 0 && ties === 1 ? best : k;
321
+ }
212
322
  var QUALIFIER_SUGGESTIONS = {
213
323
  l: ["language:"],
214
324
  s: ["stars:", "size:"],
@@ -224,13 +334,164 @@ var QUALIFIER_SUGGESTIONS = {
224
334
  a: ["archived:"]
225
335
  };
226
336
  function suggestFor(input) {
227
- const trimmed = input.trim().toLowerCase();
337
+ const trimmed = input.trim().toLowerCase().replace(/:$/, "");
228
338
  if (trimmed.length === 0) return [];
229
339
  if (trimmed.length === 1 && QUALIFIER_SUGGESTIONS[trimmed]) {
230
340
  return QUALIFIER_SUGGESTIONS[trimmed];
231
341
  }
232
- const matches = KNOWN_QUALIFIERS.filter((k) => k.startsWith(trimmed));
233
- return matches.map((k) => `${k}:`);
342
+ const resolved = resolveQualifierKey(trimmed);
343
+ const aliasHit = resolved !== trimmed && KNOWN_QUALIFIER_SET.has(resolved) ? [`${resolved}:`] : [];
344
+ const matches = KNOWN_QUALIFIERS.filter(
345
+ (k) => k.startsWith(trimmed) && !aliasHit.includes(`${k}:`)
346
+ ).map((k) => `${k}:`);
347
+ return [...aliasHit, ...matches];
348
+ }
349
+ var NUMERIC_QUALIFIERS = /* @__PURE__ */ new Set([
350
+ "forks",
351
+ "size",
352
+ "stars",
353
+ "topics",
354
+ "followers"
355
+ ]);
356
+ var BOOLEAN_QUALIFIERS = /* @__PURE__ */ new Set(["archived"]);
357
+ var VISIBILITY_VALUES = /* @__PURE__ */ new Set(["public", "private", "internal"]);
358
+ var IN_FIELDS = /* @__PURE__ */ new Set([
359
+ "name",
360
+ "description",
361
+ "topics",
362
+ "readme",
363
+ "comments"
364
+ ]);
365
+ var DATE_QUALIFIERS = /* @__PURE__ */ new Set(["created", "pushed", "updated"]);
366
+ var COMPARISON_OPERATORS = [">=", "<=", ">", "<"];
367
+ function redactToken(url, token) {
368
+ return token ? url.replace(token, "<token>") : url;
369
+ }
370
+ function splitComparison(value) {
371
+ for (const op of COMPARISON_OPERATORS) {
372
+ if (value.startsWith(op)) {
373
+ return { op, rest: value.slice(op.length) };
374
+ }
375
+ }
376
+ return { op: "", rest: value };
377
+ }
378
+ function parseCount(raw) {
379
+ const trimmed = raw.trim().replace(/_/g, "");
380
+ if (trimmed === "") return null;
381
+ const match = /^(\d+(?:\.\d+)?)([km])?$/i.exec(trimmed);
382
+ if (!match) return null;
383
+ if (!match[2] && match[1].includes(".")) return null;
384
+ const base = Number.parseFloat(match[1]);
385
+ if (!Number.isFinite(base)) return null;
386
+ const suffix = match[2]?.toLowerCase();
387
+ const multiplier = suffix === "k" ? 1e3 : suffix === "m" ? 1e6 : 1;
388
+ return Math.floor(base * multiplier);
389
+ }
390
+ function isValidISODate(raw) {
391
+ const trimmed = raw.trim();
392
+ const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?(?:Z|[+-]\d{2}:?\d{2})?)?$/.exec(
393
+ trimmed
394
+ );
395
+ if (!m) return false;
396
+ const [, y, mo, d, h = "0", mi = "0", s = "0"] = m;
397
+ const year = Number(y);
398
+ const month = Number(mo);
399
+ const day = Number(d);
400
+ if (month < 1 || month > 12) return false;
401
+ if (day < 1 || day > 31) return false;
402
+ if (Number(h) > 23 || Number(mi) > 59 || Number(s) > 59) return false;
403
+ const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
404
+ return day <= daysInMonth;
405
+ }
406
+ function normalizeQualifierValue(key, value) {
407
+ const normalizedKey = key.toLowerCase();
408
+ if (!NUMERIC_QUALIFIERS.has(normalizedKey)) return value;
409
+ if (value === "") return value;
410
+ const { op, rest } = splitComparison(value);
411
+ const rangeIdx = rest.indexOf("..");
412
+ if (rangeIdx > 0) {
413
+ const lo = parseCount(rest.slice(0, rangeIdx));
414
+ const hi = parseCount(rest.slice(rangeIdx + 2));
415
+ if (lo === null || hi === null) return value;
416
+ return `${op}${lo}..${hi}`;
417
+ }
418
+ if (!/[km]$/i.test(rest.trim())) return value;
419
+ const count = parseCount(rest);
420
+ if (count === null) return value;
421
+ return `${op || ">="}${count}`;
422
+ }
423
+ function validateQualifierValue(key, value) {
424
+ const k = key.toLowerCase();
425
+ if (!KNOWN_QUALIFIER_SET.has(k)) return null;
426
+ if (value.trim() === "") {
427
+ return `"${key}:" needs a value (e.g. ${key}:<something>)`;
428
+ }
429
+ if (NUMERIC_QUALIFIERS.has(k)) {
430
+ const { rest } = splitComparison(value.trim());
431
+ const rangeIdx = rest.indexOf("..");
432
+ if (rangeIdx > 0) {
433
+ const lo = parseCount(rest.slice(0, rangeIdx));
434
+ const hi = parseCount(rest.slice(rangeIdx + 2));
435
+ if (lo === null || hi === null) {
436
+ return `"${key}:${value}" is not a valid range \u2014 use ${k}:100..500 or ${k}:>=100`;
437
+ }
438
+ if (lo > hi) {
439
+ return `"${key}:${value}" has a reversed range (${lo} > ${hi})`;
440
+ }
441
+ return null;
442
+ }
443
+ if (parseCount(rest) === null) {
444
+ return `"${key}:${value}" is not a number \u2014 use ${k}:100, ${k}:>=100, or ${k}:1k..10k`;
445
+ }
446
+ return null;
447
+ }
448
+ if (k === "fork") {
449
+ const v = value.trim().toLowerCase();
450
+ if (v !== "true" && v !== "false" && v !== "only") {
451
+ return `"fork:${value}" is not valid \u2014 use fork:true, fork:false, or fork:only`;
452
+ }
453
+ return null;
454
+ }
455
+ if (BOOLEAN_QUALIFIERS.has(k)) {
456
+ const v = value.trim().toLowerCase();
457
+ if (v !== "true" && v !== "false") {
458
+ return `"${key}:${value}" is not valid \u2014 use ${k}:true or ${k}:false`;
459
+ }
460
+ return null;
461
+ }
462
+ if (k === "visibility") {
463
+ const v = value.trim().toLowerCase();
464
+ if (!VISIBILITY_VALUES.has(v)) {
465
+ return `"visibility:${value}" is not valid \u2014 use visibility:public, visibility:private, or visibility:internal`;
466
+ }
467
+ return null;
468
+ }
469
+ if (k === "in") {
470
+ const parts = value.split(",").map((p) => p.trim().toLowerCase());
471
+ if (parts.some((p) => !IN_FIELDS.has(p))) {
472
+ const bad = parts.find((p) => !IN_FIELDS.has(p));
473
+ return `"in:${value}" is not valid \u2014 ${bad ? `"${bad}" is not a ` : ""}searchable field. Try in:name, in:description, in:topics, or in:readme`;
474
+ }
475
+ return null;
476
+ }
477
+ if (DATE_QUALIFIERS.has(k)) {
478
+ const trimmed = value.trim();
479
+ const { rest } = splitComparison(trimmed);
480
+ const rangeIdx = rest.indexOf("..");
481
+ if (rangeIdx > 0) {
482
+ const lo = rest.slice(0, rangeIdx);
483
+ const hi = rest.slice(rangeIdx + 2);
484
+ if (!isValidISODate(lo) || !isValidISODate(hi)) {
485
+ return `"${key}:${value}" is not a valid date range \u2014 use ${k}:2020-01-01..2024-01-01`;
486
+ }
487
+ return null;
488
+ }
489
+ if (!isValidISODate(rest)) {
490
+ return `"${key}:${value}" is not a valid date \u2014 use ${k}:2024-01-01 (optionally with >, >=, <, <=)`;
491
+ }
492
+ return null;
493
+ }
494
+ return null;
234
495
  }
235
496
  function tokenize(input) {
236
497
  const tokens = [];
@@ -258,22 +519,46 @@ function tokenize(input) {
258
519
  }
259
520
  return tokens;
260
521
  }
522
+ var EMPTY_QUERY = {
523
+ keywords: [],
524
+ qualifiers: [],
525
+ raw: "",
526
+ corrections: []
527
+ };
528
+ function trendingQuery() {
529
+ return { ...EMPTY_QUERY, raw: "trending" };
530
+ }
261
531
  function parseQuery(raw) {
262
532
  const trimmed = raw.trim();
263
533
  if (trimmed === "") {
264
- return { keywords: [], qualifiers: [], raw };
534
+ return { keywords: [], qualifiers: [], raw, corrections: [] };
265
535
  }
266
536
  const keywords = [];
267
537
  const qualifiers = [];
538
+ const corrections = [];
268
539
  for (const token of tokenize(trimmed)) {
269
540
  const qualifier = matchQualifier(token);
270
541
  if (qualifier) {
271
- qualifiers.push(qualifier);
542
+ const resolvedKey = resolveQualifierKey(qualifier.key);
543
+ const normalizedValue = normalizeQualifierValue(
544
+ resolvedKey,
545
+ qualifier.value
546
+ );
547
+ const original = `${qualifier.negated ? "-" : ""}${qualifier.key}:${qualifier.value}`;
548
+ const corrected = `${qualifier.negated ? "-" : ""}${resolvedKey}:${normalizedValue}`;
549
+ if (original !== corrected) {
550
+ corrections.push({ from: original, to: corrected });
551
+ }
552
+ qualifiers.push({
553
+ key: resolvedKey,
554
+ value: normalizedValue,
555
+ negated: qualifier.negated
556
+ });
272
557
  } else {
273
558
  keywords.push(token);
274
559
  }
275
560
  }
276
- return { keywords, qualifiers, raw: trimmed };
561
+ return { keywords, qualifiers, raw: trimmed, corrections };
277
562
  }
278
563
  function matchQualifier(token) {
279
564
  const negated = token.startsWith("-");
@@ -282,8 +567,7 @@ function matchQualifier(token) {
282
567
  if (idx <= 0) return null;
283
568
  const key = body.slice(0, idx).toLowerCase();
284
569
  let value = body.slice(idx + 1);
285
- if (value === "") return null;
286
- if (!/^[a-z]+$/i.test(key)) return null;
570
+ if (!/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/i.test(key)) return null;
287
571
  if (value.startsWith("//")) return null;
288
572
  if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
289
573
  value = value.slice(1, -1);
@@ -316,11 +600,143 @@ function applyFlagFilters(query, flags) {
316
600
  });
317
601
  return { ...query, qualifiers };
318
602
  }
603
+ function withoutQualifier(query, index) {
604
+ const remaining = query.qualifiers.filter((_, i) => i !== index);
605
+ const parts = [...query.keywords];
606
+ for (const q of remaining) {
607
+ const needsQuote = q.value.includes(" ") && !q.value.startsWith('"');
608
+ const value = needsQuote ? `"${q.value}"` : q.value;
609
+ parts.push(`${q.negated ? "-" : ""}${q.key}:${value}`);
610
+ }
611
+ const text = parts.join(" ").trim();
612
+ return text === "" ? null : text;
613
+ }
319
614
  function validateQuery(query) {
320
615
  const has = (key) => query.qualifiers.some((q) => q.key === key && !q.negated);
321
616
  if (has("visibility") && has("private")) {
322
- throw new Error("Cannot combine `visibility:` and `private:` filters.");
617
+ throw new BadQueryError(
618
+ "Cannot combine `visibility:` and `private:` filters.",
619
+ `${query.qualifiers.filter((q) => q.key === "private" && !q.negated).map((q) => withoutQualifier(query, query.qualifiers.indexOf(q))).find((v) => v !== null) ?? query.raw}`
620
+ );
621
+ }
622
+ for (let i = 0; i < query.qualifiers.length; i++) {
623
+ const q = query.qualifiers[i];
624
+ const problem = validateQualifierValue(q.key, q.value);
625
+ if (problem) {
626
+ throw new BadQueryError(problem, withoutQualifier(query, i) ?? void 0);
627
+ }
628
+ }
629
+ }
630
+ var OVER_CONSTRAINT_WEIGHT = {
631
+ size: 100,
632
+ stars: 95,
633
+ forks: 80,
634
+ created: 75,
635
+ pushed: 70,
636
+ updated: 70,
637
+ visibility: 65,
638
+ repo: 60,
639
+ user: 55,
640
+ org: 50,
641
+ topic: 45,
642
+ in: 30,
643
+ license: 25,
644
+ language: 20,
645
+ fork: 15,
646
+ archived: 10
647
+ };
648
+ function loosenNumericBound(value) {
649
+ const { op, rest } = splitComparison(value.trim());
650
+ const count = parseCount(rest);
651
+ if (count === null || count === 0) return null;
652
+ const loosened = Math.max(1, Math.floor(count / 10));
653
+ return `${op || ">="}${loosened}`;
654
+ }
655
+ function loosenDateBound(value) {
656
+ const { op, rest } = splitComparison(value.trim());
657
+ if (!isValidISODate(rest)) return null;
658
+ const year = Number.parseInt(rest.slice(0, 4), 10);
659
+ if (!Number.isFinite(year)) return null;
660
+ const shifted = op === "<" || op === "<=" ? year + 2 : Math.max(1970, year - 2);
661
+ return { op, date: `${shifted}${rest.slice(4)}` };
662
+ }
663
+ function suggestRelaxations(query, limit = 3) {
664
+ const out = [];
665
+ for (let i = 0; i < query.qualifiers.length; i++) {
666
+ const q = query.qualifiers[i];
667
+ const key = q.key.toLowerCase();
668
+ if (q.negated) continue;
669
+ const weight = OVER_CONSTRAINT_WEIGHT[key] ?? 40;
670
+ const emit = (relaxed, qualifier) => {
671
+ const rest = query.qualifiers.filter((_, j) => j !== i).map((other) => {
672
+ const needsQuote = other.value.includes(" ") && !other.value.startsWith('"');
673
+ const value = needsQuote ? `"${other.value}"` : other.value;
674
+ return `${other.negated ? "-" : ""}${other.key}:${value}`;
675
+ });
676
+ const text = [...query.keywords, ...rest, relaxed].join(" ").trim();
677
+ out.push({ suggestion: text, qualifier, confidence: weight });
678
+ };
679
+ if (NUMERIC_QUALIFIERS.has(key) || key === "followers") {
680
+ const rangeIdx = q.value.indexOf("..");
681
+ if (rangeIdx > 0) {
682
+ const hi = parseCount(q.value.slice(rangeIdx + 2));
683
+ if (hi !== null) {
684
+ const lo = q.value.slice(0, rangeIdx);
685
+ emit(`${key}:${lo}..${Math.floor(hi / 10) || hi * 2}`, key);
686
+ }
687
+ } else {
688
+ const looser = loosenNumericBound(q.value);
689
+ if (looser) emit(`${key}:${looser}`, key);
690
+ else {
691
+ const dropped2 = withoutQualifier(query, i);
692
+ if (dropped2) {
693
+ out.push({
694
+ suggestion: dropped2,
695
+ qualifier: key,
696
+ confidence: weight - 30
697
+ });
698
+ }
699
+ }
700
+ }
701
+ continue;
702
+ }
703
+ if (DATE_QUALIFIERS.has(key)) {
704
+ const loosened = loosenDateBound(q.value);
705
+ if (loosened) {
706
+ emit(`${key}:${loosened.op || ">="}${loosened.date}`, key);
707
+ } else {
708
+ const dropped2 = withoutQualifier(query, i);
709
+ if (dropped2) {
710
+ out.push({
711
+ suggestion: dropped2,
712
+ qualifier: key,
713
+ confidence: weight - 30
714
+ });
715
+ }
716
+ }
717
+ continue;
718
+ }
719
+ const dropped = withoutQualifier(query, i);
720
+ if (dropped) {
721
+ out.push({
722
+ suggestion: dropped,
723
+ qualifier: key,
724
+ confidence: weight - 30
725
+ });
726
+ }
323
727
  }
728
+ const seen = /* @__PURE__ */ new Set();
729
+ const unique = out.filter((r) => {
730
+ if (seen.has(r.suggestion)) return false;
731
+ seen.add(r.suggestion);
732
+ return true;
733
+ });
734
+ unique.sort((a, b) => b.confidence - a.confidence);
735
+ return unique.slice(0, limit).map((r) => ({
736
+ ...r,
737
+ // Clamp so a "drop the qualifier" hint never reports a negative score.
738
+ confidence: Math.max(0, r.confidence)
739
+ }));
324
740
  }
325
741
  function buildGitHubQuery(query) {
326
742
  const parts = [];
@@ -417,6 +833,11 @@ var GitHubSearchAdapter = class {
417
833
  });
418
834
  const remainingStr = res.headers.get("x-ratelimit-remaining");
419
835
  rateLimitRemaining = remainingStr !== null ? Number(remainingStr) : void 0;
836
+ if (process.env.GHFIND_LOG) {
837
+ this.logger.debug(
838
+ `[github] ${res.status} remaining=${remainingStr ?? "none"} url=${redactToken(pagedUrl, token)}`
839
+ );
840
+ }
420
841
  if (res.status === 403) {
421
842
  if (remainingStr === "0" || remainingStr === null) {
422
843
  if (this.tokens.length > 0 && attempt < this.tokens.length) {
@@ -428,9 +849,25 @@ var GitHubSearchAdapter = class {
428
849
  }
429
850
  const body = await res.text().catch(() => "");
430
851
  this.logger.error(
431
- `[github] non-rate-limit 403: ${body.slice(0, 200)}`
852
+ `[github] non-rate-limit 403: remaining=${remainingStr} retry-after=${res.headers.get("retry-after") ?? "none"} x-ratelimit-reset=${res.headers.get("x-ratelimit-reset") ?? "none"} authed=${token ? "yes" : "no"} url=${redactToken(pagedUrl, token)}`
432
853
  );
433
- throw new NetworkError();
854
+ this.logger.error(`[github] 403 body: ${body.slice(0, 400)}`);
855
+ let reason = "no reason given";
856
+ try {
857
+ const parsed2 = JSON.parse(body);
858
+ if (typeof parsed2.message === "string" && parsed2.message) {
859
+ reason = parsed2.message;
860
+ }
861
+ } catch {
862
+ }
863
+ throw new ForbiddenError(reason);
864
+ }
865
+ if (res.status === 401) {
866
+ const body = await res.text().catch(() => "");
867
+ this.logger.error(
868
+ `[github] auth rejected (401): ${body.slice(0, 200)}`
869
+ );
870
+ throw new AuthError();
434
871
  }
435
872
  if (!res.ok) {
436
873
  const body = await res.text().catch(() => "");
@@ -467,6 +904,7 @@ var GitHubSearchAdapter = class {
467
904
  lastErr = void 0;
468
905
  break;
469
906
  } catch (err) {
907
+ if (err instanceof RateLimitError) throw err;
470
908
  lastErr = err;
471
909
  if (rateLimited) break;
472
910
  }
@@ -1715,7 +2153,7 @@ var cached = null;
1715
2153
  function getVersion() {
1716
2154
  if (cached) return cached;
1717
2155
  if (true) {
1718
- cached = "9.8.2";
2156
+ cached = "9.9.0";
1719
2157
  return cached;
1720
2158
  }
1721
2159
  try {
@@ -1974,22 +2412,22 @@ async function runWatch(opts, onChange, onError) {
1974
2412
  let previous = [];
1975
2413
  let failCount = 0;
1976
2414
  let intervalId = null;
2415
+ if (!opts.trending) {
2416
+ validateQuery(applyFlagFilters(parseQuery(opts.query), {}));
2417
+ }
1977
2418
  const tick = async () => {
1978
2419
  try {
1979
2420
  let repos;
1980
2421
  if (opts.trending) {
1981
2422
  const since = opts.query === "weekly" ? "weekly" : opts.query === "monthly" ? "monthly" : "daily";
1982
2423
  const trendingSearch = createTrendingSearch();
1983
- const response = await trendingSearch.search(
1984
- { keywords: [], qualifiers: [], raw: "trending" },
1985
- {
1986
- limit: opts.limit,
1987
- sort: opts.sort,
1988
- json: false,
1989
- verbose: false,
1990
- trendingSince: since
1991
- }
1992
- );
2424
+ const response = await trendingSearch.search(trendingQuery(), {
2425
+ limit: opts.limit,
2426
+ sort: opts.sort,
2427
+ json: false,
2428
+ verbose: false,
2429
+ trendingSince: since
2430
+ });
1993
2431
  repos = response.repos;
1994
2432
  } else {
1995
2433
  const parsed = applyFlagFilters(parseQuery(opts.query), {});
@@ -2865,6 +3303,122 @@ function comparisonMarkdown(repos) {
2865
3303
  return [header, sep, ...rows].join("\n");
2866
3304
  }
2867
3305
 
3306
+ // src/markdown-images.ts
3307
+ var MIN_IMAGE_PIXELS = 100;
3308
+ var IMG_TAG = /<img\b[^>]*?src\s*=\s*["']([^"']+)["'][^>]*>/gi;
3309
+ var ALT_ATTR = /\balt\s*=\s*["']([^"']*)["']/i;
3310
+ var LENGTH_ATTR = /\b(width|height)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
3311
+ var PIXEL_VALUE = /^(\d+)(?:px)?$/i;
3312
+ var HTML_TAG = /<\/?[a-z][^>]*>/gi;
3313
+ function declaredImageSize(tag) {
3314
+ const values = {};
3315
+ for (const [, name, dq, sq] of tag.matchAll(LENGTH_ATTR)) {
3316
+ const raw = dq ?? sq ?? "";
3317
+ const match = PIXEL_VALUE.exec(raw.trim());
3318
+ if (!match) continue;
3319
+ const n = Number.parseInt(match[1], 10);
3320
+ if (Number.isFinite(n) && n > 0) {
3321
+ values[name] = n;
3322
+ }
3323
+ }
3324
+ if (values.width === void 0 && values.height === void 0)
3325
+ return void 0;
3326
+ return {
3327
+ width: values.width ?? values.height ?? 0,
3328
+ height: values.height ?? values.width ?? 0
3329
+ };
3330
+ }
3331
+ function declaresTooSmall(size) {
3332
+ if (!size) return false;
3333
+ return size.width < MIN_IMAGE_PIXELS || size.height < MIN_IMAGE_PIXELS;
3334
+ }
3335
+ function imagesFromHtml(raw) {
3336
+ if (!raw || !/<img\b/i.test(raw)) return [];
3337
+ const withoutTags = raw.replace(IMG_TAG, "").replace(/<\/?(?:p|div|center|br|a|picture|source)\b[^>]*>/gi, "").trim();
3338
+ if (withoutTags) return [];
3339
+ const refs = [];
3340
+ for (const match of raw.matchAll(IMG_TAG)) {
3341
+ const tag = match[0];
3342
+ const alt = ALT_ATTR.exec(tag)?.[1] ?? "";
3343
+ refs.push({ href: match[1], alt, declared: declaredImageSize(tag) });
3344
+ }
3345
+ return refs;
3346
+ }
3347
+ function inlinePlainText(tokens) {
3348
+ if (!tokens) return "";
3349
+ let out = "";
3350
+ for (const token of tokens) {
3351
+ if (token.type === "image") continue;
3352
+ if (token.type === "html") {
3353
+ out += (token.raw ?? "").replace(HTML_TAG, "");
3354
+ continue;
3355
+ }
3356
+ if (token.type === "br") {
3357
+ out += "\n";
3358
+ continue;
3359
+ }
3360
+ if (token.text && (!token.tokens || token.tokens.length === 0)) {
3361
+ out += token.text;
3362
+ continue;
3363
+ }
3364
+ if (token.tokens?.length) {
3365
+ out += inlinePlainText(token.tokens);
3366
+ continue;
3367
+ }
3368
+ if (token.raw) out += token.raw.replace(HTML_TAG, "");
3369
+ }
3370
+ return out;
3371
+ }
3372
+ function extractImageRefs(token) {
3373
+ const images = [];
3374
+ let linkUrl;
3375
+ const collect = (inline2) => {
3376
+ if (inline2.type === "image") {
3377
+ if (inline2.href)
3378
+ images.push({ href: inline2.href, alt: inline2.text ?? "", linkUrl });
3379
+ return true;
3380
+ }
3381
+ if (inline2.type === "link" || inline2.type === "linkReference") {
3382
+ if (inline2.tokens?.length) {
3383
+ const outer = linkUrl;
3384
+ linkUrl = inline2.href || outer;
3385
+ const ok = inline2.tokens.every(collect);
3386
+ linkUrl = outer;
3387
+ return ok;
3388
+ }
3389
+ return false;
3390
+ }
3391
+ if (inline2.type === "html") {
3392
+ const refs = imagesFromHtml(inline2.raw);
3393
+ if (refs.length > 0) {
3394
+ images.push(...refs);
3395
+ return true;
3396
+ }
3397
+ return !(inline2.raw ?? "").replace(HTML_TAG, "").trim();
3398
+ }
3399
+ if (inline2.type === "text" || inline2.type === "escape") {
3400
+ return !(inline2.text ?? "").trim();
3401
+ }
3402
+ return false;
3403
+ };
3404
+ if (token.type === "image" && token.href) {
3405
+ return [{ href: token.href, alt: token.text ?? "" }];
3406
+ }
3407
+ if (token.type === "html") {
3408
+ const refs = imagesFromHtml(token.raw);
3409
+ return refs.length > 0 ? dropTooSmall(refs) : null;
3410
+ }
3411
+ const inline = token.tokens ?? [];
3412
+ if (inline.length === 0) return null;
3413
+ for (const child of inline) {
3414
+ if (!collect(child)) return null;
3415
+ }
3416
+ return images.length > 0 ? dropTooSmall(images) : null;
3417
+ }
3418
+ function dropTooSmall(refs) {
3419
+ return refs.filter((image) => !declaresTooSmall(image.declared));
3420
+ }
3421
+
2868
3422
  // src/image-loader.ts
2869
3423
  var MAX_IMAGE_BYTES = 6 * 1024 * 1024;
2870
3424
  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;
@@ -3032,6 +3586,23 @@ async function loadImageBytes(url, opts) {
3032
3586
  function readmeBaseUrl(owner, repo, branch, path = "README.md") {
3033
3587
  return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`;
3034
3588
  }
3589
+ async function imageSizeFromBytes(bytes) {
3590
+ const ImageCtor = globalThis.Bun?.Image;
3591
+ if (!ImageCtor) return void 0;
3592
+ try {
3593
+ const meta = await new ImageCtor(bytes).metadata();
3594
+ if (!meta.width || !meta.height) return void 0;
3595
+ return { width: meta.width, height: meta.height };
3596
+ } catch {
3597
+ return void 0;
3598
+ }
3599
+ }
3600
+ async function shouldRenderImage(bytes, declared) {
3601
+ if (declared) return !declaresTooSmall(declared);
3602
+ const measured = await imageSizeFromBytes(bytes);
3603
+ if (!measured) return true;
3604
+ return !declaresTooSmall(measured);
3605
+ }
3035
3606
 
3036
3607
  // src/readme.ts
3037
3608
  var CANDIDATES = [
@@ -3267,6 +3838,7 @@ import {
3267
3838
  SelectRenderable,
3268
3839
  ScrollBoxRenderable as ScrollBoxRenderable3
3269
3840
  } from "@opentui/core";
3841
+ import { appendFileSync as appendFileSync2 } from "fs";
3270
3842
 
3271
3843
  // src/trending.ts
3272
3844
  import {
@@ -3340,6 +3912,17 @@ function buildHelpSections() {
3340
3912
  { keys: "PageUp", action: "Scroll to top of results" }
3341
3913
  ]
3342
3914
  },
3915
+ {
3916
+ title: " Error Recovery ",
3917
+ titleColor: "red",
3918
+ rows: [
3919
+ { keys: "r", action: "Retry after an error" },
3920
+ { keys: "t", action: "Fix GitHub token (401/403)" },
3921
+ { keys: "u", action: "Search without a token (401)" },
3922
+ { keys: "c", action: "Change token (rate limit)" }
3923
+ ],
3924
+ note: "Active only while the matching error is in the status bar."
3925
+ },
3343
3926
  {
3344
3927
  title: " Trending Mode ",
3345
3928
  titleColor: "yellow",
@@ -3552,9 +4135,35 @@ function touchSavedSearch(name) {
3552
4135
 
3553
4136
  // src/session.ts
3554
4137
  var SESSION_FILE = "session.json";
4138
+ var MAX_QUERY_LENGTH = 200;
4139
+ function sanitizeQuery(query) {
4140
+ if (typeof query !== "string") return void 0;
4141
+ const trimmed = query.trim();
4142
+ if (trimmed.length === 0 || trimmed.length > MAX_QUERY_LENGTH) {
4143
+ return void 0;
4144
+ }
4145
+ if (trimmed.startsWith("{") && trimmed.endsWith("}") || trimmed.startsWith("[") && trimmed.endsWith("]")) {
4146
+ return void 0;
4147
+ }
4148
+ return trimmed;
4149
+ }
4150
+ function sanitizeSession(state) {
4151
+ if (!state || typeof state !== "object") return null;
4152
+ const mode = state.mode === "trending" || state.mode === "packages" || state.mode === "search" ? state.mode : "search";
4153
+ const trendingTab = typeof state.trendingTab === "string" && state.trendingTab.length > 0 ? state.trendingTab : "This Week";
4154
+ const limit = typeof state.limit === "number" && Number.isFinite(state.limit) ? state.limit : 50;
4155
+ const sort = typeof state.sort === "string" ? state.sort : "best-match";
4156
+ return {
4157
+ mode,
4158
+ query: sanitizeQuery(state.query) ?? "",
4159
+ sort,
4160
+ limit,
4161
+ trendingTab
4162
+ };
4163
+ }
3555
4164
  function saveSession(state) {
3556
4165
  try {
3557
- writeJSON(SESSION_FILE, state);
4166
+ writeJSON(SESSION_FILE, sanitizeSession(state));
3558
4167
  } catch (err) {
3559
4168
  if (process.env.DEBUG)
3560
4169
  console.error(
@@ -3563,7 +4172,7 @@ function saveSession(state) {
3563
4172
  }
3564
4173
  }
3565
4174
  function restoreSession() {
3566
- return readJSON(SESSION_FILE, null);
4175
+ return sanitizeSession(readJSON(SESSION_FILE, null));
3567
4176
  }
3568
4177
 
3569
4178
  // src/markdown-view.ts
@@ -3869,83 +4478,6 @@ async function rasterizeImage(bytes, opts) {
3869
4478
  }
3870
4479
  }
3871
4480
 
3872
- // src/markdown-images.ts
3873
- var IMG_TAG = /<img\b[^>]*?src\s*=\s*["']([^"']+)["'][^>]*>/gi;
3874
- var ALT_ATTR = /\balt\s*=\s*["']([^"']*)["']/i;
3875
- var HTML_TAG = /<\/?[a-z][^>]*>/gi;
3876
- function imagesFromHtml(raw) {
3877
- if (!raw || !/<img\b/i.test(raw)) return [];
3878
- const withoutTags = raw.replace(IMG_TAG, "").replace(/<\/?(?:p|div|center|br|a|picture|source)\b[^>]*>/gi, "").trim();
3879
- if (withoutTags) return [];
3880
- const refs = [];
3881
- for (const match of raw.matchAll(IMG_TAG)) {
3882
- const tag = match[0];
3883
- const alt = ALT_ATTR.exec(tag)?.[1] ?? "";
3884
- refs.push({ href: match[1], alt });
3885
- }
3886
- return refs;
3887
- }
3888
- function inlinePlainText(tokens) {
3889
- if (!tokens) return "";
3890
- let out = "";
3891
- for (const token of tokens) {
3892
- if (token.type === "image") continue;
3893
- if (token.type === "html") {
3894
- out += (token.raw ?? "").replace(HTML_TAG, "");
3895
- continue;
3896
- }
3897
- if (token.type === "br") {
3898
- out += "\n";
3899
- continue;
3900
- }
3901
- if (token.text && (!token.tokens || token.tokens.length === 0)) {
3902
- out += token.text;
3903
- continue;
3904
- }
3905
- if (token.tokens?.length) {
3906
- out += inlinePlainText(token.tokens);
3907
- continue;
3908
- }
3909
- if (token.raw) out += token.raw.replace(HTML_TAG, "");
3910
- }
3911
- return out;
3912
- }
3913
- function extractImageRefs(token) {
3914
- const images = [];
3915
- const collect = (inline2) => {
3916
- if (inline2.type === "image") {
3917
- if (inline2.href)
3918
- images.push({ href: inline2.href, alt: inline2.text ?? "" });
3919
- return true;
3920
- }
3921
- if (inline2.type === "html") {
3922
- const refs = imagesFromHtml(inline2.raw);
3923
- if (refs.length > 0) {
3924
- images.push(...refs);
3925
- return true;
3926
- }
3927
- return !(inline2.raw ?? "").replace(HTML_TAG, "").trim();
3928
- }
3929
- if (inline2.type === "text" || inline2.type === "escape") {
3930
- return !(inline2.text ?? "").trim();
3931
- }
3932
- return false;
3933
- };
3934
- if (token.type === "image" && token.href) {
3935
- return [{ href: token.href, alt: token.text ?? "" }];
3936
- }
3937
- if (token.type === "html") {
3938
- const refs = imagesFromHtml(token.raw);
3939
- return refs.length > 0 ? refs : null;
3940
- }
3941
- const inline = token.tokens ?? [];
3942
- if (inline.length === 0) return null;
3943
- for (const child of inline) {
3944
- if (!collect(child)) return null;
3945
- }
3946
- return images.length > 0 ? images : null;
3947
- }
3948
-
3949
4481
  // src/markdown-view.ts
3950
4482
  var MAX_IMAGE_ROWS = 24;
3951
4483
  var RASTER_CACHE_LIMIT = 32;
@@ -3981,17 +4513,19 @@ function hexToRgb(hex) {
3981
4513
  function rgba(color) {
3982
4514
  return RGBA.fromValues(color[0], color[1], color[2], 255);
3983
4515
  }
3984
- function imageStyledText(image) {
4516
+ function imageStyledText(image, linkUrl) {
3985
4517
  const chunks = [];
3986
4518
  toImageRuns(image).forEach((runs, rowIndex) => {
3987
4519
  if (rowIndex > 0) chunks.push({ __isChunk: true, text: "\n" });
3988
4520
  for (const run2 of runs) {
3989
- chunks.push({
4521
+ const chunk = {
3990
4522
  __isChunk: true,
3991
4523
  text: run2.text,
3992
4524
  fg: rgba(run2.top),
3993
4525
  bg: rgba(run2.bottom)
3994
- });
4526
+ };
4527
+ if (linkUrl) chunk.link = { url: linkUrl };
4528
+ chunks.push(chunk);
3995
4529
  }
3996
4530
  });
3997
4531
  return new StyledText3(chunks);
@@ -4018,15 +4552,21 @@ function createMarkdownView(opts) {
4018
4552
  tableOptions: { style: "grid" },
4019
4553
  width: "100%"
4020
4554
  });
4021
- const caption = (label, color = muted()) => new TextRenderable3(renderer, {
4022
- content: ` ${label}`,
4023
- fg: color,
4555
+ const caption = (label, linkUrl, color = muted()) => new TextRenderable3(renderer, {
4556
+ content: new StyledText3([
4557
+ {
4558
+ __isChunk: true,
4559
+ text: ` ${label}`,
4560
+ fg: RGBA.fromHex(color),
4561
+ ...linkUrl ? { link: { url: linkUrl } } : {}
4562
+ }
4563
+ ]),
4024
4564
  bg: bg(),
4025
4565
  wrapMode: "word",
4026
4566
  flexShrink: 0,
4027
4567
  width: "100%"
4028
4568
  });
4029
- const rasterize = (url) => {
4569
+ const rasterize = (url, declared) => {
4030
4570
  const maxCols = Math.max(8, Math.floor(opts.getImageWidth?.() ?? 100));
4031
4571
  const key = `${maxCols}|${url}`;
4032
4572
  const cached2 = rasterCache.get(key);
@@ -4036,6 +4576,7 @@ function createMarkdownView(opts) {
4036
4576
  if (!loader) return null;
4037
4577
  const bytes = await loader(url);
4038
4578
  if (!bytes) return null;
4579
+ if (!await shouldRenderImage(bytes, declared)) return null;
4039
4580
  return rasterizeImage(bytes, {
4040
4581
  maxCols,
4041
4582
  maxRows: maxImageRows,
@@ -4053,24 +4594,25 @@ function createMarkdownView(opts) {
4053
4594
  const imageNode = (image) => {
4054
4595
  const label = `\u{1F5BC} ${image.alt.trim() || "image"}`;
4055
4596
  const url = imageBase ? resolveReadmeImageUrl(image.href, imageBase) : null;
4056
- if (!imagesEnabled || !url || !opts.loadImage) return caption(label);
4597
+ if (!imagesEnabled || !url || !opts.loadImage)
4598
+ return caption(label, image.linkUrl);
4057
4599
  const container = new BoxRenderable3(renderer, {
4058
4600
  flexDirection: "column",
4059
4601
  flexShrink: 0,
4060
4602
  width: "100%"
4061
4603
  });
4062
- const placeholder = caption(`${label} (loading\u2026)`);
4604
+ const placeholder = caption(`${label} (loading\u2026)`, image.linkUrl);
4063
4605
  container.add(placeholder);
4064
- void rasterize(url).then(
4606
+ void rasterize(url, image.declared).then(
4065
4607
  (cells) => {
4066
4608
  if (container.isDestroyed || !container.parent) return;
4067
4609
  container.remove(placeholder);
4068
4610
  placeholder.destroy();
4069
- container.add(caption(label));
4611
+ container.add(caption(label, image.linkUrl));
4070
4612
  if (cells) {
4071
4613
  container.add(
4072
4614
  new TextRenderable3(renderer, {
4073
- content: imageStyledText(cells),
4615
+ content: imageStyledText(cells, image.linkUrl),
4074
4616
  bg: bg(),
4075
4617
  wrapMode: "none",
4076
4618
  selectable: false,
@@ -4112,17 +4654,22 @@ function createMarkdownView(opts) {
4112
4654
  const buildRenderNode = () => (token, _context) => {
4113
4655
  if (token.type === "heading") return headingNode(token);
4114
4656
  const images = extractImageRefs(token);
4115
- if (images && images.length > 0) {
4116
- const box = new BoxRenderable3(renderer, {
4117
- flexDirection: "column",
4657
+ if (images === null) return void 0;
4658
+ if (images.length === 0) {
4659
+ return new BoxRenderable3(renderer, {
4118
4660
  flexShrink: 0,
4119
4661
  width: "100%",
4120
- marginTop: 1
4662
+ height: 0
4121
4663
  });
4122
- for (const image of images) box.add(imageNode(image));
4123
- return box;
4124
4664
  }
4125
- return void 0;
4665
+ const box = new BoxRenderable3(renderer, {
4666
+ flexDirection: "column",
4667
+ flexShrink: 0,
4668
+ width: "100%",
4669
+ marginTop: 1
4670
+ });
4671
+ for (const image of images) box.add(imageNode(image));
4672
+ return box;
4126
4673
  };
4127
4674
  renderable.renderNode = buildRenderNode();
4128
4675
  return {
@@ -4325,19 +4872,40 @@ function shouldCheckUpdate() {
4325
4872
  if (Date.now() - state.lastCheck < CHECK_INTERVAL_MS) return false;
4326
4873
  return true;
4327
4874
  }
4875
+ function semverParts(v) {
4876
+ const core = v.trim().replace(/^v/i, "").split(/[-+]/)[0];
4877
+ return core.split(".").map((s) => {
4878
+ const n = Number(s);
4879
+ return Number.isFinite(n) ? n : 0;
4880
+ });
4881
+ }
4882
+ function isPrerelease(v) {
4883
+ const core = v.trim().replace(/^v/i, "");
4884
+ const plus = core.indexOf("+");
4885
+ const coreOnly = plus === -1 ? core : core.slice(0, plus);
4886
+ return coreOnly.includes("-");
4887
+ }
4328
4888
  function isNewerVersion(current, latest) {
4329
4889
  if (typeof Bun !== "undefined" && Bun.semver) {
4330
4890
  return Bun.semver.order(current, latest) < 0;
4331
4891
  }
4332
- const curParts = current.split(".").map((s) => Number(s.split("-")[0]));
4333
- const newParts = latest.split(".").map((s) => Number(s.split("-")[0]));
4892
+ const curParts = semverParts(current);
4893
+ const newParts = semverParts(latest);
4894
+ let order = 0;
4334
4895
  for (let i = 0; i < Math.max(curParts.length, newParts.length); i++) {
4335
4896
  const c = curParts[i] ?? 0;
4336
4897
  const n = newParts[i] ?? 0;
4337
- if (n > c) return true;
4338
- if (n < c) return false;
4898
+ if (n > c) {
4899
+ order = 1;
4900
+ break;
4901
+ }
4902
+ if (n < c) {
4903
+ order = -1;
4904
+ break;
4905
+ }
4339
4906
  }
4340
- return false;
4907
+ if (order !== 0) return order > 0;
4908
+ return isPrerelease(current) && !isPrerelease(latest);
4341
4909
  }
4342
4910
  async function checkForUpdate(currentVersion) {
4343
4911
  if (process.env.DEBUG)
@@ -4444,6 +5012,17 @@ function recordPreUpdateState(currentVersion) {
4444
5012
  lastInstalledVersion: currentVersion
4445
5013
  });
4446
5014
  }
5015
+ function markPostUpgradeSeen(currentVersion) {
5016
+ try {
5017
+ const state = readUpdateState();
5018
+ if (state.lastInstalledVersion === currentVersion) return;
5019
+ if (isNewerVersion(currentVersion, state.lastInstalledVersion ?? "")) {
5020
+ return;
5021
+ }
5022
+ writeUpdateState({ ...state, lastInstalledVersion: currentVersion });
5023
+ } catch {
5024
+ }
5025
+ }
4447
5026
  function fetchReleaseNotes(version) {
4448
5027
  try {
4449
5028
  const notesPath = join6(PKG_DIR2, "release-notes", `${version}.md`);
@@ -4518,16 +5097,33 @@ var colors = {
4518
5097
  surfaceDim: "#1a1919",
4519
5098
  borderAccent: "#1a1919"
4520
5099
  };
4521
- var logger = {
4522
- debug: () => {
4523
- },
4524
- info: () => {
4525
- },
4526
- warn: () => {
4527
- },
4528
- error: () => {
4529
- }
4530
- };
5100
+ function createTuiLogger() {
5101
+ const target = process.env.GHFIND_LOG;
5102
+ if (!target) {
5103
+ return { debug: () => {
5104
+ }, info: () => {
5105
+ }, warn: () => {
5106
+ }, error: () => {
5107
+ } };
5108
+ }
5109
+ const write = (level, msg) => {
5110
+ try {
5111
+ appendFileSync2(
5112
+ target,
5113
+ `[${(/* @__PURE__ */ new Date()).toISOString()}] [${level}] ${msg}
5114
+ `
5115
+ );
5116
+ } catch {
5117
+ }
5118
+ };
5119
+ return {
5120
+ debug: (m) => write("debug", m),
5121
+ info: (m) => write("info", m),
5122
+ warn: (m) => write("warn", m),
5123
+ error: (m) => write("error", m)
5124
+ };
5125
+ }
5126
+ var logger = createTuiLogger();
4531
5127
  var SORT_MODES = [
4532
5128
  { key: "best-match", label: "best-match" },
4533
5129
  { key: "stars", label: "stars" },
@@ -4572,7 +5168,7 @@ async function launchBrowser(theme_override) {
4572
5168
  const root = renderer.root;
4573
5169
  root.flexDirection = "column";
4574
5170
  const config = loadConfig();
4575
- const githubToken = config.githubToken || process.env.GITHUB_TOKEN;
5171
+ let githubToken = config.githubToken || process.env.GITHUB_TOKEN;
4576
5172
  const theme = loadTheme(theme_override || config.theme);
4577
5173
  Object.assign(colors, theme);
4578
5174
  if (terminalBg) {
@@ -4755,6 +5351,10 @@ async function launchBrowser(theme_override) {
4755
5351
  currentMode = "search";
4756
5352
  showLanding(false);
4757
5353
  showSearchMode();
5354
+ if (currentQueryInput) {
5355
+ searchInput.value = currentQueryInput;
5356
+ renderer.requestRender();
5357
+ }
4758
5358
  }
4759
5359
  },
4760
5360
  {
@@ -5792,6 +6392,7 @@ async function launchBrowser(theme_override) {
5792
6392
  if (!state.lastInstalledVersion) return;
5793
6393
  if (state.lastInstalledVersion === currentVersion) return;
5794
6394
  const notes = fetchReleaseNotes(currentVersion);
6395
+ markPostUpgradeSeen(currentVersion);
5795
6396
  updateSelectedOption = 0;
5796
6397
  updateHeader.content = ` Updated ghfind ${state.lastInstalledVersion} \u2192 ${currentVersion}
5797
6398
  `;
@@ -6079,9 +6680,85 @@ async function launchBrowser(theme_override) {
6079
6680
  setStatus(nextTip());
6080
6681
  }
6081
6682
  }, 2e3);
6683
+ const tokenPromptBox = new BoxRenderable5(renderer, {
6684
+ visible: false,
6685
+ position: "absolute",
6686
+ top: 0,
6687
+ left: 0,
6688
+ width: "100%",
6689
+ flexDirection: "row",
6690
+ paddingY: 1,
6691
+ paddingLeft: 1,
6692
+ backgroundColor: colors.surface
6693
+ });
6694
+ const tokenPromptLabel = new TextRenderable5(renderer, {
6695
+ content: " GitHub token:",
6696
+ fg: colors.accent,
6697
+ height: 1
6698
+ });
6699
+ const tokenPromptInput = new InputRenderable(renderer, {
6700
+ placeholder: "paste token, Enter to save \u2014 Esc cancels",
6701
+ value: "",
6702
+ backgroundColor: colors.surface,
6703
+ textColor: colors.text,
6704
+ flexGrow: 1
6705
+ });
6706
+ tokenPromptBox.add(tokenPromptLabel);
6707
+ tokenPromptBox.add(tokenPromptInput);
6708
+ root.add(tokenPromptBox);
6709
+ let tokenPromptQuery = null;
6710
+ function showTokenPrompt(query) {
6711
+ tokenPromptQuery = query;
6712
+ tokenPromptInput.value = "";
6713
+ tokenPromptBox.visible = true;
6714
+ searchBox.visible = false;
6715
+ tokenPromptInput.focus();
6716
+ renderer.requestRender();
6717
+ }
6718
+ function hideTokenPrompt() {
6719
+ tokenPromptBox.visible = false;
6720
+ tokenPromptQuery = null;
6721
+ if (currentMode === "search" || currentMode === "packages") {
6722
+ searchBox.visible = true;
6723
+ searchInput.focus();
6724
+ }
6725
+ renderer.requestRender();
6726
+ }
6727
+ function persistToken2(token) {
6728
+ githubToken = token;
6729
+ config.githubToken = token;
6730
+ saveConfig(config);
6731
+ }
6732
+ tokenPromptInput.on("enter", () => {
6733
+ const token = tokenPromptInput.value.trim();
6734
+ const query = tokenPromptQuery;
6735
+ hideTokenPrompt();
6736
+ if (!token || !query) return;
6737
+ persistToken2(token);
6738
+ setStatus("Token saved");
6739
+ doSearch(query);
6740
+ });
6741
+ function fixTokenAction() {
6742
+ showTokenPrompt(currentQueryInput || "stars:>10000");
6743
+ }
6744
+ function unsetTokenAction() {
6745
+ githubToken = void 0;
6746
+ if (config.githubToken) {
6747
+ delete config.githubToken;
6748
+ saveConfig(config);
6749
+ }
6750
+ setStatus("Token cleared \u2014 searching without one");
6751
+ if (currentQueryInput) doSearch(currentQueryInput);
6752
+ }
6082
6753
  function setStatus(msg) {
6083
6754
  statusMgr.set("idle", msg);
6084
6755
  }
6756
+ let errorHint = null;
6757
+ function reportError2(err) {
6758
+ errorHint = err instanceof AuthError ? "auth" : err instanceof ForbiddenError ? "forbidden" : err instanceof RateLimitError ? "ratelimit" : err instanceof NetworkError ? "network" : null;
6759
+ const msg = err instanceof SearchCliError ? err.userMessage : err instanceof Error ? err.message : String(err);
6760
+ statusMgr.set("error", msg.slice(0, 60));
6761
+ }
6085
6762
  function buildSessionState() {
6086
6763
  const mode = currentMode === "trending" ? "trending" : currentMode === "packages" ? "packages" : "search";
6087
6764
  return {
@@ -6298,6 +6975,7 @@ async function launchBrowser(theme_override) {
6298
6975
  async function loadTrending() {
6299
6976
  currentMode = "trending";
6300
6977
  isLoading = true;
6978
+ errorHint = null;
6301
6979
  searchBox.visible = false;
6302
6980
  toolbarText.visible = false;
6303
6981
  trendingTabBox.visible = true;
@@ -6311,16 +6989,13 @@ async function launchBrowser(theme_override) {
6311
6989
  renderer.requestRender();
6312
6990
  try {
6313
6991
  const searchModule = new SearchModule(new TrendingAdapter());
6314
- const response = await searchModule.search(
6315
- { keywords: [], qualifiers: [], raw: "trending" },
6316
- {
6317
- limit: 25,
6318
- sort: "stars",
6319
- json: false,
6320
- verbose: false,
6321
- trendingSince: tabSince(trendingTab)
6322
- }
6323
- );
6992
+ const response = await searchModule.search(trendingQuery(), {
6993
+ limit: 25,
6994
+ sort: "stars",
6995
+ json: false,
6996
+ verbose: false,
6997
+ trendingSince: tabSince(trendingTab)
6998
+ });
6324
6999
  const fetched = response.repos;
6325
7000
  const since = tabSince(trendingTab);
6326
7001
  trendingPeriod = since === "daily" ? "today" : since === "weekly" ? "this week" : "this month";
@@ -6347,11 +7022,10 @@ async function launchBrowser(theme_override) {
6347
7022
  resultCount: fetched.length
6348
7023
  });
6349
7024
  } catch (err) {
6350
- const msg = err instanceof SearchCliError ? err.userMessage : err instanceof Error ? err.message : String(err);
6351
7025
  resultsSelect.options = [
6352
7026
  { name: " (error)", description: "", value: null }
6353
7027
  ];
6354
- statusMgr.set("error", msg.slice(0, 60));
7028
+ reportError2(err);
6355
7029
  }
6356
7030
  isLoading = false;
6357
7031
  renderer.requestRender();
@@ -6390,6 +7064,7 @@ async function launchBrowser(theme_override) {
6390
7064
  if (q === "" || isLoading) return;
6391
7065
  currentQueryInput = q;
6392
7066
  isLoading = true;
7067
+ errorHint = null;
6393
7068
  resultsSelect.options = [
6394
7069
  { name: " Searching packages...", description: "", value: null }
6395
7070
  ];
@@ -6427,6 +7102,7 @@ async function launchBrowser(theme_override) {
6427
7102
  async function doSearch(queryText, append = false) {
6428
7103
  const q = queryText.trim();
6429
7104
  if (q === "" || isLoading) return;
7105
+ errorHint = null;
6430
7106
  if (!append) currentPage = 1;
6431
7107
  deepDiveActive = false;
6432
7108
  isLoading = true;
@@ -6459,7 +7135,10 @@ async function launchBrowser(theme_override) {
6459
7135
  currentRepos = newRepos;
6460
7136
  }
6461
7137
  if (currentRepos.length === 0) {
6462
- throw new NoResultsError(q);
7138
+ throw new NoResultsError(
7139
+ q,
7140
+ suggestRelaxations(parsed).map((r) => r.suggestion)
7141
+ );
6463
7142
  } else {
6464
7143
  resultsSelect.options = currentRepos.map((r) => ({
6465
7144
  name: formatResultLine(r),
@@ -6489,8 +7168,7 @@ async function launchBrowser(theme_override) {
6489
7168
  { name: " (error)", description: "", value: null }
6490
7169
  ];
6491
7170
  }
6492
- const msg = err instanceof SearchCliError ? err.userMessage : err instanceof Error ? err.message : String(err);
6493
- statusMgr.set("error", msg.slice(0, 60));
7171
+ reportError2(err);
6494
7172
  }
6495
7173
  isLoading = false;
6496
7174
  renderer.requestRender();
@@ -6587,6 +7265,16 @@ ${pack.description ?? ""}`;
6587
7265
  }
6588
7266
  });
6589
7267
  renderer.keyInput.on("keypress", (key) => {
7268
+ if (tokenPromptBox.visible) {
7269
+ if (key.name === "escape") {
7270
+ hideTokenPrompt();
7271
+ setStatus("Token entry cancelled");
7272
+ renderer.requestRender();
7273
+ return;
7274
+ }
7275
+ if (key.name === "enter" || key.name === "return") return;
7276
+ return;
7277
+ }
6590
7278
  if (currentOverlay !== "none") {
6591
7279
  if (key.name === "escape") {
6592
7280
  if (currentOverlay === "leader") {
@@ -6890,6 +7578,27 @@ ${pack.description ?? ""}`;
6890
7578
  showLeaderMenu();
6891
7579
  return;
6892
7580
  }
7581
+ if (!searchInput.focused && errorHint) {
7582
+ if (key.name === "r") {
7583
+ errorHint = null;
7584
+ if (currentMode === "trending") loadTrending();
7585
+ else if (currentQueryInput) doSearch(currentQueryInput);
7586
+ renderer.requestRender();
7587
+ return;
7588
+ }
7589
+ if (key.name === "t" && (errorHint === "auth" || errorHint === "forbidden")) {
7590
+ fixTokenAction();
7591
+ return;
7592
+ }
7593
+ if (key.name === "u" && errorHint === "auth") {
7594
+ unsetTokenAction();
7595
+ return;
7596
+ }
7597
+ if (key.name === "c" && errorHint === "ratelimit") {
7598
+ fixTokenAction();
7599
+ return;
7600
+ }
7601
+ }
6893
7602
  if (key.name === "t" && !searchInput.focused) {
6894
7603
  const current = config.theme;
6895
7604
  const themes = listThemes();
@@ -6984,25 +7693,10 @@ ${pack.description ?? ""}`;
6984
7693
  }
6985
7694
  });
6986
7695
  const _currentVersion = getVersion();
6987
- if (!session?.mode) {
6988
- showLanding(true);
6989
- }
7696
+ showLanding(true);
6990
7697
  checkForUpdateAndShow();
6991
7698
  checkPostUpgradePanel();
6992
7699
  renderer.start();
6993
- if (session?.mode) {
6994
- if (currentMode === "trending") {
6995
- loadTrending();
6996
- } else {
6997
- searchInput.value = currentQueryInput;
6998
- if (currentQueryInput) {
6999
- doSearch(currentQueryInput);
7000
- } else {
7001
- showSearchMode();
7002
- }
7003
- searchInput.focus();
7004
- }
7005
- }
7006
7700
  renderer.requestRender();
7007
7701
  }
7008
7702
  function formatResultLine(repo) {
@@ -7436,7 +8130,7 @@ function formatPackageLines(pkgs) {
7436
8130
  function asMcpToolError(err) {
7437
8131
  return new McpToolError(err instanceof Error ? err.message : String(err));
7438
8132
  }
7439
- var TRENDING_QUERY = { keywords: [], qualifiers: [], raw: "trending" };
8133
+ var TRENDING_QUERY = trendingQuery();
7440
8134
  var REPO_SORTS = ["best-match", "stars", "updated", "forks"];
7441
8135
  var SKILL_SOURCES = ["local", "registry"];
7442
8136
  var TRENDING_SINCE = ["daily", "weekly", "monthly"];
@@ -7483,6 +8177,17 @@ var TOOLS = [
7483
8177
  const token = resolveToken(state, args.token);
7484
8178
  const provider = createGitHubSearch(void 0, token ? [token] : []);
7485
8179
  const parsed = parseQuery(query);
8180
+ try {
8181
+ validateQuery(parsed);
8182
+ } catch (err) {
8183
+ if (err instanceof BadQueryError) {
8184
+ return {
8185
+ text: `Invalid query: ${err.detail}${err.hint ? ` Try: ${err.hint}` : ""}`,
8186
+ data: { totalCount: 0, repos: [], invalidQuery: true }
8187
+ };
8188
+ }
8189
+ throw err;
8190
+ }
7486
8191
  const options = {
7487
8192
  limit,
7488
8193
  sort,
@@ -7490,7 +8195,18 @@ var TOOLS = [
7490
8195
  verbose: false,
7491
8196
  token
7492
8197
  };
7493
- const response = await provider.search(parsed, options);
8198
+ let response;
8199
+ try {
8200
+ response = await provider.search(parsed, options);
8201
+ } catch (err) {
8202
+ if (err instanceof RateLimitError) {
8203
+ return {
8204
+ text: `GitHub API rate limit exceeded for "${query}". Set a token (GITHUB_TOKEN or the tool's token argument) to raise the limit from 60/hr to 5,000/hr.`,
8205
+ data: { totalCount: 0, repos: [], rateLimited: true }
8206
+ };
8207
+ }
8208
+ throw err;
8209
+ }
7494
8210
  const repos = rankRepos(response.repos, sort);
7495
8211
  try {
7496
8212
  appendHistory({
@@ -8429,6 +9145,7 @@ Options:
8429
9145
  }
8430
9146
  function buildSearchContext(flags) {
8431
9147
  const parsed = applyFlagFilters(parseQuery(flags.query), {});
9148
+ validateQuery(parsed);
8432
9149
  return {
8433
9150
  query: flags.query,
8434
9151
  parsed,
@@ -8860,17 +9577,14 @@ async function runNonInteractive(flags, outputFormat) {
8860
9577
  console.error(err instanceof Error ? err.message : String(err));
8861
9578
  process.exit(1);
8862
9579
  }
8863
- const response = await trendingSearch.search(
8864
- { keywords: [], qualifiers: [], raw: "trending" },
8865
- {
8866
- limit: 25,
8867
- sort: "stars",
8868
- json: false,
8869
- verbose: false,
8870
- trendingSince: since,
8871
- ...language ? { trendingLanguage: language } : {}
8872
- }
8873
- );
9580
+ const response = await trendingSearch.search(trendingQuery(), {
9581
+ limit: 25,
9582
+ sort: "stars",
9583
+ json: false,
9584
+ verbose: false,
9585
+ trendingSince: since,
9586
+ ...language ? { trendingLanguage: language } : {}
9587
+ });
8874
9588
  if (outputFormat) {
8875
9589
  console.log(format(response.repos, outputFormat));
8876
9590
  } else if (flags.count) {