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/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
- flags.sort = args[++i] as SearchOptions["sort"];
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
- // Optional skill id (ids contain no spaces); a following flag means none.
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(/&amp;/g, "&")
928
+ .replace(/&lt;/g, "<")
929
+ .replace(/&gt;/g, ">")
930
+ .replace(/&quot;/g, '"')
931
+ .replace(/&#39;/g, "'")
932
+ .replace(/&nbsp;/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
- const res = await search.search(
820
- { keywords: [fullName], qualifiers: [], raw: fullName },
821
- {
822
- limit: 1,
823
- sort: "stars",
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 Error && err.name === "SearchCliError") {
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
- writeFileSync(path, JSON.stringify(config, null, 2));
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 {