github-search-cli 9.8.0 → 9.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +109 -0
- package/LICENSE +21 -21
- package/README.md +81 -22
- package/completions/ghfind.bash +55 -51
- package/completions/ghfind.fish +29 -24
- package/completions/ghfind.zsh +52 -39
- package/demo.svg +71 -71
- package/dist/cli.js +1336 -551
- package/package.json +1 -1
- package/release-notes/9.8.1.md +74 -0
- package/release-notes/9.8.2.md +65 -0
- package/scripts/brew.rb +53 -53
- package/scripts/postinstall.js +38 -0
- package/src/agent-skill.ts +14 -0
- package/src/cli.ts +450 -20
- package/src/config.ts +12 -2
- package/src/deepdive.ts +70 -66
- package/src/doctor.ts +3 -2
- package/src/error-report.ts +81 -11
- package/src/errors.ts +0 -1
- package/src/history.ts +2 -5
- package/src/image-loader.ts +30 -1
- package/src/init.ts +1 -2
- package/src/markdown-view.ts +20 -4
- package/src/mcp-server.ts +65 -10
- package/src/notifications.ts +9 -3
- package/src/org.ts +15 -14
- package/src/output.ts +1 -13
- package/src/readme.ts +70 -0
- package/src/releases.ts +29 -27
- package/src/search.ts +4 -0
- package/src/skill-finder.ts +476 -0
- package/src/storage.ts +22 -47
- package/src/tui.ts +17 -33
- package/src/user.ts +15 -14
- package/dist/chunk-GYIL4S5X.js +0 -59
- package/dist/config-OCIPF2IF.js +0 -14
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,8 +123,37 @@ 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
|
|
|
143
|
+
/**
|
|
144
|
+
* Valid `--sort` values: the repo strategies plus the npm-package strategies
|
|
145
|
+
* (the `pkg` subcommand validates its own narrower set).
|
|
146
|
+
*/
|
|
147
|
+
const SORT_VALUES = [
|
|
148
|
+
"best-match",
|
|
149
|
+
"stars",
|
|
150
|
+
"updated",
|
|
151
|
+
"forks",
|
|
152
|
+
"score",
|
|
153
|
+
"downloads",
|
|
154
|
+
"name",
|
|
155
|
+
];
|
|
156
|
+
|
|
114
157
|
function parseArgs(args: string[]): CLIFlags {
|
|
115
158
|
const flags: CLIFlags = {
|
|
116
159
|
query: "",
|
|
@@ -135,6 +178,15 @@ function parseArgs(args: string[]): CLIFlags {
|
|
|
135
178
|
compare: [],
|
|
136
179
|
doctor: false,
|
|
137
180
|
mcp: false,
|
|
181
|
+
bookmarks: false,
|
|
182
|
+
history: false,
|
|
183
|
+
topics: false,
|
|
184
|
+
saved: false,
|
|
185
|
+
remote: false,
|
|
186
|
+
local: false,
|
|
187
|
+
copy: false,
|
|
188
|
+
raw: false,
|
|
189
|
+
limitExplicit: false,
|
|
138
190
|
};
|
|
139
191
|
|
|
140
192
|
const queryParts: string[] = [];
|
|
@@ -199,6 +251,7 @@ function parseArgs(args: string[]): CLIFlags {
|
|
|
199
251
|
break;
|
|
200
252
|
case "--limit": {
|
|
201
253
|
const parsed = parseInt(args[++i], 10);
|
|
254
|
+
flags.limitExplicit = true;
|
|
202
255
|
if (Number.isNaN(parsed) || parsed < 1 || parsed > 100) {
|
|
203
256
|
console.error(`Invalid limit: must be 1-100. Using default 50.`);
|
|
204
257
|
flags.limit = 50;
|
|
@@ -207,9 +260,17 @@ function parseArgs(args: string[]): CLIFlags {
|
|
|
207
260
|
}
|
|
208
261
|
break;
|
|
209
262
|
}
|
|
210
|
-
case "--sort":
|
|
211
|
-
|
|
263
|
+
case "--sort": {
|
|
264
|
+
const sort = args[++i];
|
|
265
|
+
if (!sort || !SORT_VALUES.includes(sort)) {
|
|
266
|
+
console.error(
|
|
267
|
+
`Invalid sort: ${sort ?? "(missing)"} (use best-match|stars|updated|forks).`,
|
|
268
|
+
);
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
|
271
|
+
flags.sort = sort as SearchOptions["sort"];
|
|
212
272
|
break;
|
|
273
|
+
}
|
|
213
274
|
case "--token":
|
|
214
275
|
flags.token = args[++i];
|
|
215
276
|
break;
|
|
@@ -231,9 +292,58 @@ function parseArgs(args: string[]): CLIFlags {
|
|
|
231
292
|
case "--completion":
|
|
232
293
|
flags.completion = args[++i];
|
|
233
294
|
break;
|
|
295
|
+
case "--remote":
|
|
296
|
+
flags.remote = true;
|
|
297
|
+
break;
|
|
298
|
+
case "--local":
|
|
299
|
+
flags.local = true;
|
|
300
|
+
break;
|
|
301
|
+
case "--copy":
|
|
302
|
+
flags.copy = true;
|
|
303
|
+
break;
|
|
304
|
+
case "--raw":
|
|
305
|
+
flags.raw = true;
|
|
306
|
+
break;
|
|
307
|
+
case "--as":
|
|
308
|
+
flags.sharedAs = args[++i];
|
|
309
|
+
break;
|
|
234
310
|
case "mcp":
|
|
235
311
|
flags.mcp = true;
|
|
236
312
|
break;
|
|
313
|
+
case "bookmarks":
|
|
314
|
+
flags.bookmarks = true;
|
|
315
|
+
break;
|
|
316
|
+
case "history":
|
|
317
|
+
flags.history = true;
|
|
318
|
+
break;
|
|
319
|
+
case "topics":
|
|
320
|
+
flags.topics = true;
|
|
321
|
+
break;
|
|
322
|
+
case "saved":
|
|
323
|
+
flags.saved = true;
|
|
324
|
+
break;
|
|
325
|
+
case "readme": {
|
|
326
|
+
// Single repo ref; a following flag means none was given.
|
|
327
|
+
const next = args[i + 1];
|
|
328
|
+
if (next && !next.startsWith("-")) {
|
|
329
|
+
flags.readme = next;
|
|
330
|
+
i++;
|
|
331
|
+
} else {
|
|
332
|
+
flags.readme = "";
|
|
333
|
+
}
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
case "share": {
|
|
337
|
+
// Single repo ref; a following flag means none was given.
|
|
338
|
+
const next = args[i + 1];
|
|
339
|
+
if (next && !next.startsWith("-")) {
|
|
340
|
+
flags.share = next;
|
|
341
|
+
i++;
|
|
342
|
+
} else {
|
|
343
|
+
flags.share = "";
|
|
344
|
+
}
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
237
347
|
case "deep-dive": {
|
|
238
348
|
// Single repo ref (owner/name); a following flag means none.
|
|
239
349
|
const next = args[i + 1];
|
|
@@ -244,8 +354,20 @@ function parseArgs(args: string[]): CLIFlags {
|
|
|
244
354
|
break;
|
|
245
355
|
}
|
|
246
356
|
case "skill": {
|
|
247
|
-
//
|
|
357
|
+
// `skill search <query>` searches the skill registry; plain `skill`
|
|
358
|
+
// lists the catalog and `skill <name>` prints one skill's guide.
|
|
248
359
|
const next = args[i + 1];
|
|
360
|
+
if (next === "search") {
|
|
361
|
+
i++;
|
|
362
|
+
const query = args[i + 1];
|
|
363
|
+
if (query && !query.startsWith("-")) {
|
|
364
|
+
flags.skillSearch = query;
|
|
365
|
+
i++;
|
|
366
|
+
} else {
|
|
367
|
+
flags.skillSearch = "";
|
|
368
|
+
}
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
249
371
|
if (next && !next.startsWith("-")) {
|
|
250
372
|
flags.skill = next;
|
|
251
373
|
i++;
|
|
@@ -335,6 +457,13 @@ Usage:
|
|
|
335
457
|
ghfind pkg <query> Search npm packages, text list
|
|
336
458
|
ghfind mcp Run the MCP server for AI agents (stdio)
|
|
337
459
|
ghfind skill [name] Print the agent skill guide (or catalog)
|
|
460
|
+
ghfind bookmarks [query] Saved repos, newest first
|
|
461
|
+
ghfind history [query] Past searches, newest first
|
|
462
|
+
ghfind topics Browse popular GitHub topics
|
|
463
|
+
ghfind saved Named searches saved in the TUI
|
|
464
|
+
ghfind readme <owner/repo> Print a repository README
|
|
465
|
+
ghfind share a/b --as gh-cli Share snippet (--copy to clipboard)
|
|
466
|
+
ghfind skill search <query> Find skills locally, or --remote
|
|
338
467
|
ghfind --watch <query> Watch mode (poll every Ns)
|
|
339
468
|
ghfind init Run setup wizard
|
|
340
469
|
ghfind login Import the gh CLI token or paste one
|
|
@@ -359,6 +488,11 @@ Options:
|
|
|
359
488
|
--releases New-release feed for bookmarks
|
|
360
489
|
--interval <s> Watch interval in seconds (default: 300)
|
|
361
490
|
--completion <shell> Generate completions
|
|
491
|
+
--remote skill search: query skills.sh instead of this machine
|
|
492
|
+
--local skill search: force the local scan (default)
|
|
493
|
+
--as <fmt> share format: markdown|plain|gh-cli|short
|
|
494
|
+
--copy share: also copy the snippet to the clipboard
|
|
495
|
+
--raw readme: print the raw markdown with no header
|
|
362
496
|
`);
|
|
363
497
|
return;
|
|
364
498
|
}
|
|
@@ -397,11 +531,43 @@ Options:
|
|
|
397
531
|
return;
|
|
398
532
|
}
|
|
399
533
|
|
|
534
|
+
// Skill search — find skills on this machine or in the skills.sh ecosystem
|
|
535
|
+
if (flags.skillSearch !== undefined) {
|
|
536
|
+
await runSkillSearch(flags);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
400
539
|
// Agent skill guide — token-efficient usage doc for coding agents
|
|
401
540
|
if (flags.skill !== undefined) {
|
|
402
541
|
printAgentSkill(flags.skill);
|
|
403
542
|
return;
|
|
404
543
|
}
|
|
544
|
+
// Read-only local state: bookmarks / history / saved searches
|
|
545
|
+
if (flags.bookmarks) {
|
|
546
|
+
runBookmarks(flags);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (flags.history) {
|
|
550
|
+
runHistory(flags);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (flags.saved) {
|
|
554
|
+
runSavedSearches(flags);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
// Popular GitHub topics
|
|
558
|
+
if (flags.topics) {
|
|
559
|
+
await runTopics(flags);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
// README and share-snippet views (parity with the TUI `r` / `y` actions)
|
|
563
|
+
if (flags.readme !== undefined) {
|
|
564
|
+
await runReadme(flags);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (flags.share !== undefined) {
|
|
568
|
+
await runShare(flags);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
405
571
|
// Package search (prototype, ticket 002-008)
|
|
406
572
|
if (flags.pkg) {
|
|
407
573
|
const interactive =
|
|
@@ -595,6 +761,280 @@ async function runDeepDive(
|
|
|
595
761
|
}
|
|
596
762
|
}
|
|
597
763
|
|
|
764
|
+
/** `ghfind skill search <query>` — find skills locally, or in skills.sh with --remote. */
|
|
765
|
+
async function runSkillSearch(flags: CLIFlags): Promise<void> {
|
|
766
|
+
const query = flags.skillSearch ?? "";
|
|
767
|
+
if (!query.trim()) {
|
|
768
|
+
console.error(
|
|
769
|
+
"Usage: ghfind skill search <query> [--remote] [--limit <n>] [--json]",
|
|
770
|
+
);
|
|
771
|
+
process.exit(1);
|
|
772
|
+
}
|
|
773
|
+
if (flags.remote && flags.local) {
|
|
774
|
+
console.error(
|
|
775
|
+
"Contradictory flags: pass either --remote or --local, not both.",
|
|
776
|
+
);
|
|
777
|
+
process.exit(1);
|
|
778
|
+
}
|
|
779
|
+
const source: SkillSource = flags.remote ? "registry" : "local";
|
|
780
|
+
try {
|
|
781
|
+
const result = await searchSkills(query, {
|
|
782
|
+
source,
|
|
783
|
+
limit: flags.limit,
|
|
784
|
+
});
|
|
785
|
+
if (flags.json) {
|
|
786
|
+
console.log(formatSkillSearchJson(result));
|
|
787
|
+
} else if (flags.format === "names") {
|
|
788
|
+
console.log(formatSkillSearchNames(result));
|
|
789
|
+
} else {
|
|
790
|
+
console.log(formatSkillSearchText(result));
|
|
791
|
+
}
|
|
792
|
+
} catch (err) {
|
|
793
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
794
|
+
process.exit(1);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** `ghfind bookmarks [query]` — the saved repos, newest first. */
|
|
799
|
+
function runBookmarks(flags: CLIFlags): void {
|
|
800
|
+
const query = flags.query.trim();
|
|
801
|
+
const bookmarks = query ? searchBookmarks(query) : getBookmarks();
|
|
802
|
+
if (flags.json) {
|
|
803
|
+
console.log(JSON.stringify(bookmarks, null, 2));
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (bookmarks.length === 0) {
|
|
807
|
+
console.log(
|
|
808
|
+
query
|
|
809
|
+
? `No bookmarks match "${query}".`
|
|
810
|
+
: "No bookmarks yet. Press Space → Bookmark in the TUI to save one.",
|
|
811
|
+
);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (flags.csv || flags.markdown) {
|
|
815
|
+
const header = ["full_name", "stars", "language", "tags", "saved_at"];
|
|
816
|
+
const rows = bookmarks.map((b) => [
|
|
817
|
+
b.repo.fullName,
|
|
818
|
+
String(b.repo.stars),
|
|
819
|
+
b.repo.language ?? "",
|
|
820
|
+
b.tags.join(" "),
|
|
821
|
+
new Date(b.savedAt).toISOString().slice(0, 10),
|
|
822
|
+
]);
|
|
823
|
+
console.log(
|
|
824
|
+
flags.csv ? rowsToCsv(header, rows) : rowsToMarkdown(header, rows),
|
|
825
|
+
);
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
for (const b of bookmarks) {
|
|
829
|
+
const tags = b.tags.length > 0 ? ` [${b.tags.join(", ")}]` : "";
|
|
830
|
+
console.log(
|
|
831
|
+
`${b.repo.fullName} — ${b.repo.stars.toLocaleString()} ${b.repo.language ?? "n/a"}${tags}`,
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** `ghfind history [query]` — past searches, newest first. */
|
|
837
|
+
function runHistory(flags: CLIFlags): void {
|
|
838
|
+
// History entries are short; 20 is plenty unless the caller says otherwise.
|
|
839
|
+
const limit = flags.limitExplicit ? flags.limit : 20;
|
|
840
|
+
const query = flags.query.trim().toLowerCase();
|
|
841
|
+
const entries = readHistory()
|
|
842
|
+
.filter((e) => !query || e.query.toLowerCase().includes(query))
|
|
843
|
+
.slice(0, limit);
|
|
844
|
+
if (flags.json) {
|
|
845
|
+
console.log(JSON.stringify(entries, null, 2));
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
if (entries.length === 0) {
|
|
849
|
+
console.log(
|
|
850
|
+
query ? `No history matches "${query}".` : "No search history yet.",
|
|
851
|
+
);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
if (flags.csv || flags.markdown) {
|
|
855
|
+
const header = ["query", "mode", "results", "when"];
|
|
856
|
+
const rows = entries.map((e) => [
|
|
857
|
+
e.query,
|
|
858
|
+
e.mode,
|
|
859
|
+
String(e.resultCount),
|
|
860
|
+
new Date(e.timestamp).toISOString(),
|
|
861
|
+
]);
|
|
862
|
+
console.log(
|
|
863
|
+
flags.csv ? rowsToCsv(header, rows) : rowsToMarkdown(header, rows),
|
|
864
|
+
);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
for (const e of entries) {
|
|
868
|
+
const when = new Date(e.timestamp).toISOString().slice(0, 10);
|
|
869
|
+
console.log(
|
|
870
|
+
`${e.query} — ${e.mode} — ${e.resultCount} results — ${when}`,
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** `ghfind saved` — named searches saved in the TUI. */
|
|
876
|
+
function runSavedSearches(flags: CLIFlags): void {
|
|
877
|
+
const saved = getSavedSearches();
|
|
878
|
+
if (flags.json) {
|
|
879
|
+
console.log(JSON.stringify(saved, null, 2));
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
if (saved.length === 0) {
|
|
883
|
+
console.log("No saved searches yet. Save one from the TUI command menu.");
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
if (flags.csv || flags.markdown) {
|
|
887
|
+
const header = ["name", "query", "mode", "limit"];
|
|
888
|
+
const rows = saved.map((s) => [s.name, s.query, s.mode, String(s.limit)]);
|
|
889
|
+
console.log(
|
|
890
|
+
flags.csv ? rowsToCsv(header, rows) : rowsToMarkdown(header, rows),
|
|
891
|
+
);
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
for (const s of saved) {
|
|
895
|
+
console.log(`${s.name} — ${s.query} — ${s.mode} — limit ${s.limit}`);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** CSV for the small local-state tables (repos use output.ts's richer schema). */
|
|
900
|
+
function rowsToCsv(header: string[], rows: string[][]): string {
|
|
901
|
+
const quote = (value: string) =>
|
|
902
|
+
/[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
|
903
|
+
return [header, ...rows].map((row) => row.map(quote).join(",")).join("\n");
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** Markdown table for the same small local-state tables. */
|
|
907
|
+
function rowsToMarkdown(header: string[], rows: string[][]): string {
|
|
908
|
+
const cell = (value: string) => value.replace(/\|/g, "\\|");
|
|
909
|
+
return [
|
|
910
|
+
`| ${header.map(cell).join(" | ")} |`,
|
|
911
|
+
`|${header.map(() => "---").join("|")}|`,
|
|
912
|
+
...rows.map((row) => `| ${row.map(cell).join(" | ")} |`),
|
|
913
|
+
].join("\n");
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/** Clip a string to `width` characters, ending on an ellipsis when cut. */
|
|
917
|
+
function truncate(text: string, width: number): string {
|
|
918
|
+
if (text.length <= width) return text;
|
|
919
|
+
return `${text.slice(0, width - 1).trimEnd()}\u2026`;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** GitHub topic blurbs are HTML; flatten them to one plain text line. */
|
|
923
|
+
function plainText(html: string): string {
|
|
924
|
+
return html
|
|
925
|
+
.replace(/<\/p>|<br\s*\/?>/gi, " ")
|
|
926
|
+
.replace(/<[^>]*>/g, "")
|
|
927
|
+
.replace(/&/g, "&")
|
|
928
|
+
.replace(/</g, "<")
|
|
929
|
+
.replace(/>/g, ">")
|
|
930
|
+
.replace(/"/g, '"')
|
|
931
|
+
.replace(/'/g, "'")
|
|
932
|
+
.replace(/ /g, " ")
|
|
933
|
+
.replace(/\s+/g, " ")
|
|
934
|
+
.trim();
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/** `ghfind topics` — popular GitHub topics. */
|
|
938
|
+
async function runTopics(flags: CLIFlags): Promise<void> {
|
|
939
|
+
try {
|
|
940
|
+
const topics = (await fetchTopics()).slice(0, flags.limit);
|
|
941
|
+
if (flags.json) {
|
|
942
|
+
console.log(JSON.stringify(topics, null, 2));
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
if (topics.length === 0) {
|
|
946
|
+
console.log("No topics returned.");
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
for (const t of topics) {
|
|
950
|
+
// Blurbs run to paragraphs; text output stays readable by clipping.
|
|
951
|
+
const description = truncate(plainText(t.description), 140);
|
|
952
|
+
console.log(description ? `${t.name} ${description}` : t.name);
|
|
953
|
+
}
|
|
954
|
+
} catch (err) {
|
|
955
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
956
|
+
process.exit(1);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** `ghfind readme <owner/repo>` — print a repository README. */
|
|
961
|
+
async function runReadme(flags: CLIFlags): Promise<void> {
|
|
962
|
+
const ref = flags.readme ?? "";
|
|
963
|
+
if (!ref) {
|
|
964
|
+
console.error(
|
|
965
|
+
"Usage: ghfind readme <owner/repo> [--json] [--raw] [--token <t>]",
|
|
966
|
+
);
|
|
967
|
+
process.exit(1);
|
|
968
|
+
}
|
|
969
|
+
try {
|
|
970
|
+
const { owner, name } = repoFromRef(ref); // validates the ref
|
|
971
|
+
const readme = await fetchReadme(owner, name, {
|
|
972
|
+
// A bare ref carries no visibility info, so the token is never
|
|
973
|
+
// forwarded to raw.githubusercontent.com.
|
|
974
|
+
token: flags.token,
|
|
975
|
+
private: false,
|
|
976
|
+
});
|
|
977
|
+
if (!readme) {
|
|
978
|
+
console.error(`No README found for ${owner}/${name}.`);
|
|
979
|
+
process.exit(1);
|
|
980
|
+
}
|
|
981
|
+
if (flags.json) {
|
|
982
|
+
console.log(
|
|
983
|
+
JSON.stringify(
|
|
984
|
+
{ owner, name, sourceUrl: readme.sourceUrl, text: readme.text },
|
|
985
|
+
null,
|
|
986
|
+
2,
|
|
987
|
+
),
|
|
988
|
+
);
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
if (!flags.raw) {
|
|
992
|
+
console.log(`# ${owner}/${name} README (${readme.sourceUrl})`);
|
|
993
|
+
console.log("");
|
|
994
|
+
}
|
|
995
|
+
console.log(readme.text);
|
|
996
|
+
} catch (err) {
|
|
997
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
998
|
+
process.exit(1);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** `ghfind share <owner/repo> [--as markdown|plain|gh-cli|short] [--copy]` */
|
|
1003
|
+
async function runShare(flags: CLIFlags): Promise<void> {
|
|
1004
|
+
const ref = flags.share ?? "";
|
|
1005
|
+
if (!ref) {
|
|
1006
|
+
console.error(
|
|
1007
|
+
"Usage: ghfind share <owner/repo> [--as markdown|plain|gh-cli|short] [--copy]",
|
|
1008
|
+
);
|
|
1009
|
+
process.exit(1);
|
|
1010
|
+
}
|
|
1011
|
+
const SHARE_FORMATS: ShareFormat[] = ["markdown", "plain", "gh-cli", "short"];
|
|
1012
|
+
const as = (flags.sharedAs ?? "markdown") as ShareFormat;
|
|
1013
|
+
if (!SHARE_FORMATS.includes(as)) {
|
|
1014
|
+
console.error(
|
|
1015
|
+
`Invalid --as value: "${flags.sharedAs}" (use markdown|plain|gh-cli|short).`,
|
|
1016
|
+
);
|
|
1017
|
+
process.exit(1);
|
|
1018
|
+
}
|
|
1019
|
+
try {
|
|
1020
|
+
repoFromRef(ref); // validate before the network round-trip
|
|
1021
|
+
const repo = await resolveRepoFromRef(ref, flags.token);
|
|
1022
|
+
const text = formatShare(repo, as);
|
|
1023
|
+
console.log(text);
|
|
1024
|
+
if (flags.copy) {
|
|
1025
|
+
const copied = await copyToClipboard(text);
|
|
1026
|
+
console.error(
|
|
1027
|
+
copied
|
|
1028
|
+
? "Copied to clipboard."
|
|
1029
|
+
: "Could not copy to the clipboard on this platform.",
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
} catch (err) {
|
|
1033
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1034
|
+
process.exit(1);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
598
1038
|
/** `ghfind pkg "<query>"` — npm package search. Pass-through query to the registry. */
|
|
599
1039
|
async function runPackageSearch(flags: CLIFlags): Promise<void> {
|
|
600
1040
|
if (flags.registry !== "npm") {
|
|
@@ -812,22 +1252,14 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
|
|
|
812
1252
|
}
|
|
813
1253
|
},
|
|
814
1254
|
compare: async (_ctx) => {
|
|
815
|
-
const search = createGitHubSearch(undefined, [flags.token ?? ""]);
|
|
816
1255
|
const repos: Repo[] = [];
|
|
817
1256
|
const missing: string[] = [];
|
|
818
1257
|
for (const fullName of flags.compare) {
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
json: false,
|
|
825
|
-
verbose: false,
|
|
826
|
-
token: flags.token,
|
|
827
|
-
},
|
|
828
|
-
);
|
|
829
|
-
if (res.repos.length > 0) repos.push(...res.repos);
|
|
830
|
-
else missing.push(fullName);
|
|
1258
|
+
try {
|
|
1259
|
+
repos.push(await resolveRepoFromRef(fullName, flags.token));
|
|
1260
|
+
} catch {
|
|
1261
|
+
missing.push(fullName);
|
|
1262
|
+
}
|
|
831
1263
|
}
|
|
832
1264
|
if (repos.length < 2) {
|
|
833
1265
|
console.error(
|
|
@@ -869,11 +1301,9 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
|
|
|
869
1301
|
}
|
|
870
1302
|
|
|
871
1303
|
main().catch((err) => {
|
|
872
|
-
if (err instanceof
|
|
1304
|
+
if (err instanceof SearchCliError) {
|
|
873
1305
|
// ponytail: known user-facing errors already carry a friendly message; skip the report prompt
|
|
874
|
-
console.error(
|
|
875
|
-
err instanceof SearchCliError ? err.userMessage : err.message,
|
|
876
|
-
);
|
|
1306
|
+
console.error(err.userMessage);
|
|
877
1307
|
} else {
|
|
878
1308
|
console.error(err instanceof Error ? err.message : String(err));
|
|
879
1309
|
reportError(err);
|
package/src/config.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { Config } from "./types";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
13
|
|
|
14
14
|
/** Directory for persistent state (history, bookmarks, session). */
|
|
15
15
|
export function stateDir(): string {
|
|
@@ -61,7 +61,17 @@ export function saveConfig(config: Config): void {
|
|
|
61
61
|
Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")),
|
|
62
62
|
);
|
|
63
63
|
mkdirSync(dir, { recursive: true });
|
|
64
|
-
|
|
64
|
+
// The file holds a plaintext GitHub token — owner-only, and tighten any
|
|
65
|
+
// pre-existing world-readable file too.
|
|
66
|
+
writeFileSync(path, JSON.stringify(config, null, 2), {
|
|
67
|
+
encoding: "utf-8",
|
|
68
|
+
mode: 0o600,
|
|
69
|
+
});
|
|
70
|
+
try {
|
|
71
|
+
chmodSync(path, 0o600);
|
|
72
|
+
} catch {
|
|
73
|
+
// best effort (read-only file / unsupported filesystem)
|
|
74
|
+
}
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
function mergeWithEnv(config: Config): Config {
|