github-search-cli 9.8.1 → 9.8.3

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.
@@ -3,7 +3,8 @@
3
3
  * error, print it, and offer to open it in the user's browser.
4
4
  *
5
5
  * Privacy: never auto-posts. The user sees the URL and decides whether to
6
- * submit. We redact env vars and only include what the diagnostic needs.
6
+ * submit. Credentials found in argv or the error text are redacted before
7
+ * they can reach the URL, and a redacted report is never auto-opened.
7
8
  */
8
9
  import {
9
10
  platform as osPlatform,
@@ -24,6 +25,59 @@ void _osArch;
24
25
  const REPO = "codersdfs/search-cli";
25
26
  const NEW_ISSUE_URL = `https://github.com/${REPO}/issues/new`;
26
27
 
28
+ const SECRET_PLACEHOLDER = "***";
29
+
30
+ /** Flags whose *next* argument is a credential. */
31
+ const SECRET_FLAGS = /^(?:--token|-t)$/;
32
+
33
+ /** GitHub credential shapes: classic (ghp_/gho_/ghu_/ghs_/ghr_) and fine-grained. */
34
+ const SECRET_PATTERNS: RegExp[] = [
35
+ /gh[pousr]_[A-Za-z0-9]{16,}/g,
36
+ /github_pat_[A-Za-z0-9_]{20,}/g,
37
+ ];
38
+
39
+ /** Replace anything that looks like a token inside a free-form string. */
40
+ export function redactSecrets(text: string): string {
41
+ let out = text;
42
+ for (const pattern of SECRET_PATTERNS) {
43
+ out = out.replace(pattern, SECRET_PLACEHOLDER);
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /**
49
+ * Redact an argv: the value following a credential flag, `--token=…`/`-t=…`
50
+ * forms, and any bare argument that looks like a token.
51
+ */
52
+ export function redactArgv(argv: string[]): string[] {
53
+ const out: string[] = [];
54
+ let redactNext = false;
55
+ for (const arg of argv) {
56
+ if (redactNext) {
57
+ out.push(SECRET_PLACEHOLDER);
58
+ redactNext = false;
59
+ continue;
60
+ }
61
+ if (SECRET_FLAGS.test(arg)) {
62
+ out.push(arg);
63
+ redactNext = true;
64
+ continue;
65
+ }
66
+ const eq = arg.indexOf("=");
67
+ if (eq > 0 && SECRET_FLAGS.test(arg.slice(0, eq))) {
68
+ out.push(`${arg.slice(0, eq)}=${SECRET_PLACEHOLDER}`);
69
+ continue;
70
+ }
71
+ out.push(redactSecrets(arg));
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /** True when `argv` carries a credential that redaction would remove. */
77
+ function argvHasSecret(argv: string[]): boolean {
78
+ return redactArgv(argv).some((arg, i) => arg !== argv[i]);
79
+ }
80
+
27
81
  function getPackageVersion(): string {
28
82
  return getVersion();
29
83
  }
@@ -43,10 +97,12 @@ export function formatErrorBody(
43
97
  context: { command: string; argv: string[] },
44
98
  ): string {
45
99
  const e = err instanceof Error ? err : new Error(String(err));
46
- // biome-ignore lint/suspicious/noControlCharactersInRegex: strips ANSI color escapes from stack traces
47
- const stack = (e.stack ?? "(no stack)").replace(/\u001b\[[0-9;]*m/g, "");
48
- const message = (e.message || "(no message)").slice(0, 500);
49
- const cmd = `${context.command} ${context.argv.join(" ")}`.trim();
100
+ const stack = redactSecrets(
101
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: strips ANSI color escapes from stack traces
102
+ (e.stack ?? "(no stack)").replace(/\u001b\[[0-9;]*m/g, ""),
103
+ );
104
+ const message = redactSecrets(e.message || "(no message)").slice(0, 500);
105
+ const cmd = `${context.command} ${redactArgv(context.argv).join(" ")}`.trim();
50
106
  const ghfindVersion = getPackageVersion();
51
107
 
52
108
  return [
@@ -88,7 +144,9 @@ export function buildIssueUrl(
88
144
  context: { command: string; argv: string[] },
89
145
  ): string {
90
146
  const e = err instanceof Error ? err : new Error(String(err));
91
- const title = e.message.slice(0, 80).replace(/[\r\n]+/g, " ");
147
+ const title = redactSecrets(e.message)
148
+ .slice(0, 80)
149
+ .replace(/[\r\n]+/g, " ");
92
150
  const body = formatErrorBody(err, context);
93
151
  const params = new URLSearchParams({
94
152
  title: `[bug] ${title || "Unhandled error"}`,
@@ -138,15 +196,27 @@ export function reportError(
138
196
  argv: process.argv.slice(2),
139
197
  },
140
198
  ): string {
141
- const url = buildIssueUrl(err, context);
199
+ const hadSecret = argvHasSecret(context.argv);
200
+ // Build the URL from the scrubbed argv, never the raw one.
201
+ const url = buildIssueUrl(err, {
202
+ command: context.command,
203
+ argv: redactArgv(context.argv),
204
+ });
142
205
  console.error("");
143
206
  console.error(" ⚠ ghfind encountered an unexpected error.");
144
207
  console.error("");
145
- console.error(" Help us fix it by opening a bug report with the");
146
- console.error(" details pre-filled. Your browser will open the new-issue");
147
- console.error(" page; review and submit (or close the tab to skip).");
208
+ if (hadSecret) {
209
+ console.error(" A token was found in the command line and has been");
210
+ console.error(" redacted. Your browser will NOT be opened — review the");
211
+ console.error(" scrubbed URL below before sharing it anywhere.");
212
+ } else {
213
+ console.error(" Help us fix it by opening a bug report with the");
214
+ console.error(" details pre-filled. Your browser will open the new-issue");
215
+ console.error(" page; review and submit (or close the tab to skip).");
216
+ }
148
217
  console.error("");
149
- const opened = tryOpenUrl(url);
218
+ // Never auto-open a report that contained a credential.
219
+ const opened = hadSecret ? false : tryOpenUrl(url);
150
220
  if (opened) {
151
221
  console.error(` → Opened: ${url}`);
152
222
  } else {
package/src/errors.ts CHANGED
@@ -4,7 +4,6 @@ export class SearchCliError extends Error {
4
4
  constructor(
5
5
  message: string,
6
6
  public readonly userMessage: string,
7
- public readonly recoverable: boolean = true,
8
7
  ) {
9
8
  super(message);
10
9
  this.name = "SearchCliError";
@@ -21,6 +20,33 @@ export class NetworkError extends SearchCliError {
21
20
  }
22
21
  }
23
22
 
23
+ /**
24
+ * HTTP 401 — the configured GitHub token was rejected.
25
+ * Distinct from NetworkError: connectivity is fine, the credential is not.
26
+ */
27
+ export class AuthError extends SearchCliError {
28
+ constructor() {
29
+ const msg =
30
+ "⚠ Bad GitHub token (401). Press [t] to fix it, [u] to search without one";
31
+ super("GitHub API returned 401 (invalid or revoked token)", msg);
32
+ this.name = "AuthError";
33
+ }
34
+ }
35
+
36
+ /**
37
+ * HTTP 403 — authenticated but not allowed (SAML enforcement, resource
38
+ * restrictions, secondary limits). Not the same as the rate-limit 403,
39
+ * which is detected by an exhausted x-ratelimit-remaining header first.
40
+ */
41
+ export class ForbiddenError extends SearchCliError {
42
+ constructor() {
43
+ const msg =
44
+ "⚠ Forbidden (403). If your org enforces SSO, authorize the token; [t] to change it";
45
+ super("GitHub API returned 403 (token lacks access)", msg);
46
+ this.name = "ForbiddenError";
47
+ }
48
+ }
49
+
24
50
  export class RateLimitError extends SearchCliError {
25
51
  constructor(
26
52
  public readonly hasToken: boolean,
package/src/help.ts CHANGED
@@ -47,6 +47,17 @@ export function buildHelpSections(): HelpSection[] {
47
47
  { keys: "PageUp", action: "Scroll to top of results" },
48
48
  ],
49
49
  },
50
+ {
51
+ title: " Error Recovery ",
52
+ titleColor: "red",
53
+ rows: [
54
+ { keys: "r", action: "Retry after an error" },
55
+ { keys: "t", action: "Fix GitHub token (401/403)" },
56
+ { keys: "u", action: "Search without a token (401)" },
57
+ { keys: "c", action: "Change token (rate limit)" },
58
+ ],
59
+ note: "Active only while the matching error is in the status bar.",
60
+ },
50
61
  {
51
62
  title: " Trending Mode ",
52
63
  titleColor: "yellow",
package/src/history.ts CHANGED
@@ -31,13 +31,10 @@ export function readHistory(): HistoryEntry[] {
31
31
  export function deleteHistoryEntry(index: number): void {
32
32
  const entries = readHistory();
33
33
  if (index < 0 || index >= entries.length) return;
34
- const removed = entries[index];
35
- const remaining = entries.filter(
36
- (e) => e.timestamp !== removed.timestamp || e.query !== removed.query,
37
- );
34
+ entries.splice(index, 1);
38
35
  writeRaw(
39
36
  HISTORY_FILE,
40
- `${remaining
37
+ `${entries
41
38
  .reverse()
42
39
  .map((e) => JSON.stringify(e))
43
40
  .join("\n")}\n`,
@@ -6,6 +6,7 @@
6
6
  * inline `data:` URIs. Only some of them are worth downloading, and all of
7
7
  * the downloads are cached because a README is re-opened often.
8
8
  */
9
+ import { declaresTooSmall, type DeclaredSize } from "./markdown-images";
9
10
 
10
11
  /** Reject anything bigger than this before buffering it. */
11
12
  export const MAX_IMAGE_BYTES = 6 * 1024 * 1024;
@@ -263,8 +264,7 @@ async function loadImageBytes(
263
264
  }
264
265
  }
265
266
 
266
- /**
267
- * Absolute URL of the README file itself, used as the base for relative
267
+ /** Absolute URL of the README file itself, used as the base for relative
268
268
  * image paths.
269
269
  */
270
270
  export function readmeBaseUrl(
@@ -275,3 +275,58 @@ export function readmeBaseUrl(
275
275
  ): string {
276
276
  return `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}`;
277
277
  }
278
+
279
+ /**
280
+ * Pixel size an image declares about itself, read from its header, or
281
+ * `undefined` when the runtime has no image codec or the bytes are unreadable.
282
+ * `Bun.Image.metadata()` reads only the header — no pixel decode — so this is
283
+ * cheap to run on bytes that are already in hand.
284
+ */
285
+ async function imageSizeFromBytes(
286
+ bytes: Uint8Array,
287
+ ): Promise<DeclaredSize | undefined> {
288
+ const ImageCtor = (
289
+ globalThis as {
290
+ Bun?: {
291
+ Image?: new (
292
+ input: Uint8Array,
293
+ constructorOptions?: { maxPixels?: number },
294
+ ) => {
295
+ metadata(): Promise<{ width: number; height: number }>;
296
+ };
297
+ };
298
+ }
299
+ ).Bun?.Image;
300
+ if (!ImageCtor) return undefined;
301
+ try {
302
+ const meta = await new ImageCtor(bytes).metadata();
303
+ if (!meta.width || !meta.height) return undefined;
304
+ return { width: meta.width, height: meta.height };
305
+ } catch {
306
+ return undefined;
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Whether a downloaded image is worth rasterising.
312
+ *
313
+ * A reference that declares its size in the README is trusted (`declared`);
314
+ * one that doesn't is measured from the image header. Images below the
315
+ * 100 px floor are rejected *after* the download but *before* any pixel
316
+ * decode — markdown `![]()` images carry no declared size, so this is the
317
+ * only place their real size can be checked, and the header read is the
318
+ * cheapest check that works.
319
+ *
320
+ * On a runtime without `Bun.Image` (plain Node) there is nothing to measure
321
+ * with, so the image is accepted and left to the rasteriser, which fails
322
+ * into a caption anyway.
323
+ */
324
+ export async function shouldRenderImage(
325
+ bytes: Uint8Array,
326
+ declared?: DeclaredSize,
327
+ ): Promise<boolean> {
328
+ if (declared) return !declaresTooSmall(declared);
329
+ const measured = await imageSizeFromBytes(bytes);
330
+ if (!measured) return true;
331
+ return !declaresTooSmall(measured);
332
+ }
package/src/init.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * ponytail: simple stdin prompts, no curses/readline UI.
5
5
  */
6
6
  import { createInterface } from "node:readline";
7
- import { saveConfig } from "./config";
7
+ import { saveConfig, configPath } from "./config";
8
8
  import { isLikelyGithubToken } from "./login";
9
9
  import { listThemes } from "./themes";
10
10
  import type { Config, SortStrategy } from "./types";
@@ -53,7 +53,6 @@ export async function runInitWizard(): Promise<void> {
53
53
  if (token) config.githubToken = token;
54
54
 
55
55
  saveConfig(config);
56
- const { configPath } = await import("./config.ts");
57
56
  console.log(` ✓ Config saved to ${configPath()}`);
58
57
  console.log(" Run 'ghfind' to start browsing!");
59
58
  console.log("");
@@ -14,6 +14,26 @@ export interface InlineTokenLike {
14
14
  tokens?: InlineTokenLike[];
15
15
  }
16
16
 
17
+ /**
18
+ * A pixel size an image reference declares about itself — the `width`/`height`
19
+ * attributes of an `<img>` tag, or the dimensions `Bun.Image.metadata()` reads
20
+ * from an image header. `undefined` means "not declared".
21
+ */
22
+ export interface DeclaredSize {
23
+ width: number;
24
+ height: number;
25
+ }
26
+
27
+ /**
28
+ * Images whose larger declared edge is below this are skipped before any
29
+ * request is made. Badges and 48x48 sponsor avatars are unreadable at cell
30
+ * resolution and, at 24 rows per image, a README full of them is unusable —
31
+ * 648 avatars in `openclaw/openclaw` measured 15,552 rows and 28 s to open.
32
+ * The README is the authority here: it can hide an image whose declared size
33
+ * understates it, which is judged better than a screen full of avatars.
34
+ */
35
+ export const MIN_IMAGE_PIXELS = 100;
36
+
17
37
  /** Minimal shape of a marked block token. */
18
38
  export interface BlockTokenLike extends InlineTokenLike {
19
39
  depth?: number;
@@ -22,12 +42,55 @@ export interface BlockTokenLike extends InlineTokenLike {
22
42
  export interface ImageRef {
23
43
  href: string;
24
44
  alt: string;
45
+ /** Size the reference declares about itself, when it does. */
46
+ declared?: DeclaredSize;
47
+ /** Link target when the image is wrapped in a link (`[![alt](src)](url)`). */
48
+ linkUrl?: string;
25
49
  }
26
50
 
27
51
  const IMG_TAG = /<img\b[^>]*?src\s*=\s*["']([^"']+)["'][^>]*>/gi;
28
52
  const ALT_ATTR = /\balt\s*=\s*["']([^"']*)["']/i;
53
+ const LENGTH_ATTR = /\b(width|height)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
54
+ /** Whole-value integers, with or without a `px` suffix. */
55
+ const PIXEL_VALUE = /^(\d+)(?:px)?$/i;
29
56
  const HTML_TAG = /<\/?[a-z][^>]*>/gi;
30
57
 
58
+ /**
59
+ * Pixel size an `<img>` tag declares via its `width`/`height` attributes, or
60
+ * `undefined` when it declares none. Only whole pixel values count —
61
+ * percentages (`width="100%"`) say nothing about real pixels, so they are
62
+ * ignored rather than read as a 100 px image.
63
+ */
64
+ export function declaredImageSize(tag: string): DeclaredSize | undefined {
65
+ const values: Partial<Record<"width" | "height", number>> = {};
66
+ for (const [, name, dq, sq] of tag.matchAll(LENGTH_ATTR)) {
67
+ const raw = dq ?? sq ?? "";
68
+ const match = PIXEL_VALUE.exec(raw.trim());
69
+ if (!match) continue;
70
+ const n = Number.parseInt(match[1], 10);
71
+ if (Number.isFinite(n) && n > 0) {
72
+ values[name as "width" | "height"] = n;
73
+ }
74
+ }
75
+ if (values.width === undefined && values.height === undefined)
76
+ return undefined;
77
+ return {
78
+ width: values.width ?? values.height ?? 0,
79
+ height: values.height ?? values.width ?? 0,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Whether a reference declaring `size` is too small to render: an edge under
85
+ * the floor (the shipped contract — "images declaring an edge under 100px are
86
+ * skipped"). `undefined` (nothing declared) is never too small — callers
87
+ * decide from real pixels or render and let the rasteriser cap it.
88
+ */
89
+ export function declaresTooSmall(size: DeclaredSize | undefined): boolean {
90
+ if (!size) return false;
91
+ return size.width < MIN_IMAGE_PIXELS || size.height < MIN_IMAGE_PIXELS;
92
+ }
93
+
31
94
  /** Images embedded as raw `<img>` tags, when there's no other content. */
32
95
  function imagesFromHtml(raw: string | undefined): ImageRef[] {
33
96
  if (!raw || !/<img\b/i.test(raw)) return [];
@@ -43,7 +106,7 @@ function imagesFromHtml(raw: string | undefined): ImageRef[] {
43
106
  for (const match of raw.matchAll(IMG_TAG)) {
44
107
  const tag = match[0];
45
108
  const alt = ALT_ATTR.exec(tag)?.[1] ?? "";
46
- refs.push({ href: match[1], alt });
109
+ refs.push({ href: match[1], alt, declared: declaredImageSize(tag) });
47
110
  }
48
111
  return refs;
49
112
  }
@@ -81,13 +144,29 @@ export function inlinePlainText(tokens: InlineTokenLike[] | undefined): string {
81
144
  */
82
145
  export function extractImageRefs(token: BlockTokenLike): ImageRef[] | null {
83
146
  const images: ImageRef[] = [];
147
+ let linkUrl: string | undefined;
84
148
 
85
149
  const collect = (inline: InlineTokenLike): boolean => {
86
150
  if (inline.type === "image") {
87
151
  if (inline.href)
88
- images.push({ href: inline.href, alt: inline.text ?? "" });
152
+ images.push({ href: inline.href, alt: inline.text ?? "", linkUrl });
89
153
  return true;
90
154
  }
155
+ if (inline.type === "link" || inline.type === "linkReference") {
156
+ // `[![alt](img)](url)` — the badge-and-screenshot shape. marked hands
157
+ // the image to us as a child of the link token, so recurse; rejecting
158
+ // the block here discarded the image entirely (sst/opencode,
159
+ // withastro/astro). A link whose content is prose still fails the
160
+ // inner `text` check, so those blocks stay prose.
161
+ if (inline.tokens?.length) {
162
+ const outer = linkUrl;
163
+ linkUrl = inline.href || outer;
164
+ const ok = inline.tokens.every(collect);
165
+ linkUrl = outer;
166
+ return ok;
167
+ }
168
+ return false;
169
+ }
91
170
  if (inline.type === "html") {
92
171
  const refs = imagesFromHtml(inline.raw);
93
172
  if (refs.length > 0) {
@@ -111,7 +190,7 @@ export function extractImageRefs(token: BlockTokenLike): ImageRef[] | null {
111
190
 
112
191
  if (token.type === "html") {
113
192
  const refs = imagesFromHtml(token.raw);
114
- return refs.length > 0 ? refs : null;
193
+ return refs.length > 0 ? dropTooSmall(refs) : null;
115
194
  }
116
195
 
117
196
  const inline = token.tokens ?? [];
@@ -119,5 +198,15 @@ export function extractImageRefs(token: BlockTokenLike): ImageRef[] | null {
119
198
  for (const child of inline) {
120
199
  if (!collect(child)) return null;
121
200
  }
122
- return images.length > 0 ? images : null;
201
+ return images.length > 0 ? dropTooSmall(images) : null;
202
+ }
203
+
204
+ /**
205
+ * Filter out refs the README declares too small to be worth a request.
206
+ * An empty return means "pure image block, but nothing in it is worth
207
+ * drawing" — distinct from `null` (prose), so the view can render neither
208
+ * prose nor the raw markdown of a filtered-out image.
209
+ */
210
+ function dropTooSmall(refs: ImageRef[]): ImageRef[] {
211
+ return refs.filter((image) => !declaresTooSmall(image.declared));
123
212
  }
@@ -24,11 +24,12 @@ import {
24
24
  type HalfBlockImage,
25
25
  type Rgb,
26
26
  } from "./image-render";
27
- import { resolveReadmeImageUrl } from "./image-loader";
27
+ import { resolveReadmeImageUrl, shouldRenderImage } from "./image-loader";
28
28
  import {
29
29
  extractImageRefs,
30
30
  inlinePlainText,
31
31
  type BlockTokenLike,
32
+ type DeclaredSize,
32
33
  type ImageRef,
33
34
  } from "./markdown-images";
34
35
 
@@ -105,17 +106,21 @@ function rgba(color: Rgb): RGBA {
105
106
  }
106
107
 
107
108
  /** Half-block rows as one styled-text block (one line per image row). */
108
- function imageStyledText(image: HalfBlockImage): StyledText {
109
+ function imageStyledText(image: HalfBlockImage, linkUrl?: string): StyledText {
109
110
  const chunks: TextChunk[] = [];
110
111
  toImageRuns(image).forEach((runs, rowIndex) => {
111
112
  if (rowIndex > 0) chunks.push({ __isChunk: true, text: "\n" });
112
113
  for (const run of runs) {
113
- chunks.push({
114
+ const chunk: TextChunk = {
114
115
  __isChunk: true,
115
116
  text: run.text,
116
117
  fg: rgba(run.top),
117
118
  bg: rgba(run.bottom),
118
- });
119
+ };
120
+ // `[![alt](img)](url)` keeps its link: OSC 8 on the image cells, so a
121
+ // click in the terminal opens the same URL the prose version would.
122
+ if (linkUrl) chunk.link = { url: linkUrl };
123
+ chunks.push(chunk);
119
124
  }
120
125
  });
121
126
  return new StyledText(chunks);
@@ -158,17 +163,26 @@ export function createMarkdownView(opts: MarkdownViewOptions): MarkdownView {
158
163
  width: "100%",
159
164
  });
160
165
 
161
- const caption = (label: string, color = muted()) =>
166
+ const caption = (label: string, linkUrl?: string, color = muted()) =>
162
167
  new TextRenderable(renderer, {
163
- content: ` ${label}`,
164
- fg: color,
168
+ content: new StyledText([
169
+ {
170
+ __isChunk: true,
171
+ text: ` ${label}`,
172
+ fg: RGBA.fromHex(color),
173
+ ...(linkUrl ? { link: { url: linkUrl } } : {}),
174
+ },
175
+ ]),
165
176
  bg: bg(),
166
177
  wrapMode: "word",
167
178
  flexShrink: 0,
168
179
  width: "100%",
169
180
  });
170
181
 
171
- const rasterize = (url: string): Promise<HalfBlockImage | null> => {
182
+ const rasterize = (
183
+ url: string,
184
+ declared?: DeclaredSize,
185
+ ): Promise<HalfBlockImage | null> => {
172
186
  // Size is part of the key: a resized terminal needs fresh cells.
173
187
  const maxCols = Math.max(8, Math.floor(opts.getImageWidth?.() ?? 100));
174
188
  const key = `${maxCols}|${url}`;
@@ -179,6 +193,11 @@ export function createMarkdownView(opts: MarkdownViewOptions): MarkdownView {
179
193
  if (!loader) return null;
180
194
  const bytes = await loader(url);
181
195
  if (!bytes) return null;
196
+ // Size gate before any pixel decode. Declared sizes were already
197
+ // filtered at parse time (markdown-images.ts); markdown `![]()`
198
+ // images carry no declared size, so they are measured from the image
199
+ // header here — the rejected `null` is cached like any other failure.
200
+ if (!(await shouldRenderImage(bytes, declared))) return null;
182
201
  return rasterizeImage(bytes, {
183
202
  maxCols,
184
203
  maxRows: maxImageRows,
@@ -199,28 +218,29 @@ export function createMarkdownView(opts: MarkdownViewOptions): MarkdownView {
199
218
  const imageNode = (image: ImageRef): Renderable => {
200
219
  const label = `🖼 ${image.alt.trim() || "image"}`;
201
220
  const url = imageBase ? resolveReadmeImageUrl(image.href, imageBase) : null;
202
- if (!imagesEnabled || !url || !opts.loadImage) return caption(label);
221
+ if (!imagesEnabled || !url || !opts.loadImage)
222
+ return caption(label, image.linkUrl);
203
223
 
204
224
  const container = new BoxRenderable(renderer, {
205
225
  flexDirection: "column",
206
226
  flexShrink: 0,
207
227
  width: "100%",
208
228
  });
209
- const placeholder = caption(`${label} (loading…)`);
229
+ const placeholder = caption(`${label} (loading…)`, image.linkUrl);
210
230
  container.add(placeholder);
211
231
 
212
- void rasterize(url).then(
232
+ void rasterize(url, image.declared).then(
213
233
  (cells) => {
214
234
  // The document may have been replaced (or the overlay closed) while
215
235
  // the image was downloading.
216
236
  if (container.isDestroyed || !container.parent) return;
217
237
  container.remove(placeholder);
218
238
  placeholder.destroy();
219
- container.add(caption(label));
239
+ container.add(caption(label, image.linkUrl));
220
240
  if (cells) {
221
241
  container.add(
222
242
  new TextRenderable(renderer, {
223
- content: imageStyledText(cells),
243
+ content: imageStyledText(cells, image.linkUrl),
224
244
  bg: bg(),
225
245
  wrapMode: "none",
226
246
  selectable: false,
@@ -270,17 +290,26 @@ export function createMarkdownView(opts: MarkdownViewOptions): MarkdownView {
270
290
  ): Renderable | undefined => {
271
291
  if (token.type === "heading") return headingNode(token);
272
292
  const images = extractImageRefs(token);
273
- if (images && images.length > 0) {
274
- const box = new BoxRenderable(renderer, {
275
- flexDirection: "column",
293
+ if (images === null) return undefined; // prose: default rendering
294
+ if (images.length === 0) {
295
+ // Pure image block whose refs the README declares too small to be
296
+ // worth drawing (openclaw's 648 48x48 avatars). Render an empty
297
+ // block rather than falling through to prose, which would print the
298
+ // raw markdown of the filtered-out image.
299
+ return new BoxRenderable(renderer, {
276
300
  flexShrink: 0,
277
301
  width: "100%",
278
- marginTop: 1,
302
+ height: 0,
279
303
  });
280
- for (const image of images) box.add(imageNode(image));
281
- return box;
282
304
  }
283
- return undefined;
305
+ const box = new BoxRenderable(renderer, {
306
+ flexDirection: "column",
307
+ flexShrink: 0,
308
+ width: "100%",
309
+ marginTop: 1,
310
+ });
311
+ for (const image of images) box.add(imageNode(image));
312
+ return box;
284
313
  };
285
314
 
286
315
  renderable.renderNode = buildRenderNode();
package/src/mcp-server.ts CHANGED
@@ -55,6 +55,7 @@ import {
55
55
  type SkillSource,
56
56
  } from "./skill-finder";
57
57
  import { getVersion } from "./version";
58
+ import { RateLimitError } from "./errors";
58
59
 
59
60
  // ─── Protocol constants ────────────────────────────────────────────────
60
61
 
@@ -277,7 +278,22 @@ const TOOLS: ToolDefinition[] = [
277
278
  verbose: false,
278
279
  token,
279
280
  };
280
- const response = await provider.search(parsed, options);
281
+ // The adapter no longer swallows rate limits as empty results — it
282
+ // throws RateLimitError. Convert it to the structured flag here so
283
+ // agents still get backoff advice (a tool failure reads as breakage;
284
+ // the flag reads as retry-later).
285
+ let response: Awaited<ReturnType<typeof provider.search>>;
286
+ try {
287
+ response = await provider.search(parsed, options);
288
+ } catch (err) {
289
+ if (err instanceof RateLimitError) {
290
+ return {
291
+ 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.`,
292
+ data: { totalCount: 0, repos: [], rateLimited: true },
293
+ };
294
+ }
295
+ throw err;
296
+ }
281
297
  const repos = rankRepos(response.repos, sort);
282
298
 
283
299
  // Make agent searches visible in the user's TUI history (best-effort).
@@ -510,19 +526,14 @@ const TOOLS: ToolDefinition[] = [
510
526
  throw new McpToolError("repos must contain 2-5 repo fullNames.");
511
527
  }
512
528
  const token = resolveToken(state, args.token);
513
- const provider = createGitHubSearch(undefined, token ? [token] : []);
514
529
  const found: Repo[] = [];
515
530
  const missing: string[] = [];
516
531
  for (const fullName of names) {
517
- const res = await provider.search(parseQuery(fullName), {
518
- limit: 1,
519
- sort: "stars",
520
- json: false,
521
- verbose: false,
522
- token,
523
- });
524
- if (res.repos.length > 0) found.push(res.repos[0]);
525
- else missing.push(fullName);
532
+ try {
533
+ found.push(await resolveRepoFromRef(fullName, token));
534
+ } catch {
535
+ missing.push(fullName);
536
+ }
526
537
  }
527
538
  if (found.length < 2) {
528
539
  throw new McpToolError(