github-search-cli 9.8.0 → 9.8.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-search-cli",
3
- "version": "9.8.0",
3
+ "version": "9.8.1",
4
4
  "description": "Search GitHub from your terminal — repo search, trending repos, npm package search, bookmarks, deep-dive, compare, export. Keyboard-driven TUI + pipeable CLI.",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1",
@@ -0,0 +1,74 @@
1
+ # What's New in ghfind 9.8.1
2
+
3
+ Agent skills and read-only CLI views for scripts.
4
+
5
+ This is a patch release. It also corrects the v9.8.0 release notes, which
6
+ claimed README image rendering worked. It does not, and this release does not
7
+ fix it — see the note at the end.
8
+
9
+ ### Added
10
+
11
+ **`ghfind skill search <query>`**
12
+
13
+ - Finds agent skills by keyword and prints why each one matched.
14
+ - Scans `SKILL.md` files under `~/.codex/skills`, `~/.agents/skills`,
15
+ `<project>/.codex/skills` and `<project>/.github/skills`, in that
16
+ priority order, de-duplicated by name.
17
+ - `--remote` queries the public [skills.sh](https://skills.sh) ecosystem
18
+ instead, returning each hit's install count and `npx skills add` command.
19
+ - `GHFIND_SKILL_ROOTS` overrides which directories are scanned.
20
+
21
+ **MCP tool `ghfind_skill_search`**
22
+
23
+ - Agents can search skills as soon as they connect: `source: "local"` (the
24
+ default) searches installed skills, `source: "registry"` searches
25
+ skills.sh. Read-only — it returns install commands and never writes to disk.
26
+
27
+ **Read-only CLI views of local state**
28
+
29
+ - `ghfind bookmarks [query]`, `ghfind history [query]`, `ghfind saved`
30
+ and `ghfind topics`, each with `--json` for agents. They print what the
31
+ TUI has stored and never modify it.
32
+
33
+ **`ghfind readme <owner/repo>` and `ghfind share <owner/repo>`**
34
+
35
+ - `ghfind readme` prints a repository's README (`--raw` skips the header,
36
+ `--json` returns `{ owner, name, sourceUrl, text }`), bringing the TUI's
37
+ README viewer to scripts and agents.
38
+ - `ghfind share` prints a share snippet (`--as markdown|plain|gh-cli|short`),
39
+ with `--copy` to also put it on the clipboard.
40
+
41
+ **Shell completions**
42
+
43
+ - bash, zsh and fish completions cover every new subcommand and flag.
44
+
45
+ ### Changed
46
+
47
+ - The bundled `ghfind-cli` agent skill documents skill search and the new
48
+ read-only commands, so CLI-first agents discover them without reading
49
+ `--help`.
50
+
51
+ ### Fixed
52
+
53
+ **README image rendering is not working — please read this before upgrading**
54
+
55
+ The 9.8.0 README viewer added inline image support. On real repositories it
56
+ does not work reliably, and this release does not fix it. Known failures, all
57
+ verified against live READMEs:
58
+
59
+ - **An image wrapped in a link renders nothing.** `[![alt](shot.png)](url)`
60
+ is a common way to write a linked screenshot. The whole block is treated as
61
+ prose and the image is dropped. This affects repositories such as
62
+ `sst/opencode`.
63
+ - **A README with hundreds of small images is unusable.** `openclaw/openclaw`
64
+ has 661 `<img>` tags, 648 of them 48x48 sponsor avatars. Each becomes a
65
+ full screen of terminal cells, so the document is 15,552 rows tall.
66
+ - **An image smaller than 100px is skipped**, judged by the `width`/`height`
67
+ its `<img>` tag declares. A README that understates an image's size hides
68
+ that image.
69
+ - **SVG is not rendered.**
70
+
71
+ Plain `![]()` images and large banners do render, correctly and quickly. The
72
+ v9.8.0 notes described image rendering as working; that was wrong, and it is
73
+ corrected here. Measurements, root causes and the remaining work are tracked
74
+ in `.issues/tickets/readme-image-rendering.md`.
@@ -77,6 +77,12 @@ reported in text output and via the error message.
77
77
  ghfind deep-dive owner/repo --json
78
78
  Text output (no flag) prints the same sections formatted for the terminal.
79
79
 
80
+ ## Finding skills
81
+ ghfind skill search QUERY # skills installed on this machine
82
+ ghfind skill search QUERY --remote # the public skills.sh ecosystem
83
+ Results carry path+description (local) or install command (registry, read-only --
84
+ nothing is installed). The MCP server exposes the same search as
85
+ ghfind_skill_search.
80
86
  ## Rate limits
81
87
 
82
88
  60 req/hr unauthenticated, 5,000 with GITHUB_TOKEN (env var or \`ghfind login\`).
@@ -88,6 +94,14 @@ Bookmarks live in ghfind's local state, not GitHub. An agent can read the
88
94
  release feed with:
89
95
  ghfind --releases --json
90
96
  Marking releases seen and editing bookmarks is a TUI action; skip it.
97
+ ## Local state (read-only)
98
+ ghfind bookmarks [QUERY] --json # saved repos, newest first
99
+ ghfind history [QUERY] --json # past searches
100
+ ghfind saved --json # named searches
101
+ ghfind topics --json # popular GitHub topics
102
+ ghfind readme owner/repo # print a repo README
103
+ ghfind share owner/repo --as gh-cli|markdown|plain|short
104
+ These never write local state; skip them when nothing needs reading.
91
105
 
92
106
  ## Token efficiency
93
107
 
package/src/cli.ts CHANGED
@@ -73,6 +73,20 @@ import {
73
73
  } from "./agent-skill";
74
74
  import type { SearchOptions } from "./types";
75
75
  import { SearchCliError } from "./errors";
76
+ import { getBookmarks, searchBookmarks } from "./bookmarks";
77
+ import { readHistory } from "./history";
78
+ import { getSavedSearches } from "./saved-searches";
79
+ import { fetchTopics } from "./explore";
80
+ import { fetchReadme } from "./readme";
81
+ import { copyToClipboard, formatShare, type ShareFormat } from "./share";
82
+ import { repoFromRef } from "./deepdive";
83
+ import {
84
+ formatSkillSearchJson,
85
+ formatSkillSearchNames,
86
+ formatSkillSearchText,
87
+ searchSkills,
88
+ type SkillSource,
89
+ } from "./skill-finder";
76
90
 
77
91
  const __dirname = dirname(fileURLToPath(import.meta.url));
78
92
  const PKG_DIR = join(__dirname, "..");
@@ -109,6 +123,21 @@ interface CLIFlags {
109
123
  skill?: string; // agent skill guide (ghfind skill [name])
110
124
  deepDive?: string; // repo deep-dive (ghfind deep-dive <owner/repo>)
111
125
  language?: string; // trending language filter (--trending rust / ghfind trending rust)
126
+ // ?? Read-only local/skill surface (see README "Non-interactive") ??
127
+ bookmarks: boolean; // list bookmarks (ghfind bookmarks)
128
+ history: boolean; // list past searches (ghfind history)
129
+ topics: boolean; // browse popular GitHub topics (ghfind topics)
130
+ saved: boolean; // list saved searches (ghfind saved)
131
+ readme?: string; // print a repo README (ghfind readme <owner/repo>)
132
+ share?: string; // print a share snippet (ghfind share <owner/repo>)
133
+ skillSearch?: string; // skill search query (ghfind skill search <query>)
134
+ sharedAs?: string; // --as <format> for share
135
+ remote: boolean; // skill search: hit skills.sh instead of this machine
136
+ local: boolean; // skill search: force the local scan (the default)
137
+ copy: boolean; // share: also write the snippet to the clipboard
138
+ raw: boolean; // readme: print the raw markdown with no header
139
+ /** True when --limit was passed (history uses a smaller default than search). */
140
+ limitExplicit: boolean;
112
141
  }
113
142
 
114
143
  function parseArgs(args: string[]): CLIFlags {
@@ -135,6 +164,15 @@ function parseArgs(args: string[]): CLIFlags {
135
164
  compare: [],
136
165
  doctor: false,
137
166
  mcp: false,
167
+ bookmarks: false,
168
+ history: false,
169
+ topics: false,
170
+ saved: false,
171
+ remote: false,
172
+ local: false,
173
+ copy: false,
174
+ raw: false,
175
+ limitExplicit: false,
138
176
  };
139
177
 
140
178
  const queryParts: string[] = [];
@@ -199,6 +237,7 @@ function parseArgs(args: string[]): CLIFlags {
199
237
  break;
200
238
  case "--limit": {
201
239
  const parsed = parseInt(args[++i], 10);
240
+ flags.limitExplicit = true;
202
241
  if (Number.isNaN(parsed) || parsed < 1 || parsed > 100) {
203
242
  console.error(`Invalid limit: must be 1-100. Using default 50.`);
204
243
  flags.limit = 50;
@@ -231,9 +270,58 @@ function parseArgs(args: string[]): CLIFlags {
231
270
  case "--completion":
232
271
  flags.completion = args[++i];
233
272
  break;
273
+ case "--remote":
274
+ flags.remote = true;
275
+ break;
276
+ case "--local":
277
+ flags.local = true;
278
+ break;
279
+ case "--copy":
280
+ flags.copy = true;
281
+ break;
282
+ case "--raw":
283
+ flags.raw = true;
284
+ break;
285
+ case "--as":
286
+ flags.sharedAs = args[++i];
287
+ break;
234
288
  case "mcp":
235
289
  flags.mcp = true;
236
290
  break;
291
+ case "bookmarks":
292
+ flags.bookmarks = true;
293
+ break;
294
+ case "history":
295
+ flags.history = true;
296
+ break;
297
+ case "topics":
298
+ flags.topics = true;
299
+ break;
300
+ case "saved":
301
+ flags.saved = true;
302
+ break;
303
+ case "readme": {
304
+ // Single repo ref; a following flag means none was given.
305
+ const next = args[i + 1];
306
+ if (next && !next.startsWith("-")) {
307
+ flags.readme = next;
308
+ i++;
309
+ } else {
310
+ flags.readme = "";
311
+ }
312
+ break;
313
+ }
314
+ case "share": {
315
+ // Single repo ref; a following flag means none was given.
316
+ const next = args[i + 1];
317
+ if (next && !next.startsWith("-")) {
318
+ flags.share = next;
319
+ i++;
320
+ } else {
321
+ flags.share = "";
322
+ }
323
+ break;
324
+ }
237
325
  case "deep-dive": {
238
326
  // Single repo ref (owner/name); a following flag means none.
239
327
  const next = args[i + 1];
@@ -244,8 +332,20 @@ function parseArgs(args: string[]): CLIFlags {
244
332
  break;
245
333
  }
246
334
  case "skill": {
247
- // Optional skill id (ids contain no spaces); a following flag means none.
335
+ // `skill search <query>` searches the skill registry; plain `skill`
336
+ // lists the catalog and `skill <name>` prints one skill's guide.
248
337
  const next = args[i + 1];
338
+ if (next === "search") {
339
+ i++;
340
+ const query = args[i + 1];
341
+ if (query && !query.startsWith("-")) {
342
+ flags.skillSearch = query;
343
+ i++;
344
+ } else {
345
+ flags.skillSearch = "";
346
+ }
347
+ break;
348
+ }
249
349
  if (next && !next.startsWith("-")) {
250
350
  flags.skill = next;
251
351
  i++;
@@ -335,6 +435,13 @@ Usage:
335
435
  ghfind pkg <query> Search npm packages, text list
336
436
  ghfind mcp Run the MCP server for AI agents (stdio)
337
437
  ghfind skill [name] Print the agent skill guide (or catalog)
438
+ ghfind bookmarks [query] Saved repos, newest first
439
+ ghfind history [query] Past searches, newest first
440
+ ghfind topics Browse popular GitHub topics
441
+ ghfind saved Named searches saved in the TUI
442
+ ghfind readme <owner/repo> Print a repository README
443
+ ghfind share a/b --as gh-cli Share snippet (--copy to clipboard)
444
+ ghfind skill search <query> Find skills locally, or --remote
338
445
  ghfind --watch <query> Watch mode (poll every Ns)
339
446
  ghfind init Run setup wizard
340
447
  ghfind login Import the gh CLI token or paste one
@@ -359,6 +466,11 @@ Options:
359
466
  --releases New-release feed for bookmarks
360
467
  --interval <s> Watch interval in seconds (default: 300)
361
468
  --completion <shell> Generate completions
469
+ --remote skill search: query skills.sh instead of this machine
470
+ --local skill search: force the local scan (default)
471
+ --as <fmt> share format: markdown|plain|gh-cli|short
472
+ --copy share: also copy the snippet to the clipboard
473
+ --raw readme: print the raw markdown with no header
362
474
  `);
363
475
  return;
364
476
  }
@@ -397,11 +509,43 @@ Options:
397
509
  return;
398
510
  }
399
511
 
512
+ // Skill search — find skills on this machine or in the skills.sh ecosystem
513
+ if (flags.skillSearch !== undefined) {
514
+ await runSkillSearch(flags);
515
+ return;
516
+ }
400
517
  // Agent skill guide — token-efficient usage doc for coding agents
401
518
  if (flags.skill !== undefined) {
402
519
  printAgentSkill(flags.skill);
403
520
  return;
404
521
  }
522
+ // Read-only local state: bookmarks / history / saved searches
523
+ if (flags.bookmarks) {
524
+ runBookmarks(flags);
525
+ return;
526
+ }
527
+ if (flags.history) {
528
+ runHistory(flags);
529
+ return;
530
+ }
531
+ if (flags.saved) {
532
+ runSavedSearches(flags);
533
+ return;
534
+ }
535
+ // Popular GitHub topics
536
+ if (flags.topics) {
537
+ await runTopics(flags);
538
+ return;
539
+ }
540
+ // README and share-snippet views (parity with the TUI `r` / `y` actions)
541
+ if (flags.readme !== undefined) {
542
+ await runReadme(flags);
543
+ return;
544
+ }
545
+ if (flags.share !== undefined) {
546
+ await runShare(flags);
547
+ return;
548
+ }
405
549
  // Package search (prototype, ticket 002-008)
406
550
  if (flags.pkg) {
407
551
  const interactive =
@@ -595,6 +739,274 @@ async function runDeepDive(
595
739
  }
596
740
  }
597
741
 
742
+ /** `ghfind skill search <query>` — find skills locally, or in skills.sh with --remote. */
743
+ async function runSkillSearch(flags: CLIFlags): Promise<void> {
744
+ const query = flags.skillSearch ?? "";
745
+ if (!query.trim()) {
746
+ console.error(
747
+ "Usage: ghfind skill search <query> [--remote] [--limit <n>] [--json]",
748
+ );
749
+ process.exit(1);
750
+ }
751
+ if (flags.remote && flags.local) {
752
+ console.error(
753
+ "Contradictory flags: pass either --remote or --local, not both.",
754
+ );
755
+ process.exit(1);
756
+ }
757
+ const source: SkillSource = flags.remote ? "registry" : "local";
758
+ try {
759
+ const result = await searchSkills(query, {
760
+ source,
761
+ limit: flags.limit,
762
+ });
763
+ if (flags.json) {
764
+ console.log(formatSkillSearchJson(result));
765
+ } else if (flags.format === "names") {
766
+ console.log(formatSkillSearchNames(result));
767
+ } else {
768
+ console.log(formatSkillSearchText(result));
769
+ }
770
+ } catch (err) {
771
+ console.error(err instanceof Error ? err.message : String(err));
772
+ process.exit(1);
773
+ }
774
+ }
775
+
776
+ /** `ghfind bookmarks [query]` — the saved repos, newest first. */
777
+ function runBookmarks(flags: CLIFlags): void {
778
+ const query = flags.query.trim();
779
+ const bookmarks = query ? searchBookmarks(query) : getBookmarks();
780
+ if (flags.json) {
781
+ console.log(JSON.stringify(bookmarks, null, 2));
782
+ return;
783
+ }
784
+ if (bookmarks.length === 0) {
785
+ console.log(
786
+ query
787
+ ? `No bookmarks match "${query}".`
788
+ : "No bookmarks yet. Press Space → Bookmark in the TUI to save one.",
789
+ );
790
+ return;
791
+ }
792
+ if (flags.csv || flags.markdown) {
793
+ const header = ["full_name", "stars", "language", "tags", "saved_at"];
794
+ const rows = bookmarks.map((b) => [
795
+ b.repo.fullName,
796
+ String(b.repo.stars),
797
+ b.repo.language ?? "",
798
+ b.tags.join(" "),
799
+ new Date(b.savedAt).toISOString().slice(0, 10),
800
+ ]);
801
+ console.log(
802
+ flags.csv ? rowsToCsv(header, rows) : rowsToMarkdown(header, rows),
803
+ );
804
+ return;
805
+ }
806
+ for (const b of bookmarks) {
807
+ const tags = b.tags.length > 0 ? ` [${b.tags.join(", ")}]` : "";
808
+ console.log(
809
+ `${b.repo.fullName} — ${b.repo.stars.toLocaleString()} ${b.repo.language ?? "n/a"}${tags}`,
810
+ );
811
+ }
812
+ }
813
+
814
+ /** `ghfind history [query]` — past searches, newest first. */
815
+ function runHistory(flags: CLIFlags): void {
816
+ // History entries are short; 20 is plenty unless the caller says otherwise.
817
+ const limit = flags.limitExplicit ? flags.limit : 20;
818
+ const query = flags.query.trim().toLowerCase();
819
+ const entries = readHistory()
820
+ .filter((e) => !query || e.query.toLowerCase().includes(query))
821
+ .slice(0, limit);
822
+ if (flags.json) {
823
+ console.log(JSON.stringify(entries, null, 2));
824
+ return;
825
+ }
826
+ if (entries.length === 0) {
827
+ console.log(
828
+ query ? `No history matches "${query}".` : "No search history yet.",
829
+ );
830
+ return;
831
+ }
832
+ if (flags.csv || flags.markdown) {
833
+ const header = ["query", "mode", "results", "when"];
834
+ const rows = entries.map((e) => [
835
+ e.query,
836
+ e.mode,
837
+ String(e.resultCount),
838
+ new Date(e.timestamp).toISOString(),
839
+ ]);
840
+ console.log(
841
+ flags.csv ? rowsToCsv(header, rows) : rowsToMarkdown(header, rows),
842
+ );
843
+ return;
844
+ }
845
+ for (const e of entries) {
846
+ const when = new Date(e.timestamp).toISOString().slice(0, 10);
847
+ console.log(
848
+ `${e.query} — ${e.mode} — ${e.resultCount} results — ${when}`,
849
+ );
850
+ }
851
+ }
852
+
853
+ /** `ghfind saved` — named searches saved in the TUI. */
854
+ function runSavedSearches(flags: CLIFlags): void {
855
+ const saved = getSavedSearches();
856
+ if (flags.json) {
857
+ console.log(JSON.stringify(saved, null, 2));
858
+ return;
859
+ }
860
+ if (saved.length === 0) {
861
+ console.log("No saved searches yet. Save one from the TUI command menu.");
862
+ return;
863
+ }
864
+ if (flags.csv || flags.markdown) {
865
+ const header = ["name", "query", "mode", "limit"];
866
+ const rows = saved.map((s) => [s.name, s.query, s.mode, String(s.limit)]);
867
+ console.log(
868
+ flags.csv ? rowsToCsv(header, rows) : rowsToMarkdown(header, rows),
869
+ );
870
+ return;
871
+ }
872
+ for (const s of saved) {
873
+ console.log(`${s.name} — ${s.query} — ${s.mode} — limit ${s.limit}`);
874
+ }
875
+ }
876
+
877
+ /** CSV for the small local-state tables (repos use output.ts's richer schema). */
878
+ function rowsToCsv(header: string[], rows: string[][]): string {
879
+ const quote = (value: string) =>
880
+ /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
881
+ return [header, ...rows].map((row) => row.map(quote).join(",")).join("\n");
882
+ }
883
+
884
+ /** Markdown table for the same small local-state tables. */
885
+ function rowsToMarkdown(header: string[], rows: string[][]): string {
886
+ return [
887
+ `| ${header.join(" | ")} |`,
888
+ `|${header.map(() => "---").join("|")}|`,
889
+ ...rows.map((row) => `| ${row.join(" | ")} |`),
890
+ ].join("\n");
891
+ }
892
+
893
+ /** Clip a string to `width` characters, ending on an ellipsis when cut. */
894
+ function truncate(text: string, width: number): string {
895
+ if (text.length <= width) return text;
896
+ return `${text.slice(0, width - 1).trimEnd()}\u2026`;
897
+ }
898
+
899
+ /** GitHub topic blurbs are HTML; flatten them to one plain text line. */
900
+ function plainText(html: string): string {
901
+ return html
902
+ .replace(/<\/p>|<br\s*\/?>/gi, " ")
903
+ .replace(/<[^>]*>/g, "")
904
+ .replace(/&amp;/g, "&")
905
+ .replace(/&lt;/g, "<")
906
+ .replace(/&gt;/g, ">")
907
+ .replace(/&quot;/g, '"')
908
+ .replace(/&#39;/g, "'")
909
+ .replace(/&nbsp;/g, " ")
910
+ .replace(/\s+/g, " ")
911
+ .trim();
912
+ }
913
+
914
+ /** `ghfind topics` — popular GitHub topics. */
915
+ async function runTopics(flags: CLIFlags): Promise<void> {
916
+ try {
917
+ const topics = (await fetchTopics()).slice(0, flags.limit);
918
+ if (flags.json) {
919
+ console.log(JSON.stringify(topics, null, 2));
920
+ return;
921
+ }
922
+ if (topics.length === 0) {
923
+ console.log("No topics returned.");
924
+ return;
925
+ }
926
+ for (const t of topics) {
927
+ // Blurbs run to paragraphs; text output stays readable by clipping.
928
+ const description = truncate(plainText(t.description), 140);
929
+ console.log(description ? `${t.name} ${description}` : t.name);
930
+ }
931
+ } catch (err) {
932
+ console.error(err instanceof Error ? err.message : String(err));
933
+ process.exit(1);
934
+ }
935
+ }
936
+
937
+ /** `ghfind readme <owner/repo>` — print a repository README. */
938
+ async function runReadme(flags: CLIFlags): Promise<void> {
939
+ const ref = flags.readme ?? "";
940
+ if (!ref) {
941
+ console.error(
942
+ "Usage: ghfind readme <owner/repo> [--json] [--raw] [--token <t>]",
943
+ );
944
+ process.exit(1);
945
+ }
946
+ try {
947
+ const { owner, name } = repoFromRef(ref); // validates the ref
948
+ const readme = await fetchReadme(owner, name, { token: flags.token });
949
+ if (!readme) {
950
+ console.error(`No README found for ${owner}/${name}.`);
951
+ process.exit(1);
952
+ }
953
+ if (flags.json) {
954
+ console.log(
955
+ JSON.stringify(
956
+ { owner, name, sourceUrl: readme.sourceUrl, text: readme.text },
957
+ null,
958
+ 2,
959
+ ),
960
+ );
961
+ return;
962
+ }
963
+ if (!flags.raw) {
964
+ console.log(`# ${owner}/${name} README (${readme.sourceUrl})`);
965
+ console.log("");
966
+ }
967
+ console.log(readme.text);
968
+ } catch (err) {
969
+ console.error(err instanceof Error ? err.message : String(err));
970
+ process.exit(1);
971
+ }
972
+ }
973
+
974
+ /** `ghfind share <owner/repo> [--as markdown|plain|gh-cli|short] [--copy]` */
975
+ async function runShare(flags: CLIFlags): Promise<void> {
976
+ const ref = flags.share ?? "";
977
+ if (!ref) {
978
+ console.error(
979
+ "Usage: ghfind share <owner/repo> [--as markdown|plain|gh-cli|short] [--copy]",
980
+ );
981
+ process.exit(1);
982
+ }
983
+ const SHARE_FORMATS: ShareFormat[] = ["markdown", "plain", "gh-cli", "short"];
984
+ const as = (flags.sharedAs ?? "markdown") as ShareFormat;
985
+ if (!SHARE_FORMATS.includes(as)) {
986
+ console.error(
987
+ `Invalid --as value: "${flags.sharedAs}" (use markdown|plain|gh-cli|short).`,
988
+ );
989
+ process.exit(1);
990
+ }
991
+ try {
992
+ repoFromRef(ref); // validate before the network round-trip
993
+ const repo = await resolveRepoFromRef(ref, flags.token);
994
+ const text = formatShare(repo, as);
995
+ console.log(text);
996
+ if (flags.copy) {
997
+ const copied = await copyToClipboard(text);
998
+ console.error(
999
+ copied
1000
+ ? "Copied to clipboard."
1001
+ : "Could not copy to the clipboard on this platform.",
1002
+ );
1003
+ }
1004
+ } catch (err) {
1005
+ console.error(err instanceof Error ? err.message : String(err));
1006
+ process.exit(1);
1007
+ }
1008
+ }
1009
+
598
1010
  /** `ghfind pkg "<query>"` — npm package search. Pass-through query to the registry. */
599
1011
  async function runPackageSearch(flags: CLIFlags): Promise<void> {
600
1012
  if (flags.registry !== "npm") {
@@ -33,8 +33,37 @@ const RASTER_EXT = /\.(png|jpe?g|webp|gif|bmp|avif|heic|tiff?)(?:[?#]|$)/i;
33
33
  /** Cap on memoised downloads — a long session can open many READMEs. */
34
34
  const CACHE_LIMIT = 24;
35
35
 
36
+ /**
37
+ * How many image downloads may be in flight at once. A README can reference
38
+ * dozens of images; firing them all at the same time makes them compete for
39
+ * bandwidth so the first one lands later, not sooner.
40
+ */
41
+ export const MAX_CONCURRENT_IMAGE_FETCHES = 4;
42
+
36
43
  const cache = new Map<string, Promise<Uint8Array | null>>();
37
44
 
45
+ let activeFetches = 0;
46
+ const waiting: Array<() => void> = [];
47
+
48
+ /** Hand the next queued caller its turn, if the cap allows it. */
49
+ function releaseSlot(): void {
50
+ activeFetches--;
51
+ waiting.shift()?.();
52
+ }
53
+
54
+ /** Run `job` as soon as fewer than the cap are already running. */
55
+ async function withSlot<T>(job: () => Promise<T>): Promise<T> {
56
+ if (activeFetches >= MAX_CONCURRENT_IMAGE_FETCHES) {
57
+ await new Promise<void>((resolve) => waiting.push(resolve));
58
+ }
59
+ activeFetches++;
60
+ try {
61
+ return await job();
62
+ } finally {
63
+ releaseSlot();
64
+ }
65
+ }
66
+
38
67
  /** Drop memoised image downloads (used by tests). */
39
68
  export function clearImageByteCache(): void {
40
69
  cache.clear();
@@ -169,7 +198,7 @@ export async function fetchImageBytes(
169
198
  ): Promise<Uint8Array | null> {
170
199
  const cached = cache.get(url);
171
200
  if (cached) return cached;
172
- const pending = loadImageBytes(url, opts);
201
+ const pending = withSlot(() => loadImageBytes(url, opts));
173
202
  cache.set(url, pending);
174
203
  // Map iteration order is insertion order, so the first key is the oldest.
175
204
  while (cache.size > CACHE_LIMIT) {