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/CHANGELOG.md +46 -0
- package/README.md +70 -14
- package/completions/ghfind.bash +5 -1
- package/completions/ghfind.fish +6 -1
- package/completions/ghfind.zsh +14 -1
- package/dist/cli.js +694 -33
- package/package.json +1 -1
- package/release-notes/9.8.1.md +74 -0
- package/src/agent-skill.ts +14 -0
- package/src/cli.ts +413 -1
- package/src/image-loader.ts +30 -1
- package/src/markdown-view.ts +20 -4
- package/src/mcp-server.ts +60 -0
- package/src/readme.ts +54 -0
- package/src/skill-finder.ts +445 -0
- package/src/tui.ts +8 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "github-search-cli",
|
|
3
|
-
"version": "9.8.
|
|
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.** `[](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`.
|
package/src/agent-skill.ts
CHANGED
|
@@ -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
|
-
//
|
|
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(/&/g, "&")
|
|
905
|
+
.replace(/</g, "<")
|
|
906
|
+
.replace(/>/g, ">")
|
|
907
|
+
.replace(/"/g, '"')
|
|
908
|
+
.replace(/'/g, "'")
|
|
909
|
+
.replace(/ /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") {
|
package/src/image-loader.ts
CHANGED
|
@@ -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) {
|