github-search-cli 9.4.1 → 9.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +65 -1
  2. package/README.md +268 -237
  3. package/bin/ghfind.js +98 -89
  4. package/completions/ghfind.bash +1 -1
  5. package/completions/ghfind.fish +3 -1
  6. package/completions/ghfind.zsh +6 -0
  7. package/dist/{chunk-IVUU2KGR.js → chunk-GYIL4S5X.js} +4 -1
  8. package/dist/cli.js +1179 -403
  9. package/dist/{config-GNZOQRTH.js → config-OCIPF2IF.js} +1 -1
  10. package/package.json +16 -5
  11. package/release-notes/9.3.0.md +32 -29
  12. package/scripts/build.ts +34 -19
  13. package/scripts/generate-svg-demo.js +218 -168
  14. package/scripts/postinstall.js +176 -176
  15. package/src/bookmarks.ts +69 -69
  16. package/src/cli.ts +650 -566
  17. package/src/compare.ts +69 -47
  18. package/src/config.ts +70 -65
  19. package/src/deepdive.ts +149 -118
  20. package/src/doctor.ts +287 -287
  21. package/src/error-report.ts +8 -12
  22. package/src/errors.ts +62 -59
  23. package/src/explore.ts +34 -29
  24. package/src/export.ts +73 -63
  25. package/src/help.ts +157 -157
  26. package/src/history.ts +69 -55
  27. package/src/init.ts +60 -55
  28. package/src/landing.ts +106 -104
  29. package/src/login.ts +186 -0
  30. package/src/markdown-render.ts +1 -6
  31. package/src/notifications.ts +75 -59
  32. package/src/open-url.ts +16 -16
  33. package/src/org.ts +299 -0
  34. package/src/output.ts +153 -137
  35. package/src/package-browser.ts +317 -305
  36. package/src/package.ts +149 -137
  37. package/src/pipe.ts +60 -54
  38. package/src/releases.ts +182 -144
  39. package/src/saved-searches.ts +72 -50
  40. package/src/search-state.ts +283 -229
  41. package/src/search.ts +678 -653
  42. package/src/session.ts +26 -24
  43. package/src/share.ts +61 -54
  44. package/src/status.ts +68 -61
  45. package/src/storage.ts +136 -106
  46. package/src/themes.ts +259 -116
  47. package/src/tips.ts +42 -42
  48. package/src/toast.ts +70 -69
  49. package/src/trending-parser.ts +104 -0
  50. package/src/trending.ts +481 -462
  51. package/src/tui.ts +3015 -2867
  52. package/src/types.ts +160 -158
  53. package/src/update-check.ts +231 -215
  54. package/src/version.ts +33 -0
  55. package/src/watch.ts +95 -74
package/src/cli.ts CHANGED
@@ -1,566 +1,650 @@
1
- // Prevent tui.ts auto-launch when imported from cli.ts
2
- process.env.GHFIND_CLI_RUN = "1";
3
- import {
4
- parseQuery,
5
- applyFlagFilters,
6
- rankRepos,
7
- createGitHubSearch,
8
- createTrendingSearch,
9
- } from "./search";
10
- import {
11
- createPackageSearch,
12
- formatPackagesJson,
13
- formatPackagesCsv,
14
- formatPackagesMarkdown,
15
- formatPackagesUrls,
16
- formatPackagesNames,
17
- sortPackages,
18
- type PackageSortMode,
19
- } from "./package";
20
- import { launchPackageBrowser } from "./package-browser";
21
- import { runDoctor } from "./doctor";
22
- import { reportError } from "./error-report";
23
- import {
24
- format as formatOutput,
25
- exportToFile,
26
- pipeExec,
27
- type Format,
28
- type ExportFormat,
29
- type FormatLine,
30
- } from "./output";
31
- import { runWatch } from "./watch";
32
- import { checkAndNotify, allCachedReleases } from "./releases";
33
- import { runInitWizard } from "./init";
34
- import { buildComparisonTable } from "./compare";
35
- import type { Repo } from "./types";
36
- import { readFileSync } from "fs";
37
- import { join, dirname } from "path";
38
- import { fileURLToPath } from "url";
39
- import { launchBrowser } from "./tui";
40
- import type { SearchOptions } from "./types";
41
-
42
- const __dirname = dirname(fileURLToPath(import.meta.url));
43
- const PKG_DIR = join(__dirname, "..");
44
-
45
- interface CLIFlags {
46
- query: string;
47
- json: boolean;
48
- csv: boolean;
49
- markdown: boolean;
50
- count: boolean;
51
- limit: number;
52
- sort: SearchOptions["sort"];
53
- token?: string;
54
- trending: boolean;
55
- since: string;
56
- pipe?: string;
57
- format?: string;
58
- watch: boolean;
59
- releases: boolean;
60
- interval: number;
61
- completion?: string;
62
- init: boolean;
63
- version: boolean;
64
- help: boolean;
65
- pkg: boolean;
66
- theme?: string;
67
- registry: string;
68
- compare: string[]; // repo fullNames to compare
69
- doctor: boolean;
70
- }
71
-
72
- function parseArgs(args: string[]): CLIFlags {
73
- const flags: CLIFlags = {
74
- query: "",
75
- json: false,
76
- csv: false,
77
- markdown: false,
78
- count: false,
79
- limit: 50,
80
- sort: "best-match",
81
- token: process.env.GITHUB_TOKEN,
82
- trending: false,
83
- since: "daily",
84
- watch: false,
85
- releases: false,
86
- interval: 300,
87
- init: false,
88
- version: false,
89
- help: false,
90
- pkg: false,
91
- registry: "npm",
92
- compare: [],
93
- doctor: false,
94
- };
95
-
96
- let queryParts: string[] = [];
97
- for (let i = 0; i < args.length; i++) {
98
- const arg = args[i];
99
- switch (arg) {
100
- case "--json":
101
- flags.json = true;
102
- break;
103
- case "--csv":
104
- flags.csv = true;
105
- break;
106
- case "--markdown":
107
- flags.markdown = true;
108
- break;
109
- case "--count":
110
- flags.count = true;
111
- break;
112
- case "--trending":
113
- flags.trending = true;
114
- break;
115
- case "--watch":
116
- flags.watch = true;
117
- break;
118
- case "--releases":
119
- flags.releases = true;
120
- break;
121
- case "--version":
122
- case "-v":
123
- flags.version = true;
124
- break;
125
- case "--doctor":
126
- case "doctor":
127
- flags.doctor = true;
128
- break;
129
- case "--help":
130
- case "-h":
131
- flags.help = true;
132
- break;
133
- case "init":
134
- flags.init = true;
135
- break;
136
- case "--limit": {
137
- const parsed = parseInt(args[++i]);
138
- if (isNaN(parsed) || parsed < 1 || parsed > 100) {
139
- console.error(`Invalid limit: must be 1-100. Using default 50.`);
140
- flags.limit = 50;
141
- } else {
142
- flags.limit = parsed;
143
- }
144
- break;
145
- }
146
- case "--sort":
147
- flags.sort = args[++i] as SearchOptions["sort"];
148
- break;
149
- case "--token":
150
- flags.token = args[++i];
151
- break;
152
- case "--since":
153
- flags.since = args[++i];
154
- break;
155
- case "--pipe":
156
- flags.pipe = args[++i];
157
- break;
158
- case "--format":
159
- flags.format = args[++i];
160
- break;
161
- case "--registry":
162
- flags.registry = args[++i] ?? "npm";
163
- break;
164
- case "--theme":
165
- flags.theme = args[++i] ?? "tokyo-night";
166
- break;
167
- case "--completion":
168
- flags.completion = args[++i];
169
- break;
170
- case "--compare": {
171
- // Collect repo names until next flag
172
- const repos: string[] = [];
173
- while (i + 1 < args.length && !args[i + 1].startsWith("-")) {
174
- repos.push(args[++i]);
175
- }
176
- flags.compare = repos;
177
- break;
178
- }
179
- default:
180
- if (!arg.startsWith("-")) queryParts.push(arg);
181
- }
182
- }
183
- if (queryParts[0] === "pkg") {
184
- flags.pkg = true;
185
- queryParts.shift();
186
- }
187
- flags.query = queryParts.join(" ");
188
- return flags;
189
- }
190
-
191
- async function main() {
192
- const flags = parseArgs(process.argv.slice(2));
193
-
194
- // Version
195
- if (flags.version) {
196
- const pkg = JSON.parse(
197
- readFileSync(join(PKG_DIR, "package.json"), "utf-8"),
198
- );
199
- console.log(`ghfind v${pkg.version}`);
200
- return;
201
- }
202
-
203
- // Doctor — environment diagnostics
204
- if (flags.doctor) {
205
- await runDoctor();
206
- return;
207
- }
208
-
209
- // Help
210
- if (flags.help) {
211
- console.log(`
212
- ghfind — Interactive GitHub repository browser
213
-
214
- Usage:
215
- ghfind Launch interactive TUI
216
- ghfind <query> --json Search, output JSON
217
- ghfind <query> --csv Search, output CSV
218
- ghfind <query> --markdown Search, output Markdown table
219
- ghfind <query> --count Just the result count
220
- ghfind <query> --format <fmt> Format lines (urls|names|ssh-urls|clone-commands|ids)
221
- ghfind <query> --pipe <target> Pipe to clone/open
222
- ghfind --trending --json Trending repos as JSON
223
- ghfind --releases Check bookmarks for new releases
224
- ghfind --compare <repo1> <repo2> Compare two+ repos side-by-side
225
- ghfind pkg <query> --json Search npm packages, output JSON
226
- ghfind pkg <query> Search npm packages, text list
227
- ghfind --watch <query> Watch mode (poll every Ns)
228
- ghfind init Run setup wizard
229
- ghfind --completion <shell> Print completion script (bash|zsh|fish)
230
- ghfind --version Print version
231
- ghfind --doctor Run environment diagnostics (troubleshooting)
232
- ghfind --help Print this help
233
-
234
- Options:
235
- --json, --csv, --markdown, --count Output format
236
- --limit <n> Max results (default: 50)
237
- --sort <strategy> Sort: best-match|stars|updated|forks
238
- --theme <name> Theme: tokyo-night|premium-dark (default: tokyo-night)
239
- --trending Trending mode
240
- --since <period> Trending period: daily|weekly|monthly
241
- --pipe <target> Pipe target: clone|open
242
- --format <fmt> Line format: urls|names|ssh-urls|...
243
- --registry <name> Registry for pkg: npm (default) | others TBD
244
-
245
- --watch Watch mode (re-run periodically)
246
- --releases New-release feed for bookmarks
247
- --interval <s> Watch interval in seconds (default: 300)
248
- --completion <shell> Generate completions
249
- `);
250
- return;
251
- }
252
-
253
- // Shell completions
254
- if (flags.completion) {
255
- const shell = flags.completion;
256
- try {
257
- const content = readFileSync(
258
- join(PKG_DIR, "completions", `ghfind.${shell}`),
259
- "utf-8",
260
- );
261
- console.log(content);
262
- } catch {
263
- console.error(`Completions not available for shell: ${shell}`);
264
- process.exit(1);
265
- }
266
- return;
267
- }
268
-
269
- // Init wizard
270
- if (flags.init) {
271
- await runInitWizard();
272
- return;
273
- }
274
- // Package search (prototype, ticket 002-008)
275
- if (flags.pkg) {
276
- const interactive =
277
- !flags.query &&
278
- !flags.json &&
279
- !flags.csv &&
280
- !flags.markdown &&
281
- !flags.count &&
282
- !flags.format;
283
- if (interactive) {
284
- await launchPackageBrowser();
285
- } else {
286
- await runPackageSearch(flags);
287
- }
288
- return;
289
- }
290
-
291
- // Determine output format
292
- const outputFormat: ExportFormat | undefined = flags.json
293
- ? "json"
294
- : flags.csv
295
- ? "csv"
296
- : flags.markdown
297
- ? "markdown"
298
- : undefined;
299
- const isNonInteractive =
300
- outputFormat ||
301
- flags.count ||
302
- flags.format ||
303
- flags.pipe ||
304
- flags.watch ||
305
- flags.releases;
306
-
307
- // Non-interactive mode
308
- if (isNonInteractive) {
309
- if (flags.releases) {
310
- await runReleases(flags, outputFormat);
311
- return;
312
- }
313
- await runNonInteractive(flags, outputFormat);
314
- return;
315
- }
316
-
317
- await launchBrowser(flags.theme);
318
- }
319
-
320
- interface SearchContext {
321
- query: string;
322
- parsed: ReturnType<typeof parseQuery> & { raw: string };
323
- provider: ReturnType<typeof createGitHubSearch>;
324
- }
325
-
326
- function buildSearchContext(flags: CLIFlags): SearchContext {
327
- const parsed = applyFlagFilters(parseQuery(flags.query), {});
328
- return {
329
- query: flags.query,
330
- parsed,
331
- provider: createGitHubSearch(undefined, flags.token ? [flags.token] : []),
332
- };
333
- }
334
-
335
- /** `ghfind pkg "<query>"` — npm package search. Pass-through query to the registry. */
336
- async function runPackageSearch(flags: CLIFlags): Promise<void> {
337
- if (flags.registry !== "npm") {
338
- console.error(`Registry "${flags.registry}" not yet supported (only npm).`);
339
- process.exit(1);
340
- }
341
- const PKG_SORTS: PackageSortMode[] = [
342
- "best-match",
343
- "score",
344
- "downloads",
345
- "name",
346
- ];
347
- const pkgSort = flags.sort as PackageSortMode;
348
- if (!PKG_SORTS.includes(pkgSort)) {
349
- console.error(
350
- `Invalid sort for pkg: "${flags.sort}" (use best-match|score|downloads|name).`,
351
- );
352
- process.exit(1);
353
- }
354
- const search = createPackageSearch();
355
- const query = flags.query.trim();
356
- if (!query) {
357
- console.error(
358
- "Usage: ghfind pkg <query> [--json|--csv|--markdown|--count|--format urls|names] [--limit <n>]",
359
- );
360
- process.exit(1);
361
- }
362
- try {
363
- const { totalCount, packages } = await search.searchPackage(
364
- query,
365
- flags.limit,
366
- );
367
- const sorted = sortPackages(packages, pkgSort);
368
- if (flags.count) {
369
- console.log(totalCount);
370
- } else if (flags.json) {
371
- console.log(formatPackagesJson(sorted));
372
- } else if (flags.csv) {
373
- console.log(formatPackagesCsv(sorted));
374
- } else if (flags.markdown) {
375
- console.log(formatPackagesMarkdown(sorted));
376
- } else if (flags.format === "urls") {
377
- console.log(formatPackagesUrls(sorted));
378
- } else if (flags.format === "names") {
379
- console.log(formatPackagesNames(sorted));
380
- } else {
381
- for (const p of sorted) {
382
- console.log(
383
- `${p.name}@${p.version} ↓ ${p.downloads.toLocaleString()} ${p.score.toFixed(2)} ${p.description ?? ""}`,
384
- );
385
- }
386
- }
387
- } catch (err) {
388
- console.error(err instanceof Error ? err.message : String(err));
389
- process.exit(1);
390
- }
391
- }
392
-
393
- /** `ghfind --releases` — check bookmarks for new releases; print the cached feed, then mark all seen. */
394
- async function runReleases(flags: CLIFlags, outputFormat?: ExportFormat): Promise<void> {
395
- const results = await checkAndNotify({ token: flags.token });
396
- const releases = allCachedReleases();
397
- if (outputFormat === "json") {
398
- console.log(JSON.stringify(releases.map((r) => ({
399
- fullName: r.fullName, tag: r.tagName, name: r.name,
400
- url: r.url, publishedAt: r.publishedAt, prerelease: r.prerelease,
401
- })), null, 2));
402
- return;
403
- }
404
- const unseen = results.reduce((n, r) => n + r.newReleases.length, 0);
405
- const errors = results.filter((r) => r.error).length;
406
- console.log(`Checked ${results.length} bookmarked repos — ${unseen} new release${unseen === 1 ? "" : "s"}${errors ? `, ${errors} error${errors === 1 ? "" : "s"}` : ""}.`);
407
- for (const r of releases) {
408
- console.log(`${r.fullName} ${r.prerelease ? "[pre] " : ""}${r.tagName} ${r.publishedAt.slice(0, 10)}\n ${r.url}`);
409
- }
410
- }
411
-
412
- async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
413
- // Dispatch table: each handler receives shared context + flags, returns void.
414
- const handlers: Record<string, (ctx: SearchContext) => Promise<void>> = {
415
- trending: async (ctx) => {
416
- const since =
417
- flags.since === "weekly"
418
- ? "weekly"
419
- : flags.since === "monthly"
420
- ? "monthly"
421
- : "daily";
422
- const trendingSearch = createTrendingSearch();
423
- const response = await trendingSearch.search(
424
- { keywords: [], qualifiers: [], raw: "trending" },
425
- {
426
- limit: 25,
427
- sort: "stars",
428
- json: false,
429
- verbose: false,
430
- trendingSince: since,
431
- },
432
- );
433
- if (outputFormat) {
434
- console.log(formatOutput(response.repos, outputFormat));
435
- } else if (flags.count) {
436
- console.log(response.totalCount);
437
- } else {
438
- for (const r of response.repos) {
439
- console.log(
440
- r.fullName +
441
- " ★ " +
442
- r.stars +
443
- " ▲ +" +
444
- r.score +
445
- " " +
446
- since +
447
- " ● " +
448
- (r.language ?? ""),
449
- );
450
- }
451
- }
452
- },
453
- watch: async (ctx) => {
454
- let tick = 0;
455
- await runWatch(
456
- {
457
- query: ctx.query,
458
- sort: flags.sort,
459
- limit: flags.limit,
460
- token: flags.token,
461
- intervalMs: flags.interval * 1000,
462
- trending: flags.trending,
463
- },
464
- (repos, delta) => {
465
- tick++;
466
- if (outputFormat) {
467
- console.log(
468
- `[Watch #${tick}] ${repos.length} results (${delta >= 0 ? "+" : ""}${delta} since last check)`,
469
- );
470
- console.log(formatOutput(repos, outputFormat));
471
- } else {
472
- console.log(
473
- `[Watch #${tick}] ${repos.length} results — ${delta >= 0 ? "+" : ""}${delta} since last check`,
474
- );
475
- }
476
- },
477
- (err) => console.error(`[Watch error] ${err.message}`),
478
- );
479
- },
480
- pipe: async (ctx) => {
481
- const response = await ctx.provider.search(ctx.parsed, {
482
- limit: flags.limit,
483
- sort: flags.sort,
484
- json: false,
485
- verbose: false,
486
- token: flags.token,
487
- });
488
- const repos = rankRepos(response.repos, flags.sort);
489
- await pipeExec(repos, flags.pipe!);
490
- },
491
- format: async (ctx) => {
492
- const response = await ctx.provider.search(ctx.parsed, {
493
- limit: flags.limit,
494
- sort: flags.sort,
495
- json: false,
496
- verbose: false,
497
- token: flags.token,
498
- });
499
- const repos = rankRepos(response.repos, flags.sort);
500
- console.log(formatOutput(repos, flags.format as Format));
501
- },
502
- search: async (ctx) => {
503
- const response = await ctx.provider.search(ctx.parsed, {
504
- limit: flags.limit,
505
- sort: flags.sort,
506
- json: false,
507
- verbose: false,
508
- token: flags.token,
509
- });
510
- const repos = rankRepos(response.repos, flags.sort);
511
- if (flags.count) {
512
- console.log(response.totalCount);
513
- return;
514
- }
515
- if (outputFormat) {
516
- console.log(formatOutput(repos, outputFormat));
517
- }
518
- },
519
- compare: async (_ctx) => {
520
- const search = createGitHubSearch(undefined, [flags.token ?? ""]);
521
- const repos: Repo[] = [];
522
- for (const fullName of flags.compare) {
523
- const res = await search.search(
524
- { keywords: [fullName], qualifiers: [], raw: fullName },
525
- {
526
- limit: 1,
527
- sort: "stars",
528
- json: false,
529
- verbose: false,
530
- token: flags.token,
531
- },
532
- );
533
- repos.push(...res.repos);
534
- }
535
- console.log(buildComparisonTable(repos));
536
- },
537
- };
538
-
539
- // Determine which handler to run
540
- const key =
541
- flags.compare.length > 0
542
- ? "compare"
543
- : flags.trending && !flags.watch
544
- ? "trending"
545
- : flags.watch
546
- ? "watch"
547
- : flags.pipe
548
- ? "pipe"
549
- : flags.format
550
- ? "format"
551
- : "search";
552
-
553
- const ctx = buildSearchContext(flags);
554
- await handlers[key](ctx);
555
- }
556
-
557
- main().catch((err) => {
558
- if (err instanceof Error && err.name === "SearchCliError") {
559
- // ponytail: known user-facing errors already carry a friendly message; skip the report prompt
560
- console.error(err.userMessage ?? err.message);
561
- } else {
562
- console.error(err instanceof Error ? err.message : String(err));
563
- reportError(err);
564
- }
565
- process.exit(1);
566
- });
1
+ // Prevent tui.ts auto-launch when imported from cli.ts
2
+ process.env.GHFIND_CLI_RUN = "1";
3
+ import {
4
+ parseQuery,
5
+ applyFlagFilters,
6
+ rankRepos,
7
+ createGitHubSearch,
8
+ createTrendingSearch,
9
+ } from "./search";
10
+ import {
11
+ createPackageSearch,
12
+ formatPackagesJson,
13
+ formatPackagesCsv,
14
+ formatPackagesMarkdown,
15
+ formatPackagesUrls,
16
+ formatPackagesNames,
17
+ sortPackages,
18
+ type PackageSortMode,
19
+ } from "./package";
20
+ import { launchPackageBrowser } from "./package-browser";
21
+ import { runDoctor } from "./doctor";
22
+ import { reportError } from "./error-report";
23
+ import {
24
+ format as formatOutput,
25
+ exportToFile,
26
+ pipeExec,
27
+ type Format,
28
+ type ExportFormat,
29
+ type FormatLine,
30
+ } from "./output";
31
+ import { runWatch } from "./watch";
32
+ import {
33
+ fetchOrgProfile,
34
+ formatOrgJson,
35
+ formatOrgCsv,
36
+ formatOrgMarkdown,
37
+ formatOrgText,
38
+ } from "./org";
39
+ import { checkAndNotify, allCachedReleases } from "./releases";
40
+ import { runInitWizard } from "./init";
41
+ import { runLoginWizard } from "./login";
42
+ import { buildComparisonTable } from "./compare";
43
+ import type { Repo } from "./types";
44
+ import { readFileSync } from "fs";
45
+ import { join, dirname } from "path";
46
+ import { fileURLToPath } from "url";
47
+ import { launchBrowser } from "./tui";
48
+ import { getVersion } from "./version";
49
+ import type { SearchOptions } from "./types";
50
+ import { SearchCliError } from "./errors";
51
+
52
+ const __dirname = dirname(fileURLToPath(import.meta.url));
53
+ const PKG_DIR = join(__dirname, "..");
54
+
55
+ interface CLIFlags {
56
+ query: string;
57
+ json: boolean;
58
+ csv: boolean;
59
+ markdown: boolean;
60
+ count: boolean;
61
+ limit: number;
62
+ sort: SearchOptions["sort"];
63
+ token?: string;
64
+ trending: boolean;
65
+ since: string;
66
+ pipe?: string;
67
+ format?: string;
68
+ watch: boolean;
69
+ releases: boolean;
70
+ interval: number;
71
+ completion?: string;
72
+ init: boolean;
73
+ login: boolean;
74
+ version: boolean;
75
+ help: boolean;
76
+ pkg: boolean;
77
+ theme?: string;
78
+ registry: string;
79
+ compare: string[]; // repo fullNames to compare
80
+ doctor: boolean;
81
+ org?: string; // org profile lookup (ghfind org <name>)
82
+ }
83
+
84
+ function parseArgs(args: string[]): CLIFlags {
85
+ const flags: CLIFlags = {
86
+ query: "",
87
+ json: false,
88
+ csv: false,
89
+ markdown: false,
90
+ count: false,
91
+ limit: 50,
92
+ sort: "best-match",
93
+ token: process.env.GITHUB_TOKEN,
94
+ trending: false,
95
+ since: "daily",
96
+ watch: false,
97
+ releases: false,
98
+ interval: 300,
99
+ init: false,
100
+ login: false,
101
+ version: false,
102
+ help: false,
103
+ pkg: false,
104
+ registry: "npm",
105
+ compare: [],
106
+ doctor: false,
107
+ };
108
+
109
+ let queryParts: string[] = [];
110
+ for (let i = 0; i < args.length; i++) {
111
+ const arg = args[i];
112
+ switch (arg) {
113
+ case "--json":
114
+ flags.json = true;
115
+ break;
116
+ case "--csv":
117
+ flags.csv = true;
118
+ break;
119
+ case "--markdown":
120
+ flags.markdown = true;
121
+ break;
122
+ case "--count":
123
+ flags.count = true;
124
+ break;
125
+ case "--trending":
126
+ flags.trending = true;
127
+ break;
128
+ case "--watch":
129
+ flags.watch = true;
130
+ break;
131
+ case "--releases":
132
+ flags.releases = true;
133
+ break;
134
+ case "--version":
135
+ case "-v":
136
+ flags.version = true;
137
+ break;
138
+ case "--doctor":
139
+ case "doctor":
140
+ flags.doctor = true;
141
+ break;
142
+ case "--help":
143
+ case "-h":
144
+ flags.help = true;
145
+ break;
146
+ case "init":
147
+ flags.init = true;
148
+ break;
149
+ case "login":
150
+ flags.login = true;
151
+ break;
152
+ case "--limit": {
153
+ const parsed = parseInt(args[++i]);
154
+ if (isNaN(parsed) || parsed < 1 || parsed > 100) {
155
+ console.error(`Invalid limit: must be 1-100. Using default 50.`);
156
+ flags.limit = 50;
157
+ } else {
158
+ flags.limit = parsed;
159
+ }
160
+ break;
161
+ }
162
+ case "--sort":
163
+ flags.sort = args[++i] as SearchOptions["sort"];
164
+ break;
165
+ case "--token":
166
+ flags.token = args[++i];
167
+ break;
168
+ case "--since":
169
+ flags.since = args[++i];
170
+ break;
171
+ case "--pipe":
172
+ flags.pipe = args[++i];
173
+ break;
174
+ case "--format":
175
+ flags.format = args[++i];
176
+ break;
177
+ case "--registry":
178
+ flags.registry = args[++i] ?? "npm";
179
+ break;
180
+ case "--theme":
181
+ flags.theme = args[++i] ?? "tokyo-night";
182
+ break;
183
+ case "--completion":
184
+ flags.completion = args[++i];
185
+ break;
186
+ case "org": {
187
+ // Collect org name until next flag (allows multi-word quoted names)
188
+ const parts: string[] = [];
189
+ while (i + 1 < args.length && !args[i + 1].startsWith("-")) {
190
+ parts.push(args[++i]);
191
+ }
192
+ flags.org = parts.join("-");
193
+ break;
194
+ }
195
+ case "--compare": {
196
+ // Collect repo names until next flag
197
+ const repos: string[] = [];
198
+ while (i + 1 < args.length && !args[i + 1].startsWith("-")) {
199
+ repos.push(args[++i]);
200
+ }
201
+ flags.compare = repos;
202
+ break;
203
+ }
204
+ default:
205
+ if (!arg.startsWith("-")) queryParts.push(arg);
206
+ }
207
+ }
208
+ if (queryParts[0] === "pkg") {
209
+ flags.pkg = true;
210
+ queryParts.shift();
211
+ }
212
+ flags.query = queryParts.join(" ");
213
+ return flags;
214
+ }
215
+
216
+ async function main() {
217
+ const flags = parseArgs(process.argv.slice(2));
218
+
219
+ // Version
220
+ if (flags.version) {
221
+ console.log(`ghfind v${getVersion()}`);
222
+ return;
223
+ }
224
+
225
+ // Doctor — environment diagnostics
226
+ if (flags.doctor) {
227
+ await runDoctor();
228
+ return;
229
+ }
230
+
231
+ // Help
232
+ if (flags.help) {
233
+ console.log(`
234
+ ghfind — Interactive GitHub repository browser
235
+
236
+ Usage:
237
+ ghfind Launch interactive TUI
238
+ ghfind <query> --json Search, output JSON
239
+ ghfind <query> --csv Search, output CSV
240
+ ghfind <query> --markdown Search, output Markdown table
241
+ ghfind <query> --count Just the result count
242
+ ghfind <query> --format <fmt> Format lines (urls|names|ssh-urls|clone-commands|ids)
243
+ ghfind <query> --pipe <target> Pipe to clone/open
244
+ ghfind --trending --json Trending repos as JSON
245
+ ghfind --releases Check bookmarks for new releases
246
+ ghfind --compare <repo1> <repo2> Compare two+ repos side-by-side
247
+ ghfind org <name> --json Org profile: repos, stars, top languages
248
+ ghfind pkg <query> --json Search npm packages, output JSON
249
+ ghfind pkg <query> Search npm packages, text list
250
+ ghfind --watch <query> Watch mode (poll every Ns)
251
+ ghfind init Run setup wizard
252
+ ghfind login Import the gh CLI token or paste one
253
+ ghfind --completion <shell> Print completion script (bash|zsh|fish)
254
+ ghfind --version Print version
255
+ ghfind --doctor Run environment diagnostics (troubleshooting)
256
+ ghfind --help Print this help
257
+
258
+ Options:
259
+ --json, --csv, --markdown, --count Output format
260
+ --limit <n> Max results (default: 50)
261
+ --sort <strategy> Sort: best-match|stars|updated|forks
262
+ --theme <name> Theme: tokyo-night|premium-dark (default: tokyo-night)
263
+ --trending Trending mode
264
+ --since <period> Trending period: daily|weekly|monthly
265
+ --pipe <target> Pipe target: clone|open
266
+ --format <fmt> Line format: urls|names|ssh-urls|...
267
+ --registry <name> Registry for pkg: npm (default) | others TBD
268
+
269
+ --watch Watch mode (re-run periodically)
270
+ --releases New-release feed for bookmarks
271
+ --interval <s> Watch interval in seconds (default: 300)
272
+ --completion <shell> Generate completions
273
+ `);
274
+ return;
275
+ }
276
+
277
+ // Shell completions
278
+ if (flags.completion) {
279
+ const shell = flags.completion;
280
+ try {
281
+ const content = readFileSync(
282
+ join(PKG_DIR, "completions", `ghfind.${shell}`),
283
+ "utf-8",
284
+ );
285
+ console.log(content);
286
+ } catch {
287
+ console.error(`Completions not available for shell: ${shell}`);
288
+ process.exit(1);
289
+ }
290
+ return;
291
+ }
292
+
293
+ // Init wizard
294
+ if (flags.init) {
295
+ await runInitWizard();
296
+ return;
297
+ }
298
+
299
+ // Login — store a GitHub token for higher rate limits
300
+ if (flags.login) {
301
+ await runLoginWizard();
302
+ return;
303
+ }
304
+ // Package search (prototype, ticket 002-008)
305
+ if (flags.pkg) {
306
+ const interactive =
307
+ !flags.query &&
308
+ !flags.json &&
309
+ !flags.csv &&
310
+ !flags.markdown &&
311
+ !flags.count &&
312
+ !flags.format;
313
+ if (interactive) {
314
+ await launchPackageBrowser();
315
+ } else {
316
+ await runPackageSearch(flags);
317
+ }
318
+ return;
319
+ }
320
+
321
+ // Determine output format
322
+ const outputFormat: ExportFormat | undefined = flags.json
323
+ ? "json"
324
+ : flags.csv
325
+ ? "csv"
326
+ : flags.markdown
327
+ ? "markdown"
328
+ : undefined;
329
+
330
+ // Org profile mode — independent of repo search handlers
331
+ // (checks presence, not truthiness, so `ghfind org` with no name errors helpfully)
332
+ if (flags.org !== undefined) {
333
+ await runOrgProfile(flags, outputFormat);
334
+ return;
335
+ }
336
+
337
+ const isNonInteractive =
338
+ outputFormat ||
339
+ flags.count ||
340
+ flags.format ||
341
+ flags.pipe ||
342
+ flags.watch ||
343
+ flags.releases;
344
+
345
+ // Non-interactive mode
346
+ if (isNonInteractive) {
347
+ if (flags.releases) {
348
+ await runReleases(flags, outputFormat);
349
+ return;
350
+ }
351
+ await runNonInteractive(flags, outputFormat);
352
+ return;
353
+ }
354
+
355
+ await launchBrowser(flags.theme);
356
+ }
357
+
358
+ interface SearchContext {
359
+ query: string;
360
+ parsed: ReturnType<typeof parseQuery> & { raw: string };
361
+ provider: ReturnType<typeof createGitHubSearch>;
362
+ }
363
+
364
+ function buildSearchContext(flags: CLIFlags): SearchContext {
365
+ const parsed = applyFlagFilters(parseQuery(flags.query), {});
366
+ return {
367
+ query: flags.query,
368
+ parsed,
369
+ provider: createGitHubSearch(undefined, flags.token ? [flags.token] : []),
370
+ };
371
+ }
372
+
373
+ /** `ghfind org <name>` — aggregate org profile: metadata, stars, top languages, top repos. */
374
+ async function runOrgProfile(
375
+ flags: CLIFlags,
376
+ outputFormat?: ExportFormat,
377
+ ): Promise<void> {
378
+ try {
379
+ const profile = await fetchOrgProfile(flags.org!, {
380
+ token: flags.token,
381
+ limit: flags.limit,
382
+ });
383
+ if (outputFormat === "json") {
384
+ console.log(formatOrgJson(profile));
385
+ } else if (outputFormat === "csv") {
386
+ console.log(formatOrgCsv(profile));
387
+ } else if (outputFormat === "markdown") {
388
+ console.log(formatOrgMarkdown(profile));
389
+ } else if (flags.count) {
390
+ console.log(profile.publicRepoCount);
391
+ } else {
392
+ console.log(formatOrgText(profile));
393
+ }
394
+ } catch (err) {
395
+ console.error(err instanceof Error ? err.message : String(err));
396
+ process.exit(1);
397
+ }
398
+ }
399
+
400
+ /** `ghfind pkg "<query>"` — npm package search. Pass-through query to the registry. */
401
+ async function runPackageSearch(flags: CLIFlags): Promise<void> {
402
+ if (flags.registry !== "npm") {
403
+ console.error(`Registry "${flags.registry}" not yet supported (only npm).`);
404
+ process.exit(1);
405
+ }
406
+ const PKG_SORTS: PackageSortMode[] = [
407
+ "best-match",
408
+ "score",
409
+ "downloads",
410
+ "name",
411
+ ];
412
+ const pkgSort = flags.sort as PackageSortMode;
413
+ if (!PKG_SORTS.includes(pkgSort)) {
414
+ console.error(
415
+ `Invalid sort for pkg: "${flags.sort}" (use best-match|score|downloads|name).`,
416
+ );
417
+ process.exit(1);
418
+ }
419
+ const search = createPackageSearch();
420
+ const query = flags.query.trim();
421
+ if (!query) {
422
+ console.error(
423
+ "Usage: ghfind pkg <query> [--json|--csv|--markdown|--count|--format urls|names] [--limit <n>]",
424
+ );
425
+ process.exit(1);
426
+ }
427
+ try {
428
+ const { totalCount, packages } = await search.searchPackage(
429
+ query,
430
+ flags.limit,
431
+ );
432
+ const sorted = sortPackages(packages, pkgSort);
433
+ if (flags.count) {
434
+ console.log(totalCount);
435
+ } else if (flags.json) {
436
+ console.log(formatPackagesJson(sorted));
437
+ } else if (flags.csv) {
438
+ console.log(formatPackagesCsv(sorted));
439
+ } else if (flags.markdown) {
440
+ console.log(formatPackagesMarkdown(sorted));
441
+ } else if (flags.format === "urls") {
442
+ console.log(formatPackagesUrls(sorted));
443
+ } else if (flags.format === "names") {
444
+ console.log(formatPackagesNames(sorted));
445
+ } else {
446
+ for (const p of sorted) {
447
+ console.log(
448
+ `${p.name}@${p.version} ↓ ${p.downloads.toLocaleString()} ${p.score.toFixed(2)} ${p.description ?? ""}`,
449
+ );
450
+ }
451
+ }
452
+ } catch (err) {
453
+ console.error(err instanceof Error ? err.message : String(err));
454
+ process.exit(1);
455
+ }
456
+ }
457
+
458
+ /** `ghfind --releases` — check bookmarks for new releases; print the cached feed, then mark all seen. */
459
+ async function runReleases(
460
+ flags: CLIFlags,
461
+ outputFormat?: ExportFormat,
462
+ ): Promise<void> {
463
+ const results = await checkAndNotify({ token: flags.token });
464
+ const releases = allCachedReleases();
465
+ if (outputFormat === "json") {
466
+ console.log(
467
+ JSON.stringify(
468
+ releases.map((r) => ({
469
+ fullName: r.fullName,
470
+ tag: r.tagName,
471
+ name: r.name,
472
+ url: r.url,
473
+ publishedAt: r.publishedAt,
474
+ prerelease: r.prerelease,
475
+ })),
476
+ null,
477
+ 2,
478
+ ),
479
+ );
480
+ return;
481
+ }
482
+ const unseen = results.reduce((n, r) => n + r.newReleases.length, 0);
483
+ const errors = results.filter((r) => r.error).length;
484
+ console.log(
485
+ `Checked ${results.length} bookmarked repos — ${unseen} new release${unseen === 1 ? "" : "s"}${errors ? `, ${errors} error${errors === 1 ? "" : "s"}` : ""}.`,
486
+ );
487
+ for (const r of releases) {
488
+ console.log(
489
+ `${r.fullName} ${r.prerelease ? "[pre] " : ""}${r.tagName} ${r.publishedAt.slice(0, 10)}\n ${r.url}`,
490
+ );
491
+ }
492
+ }
493
+
494
+ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
495
+ // Dispatch table: each handler receives shared context + flags, returns void.
496
+ const handlers: Record<string, (ctx: SearchContext) => Promise<void>> = {
497
+ trending: async (ctx) => {
498
+ const since =
499
+ flags.since === "weekly"
500
+ ? "weekly"
501
+ : flags.since === "monthly"
502
+ ? "monthly"
503
+ : "daily";
504
+ const trendingSearch = createTrendingSearch();
505
+ const response = await trendingSearch.search(
506
+ { keywords: [], qualifiers: [], raw: "trending" },
507
+ {
508
+ limit: 25,
509
+ sort: "stars",
510
+ json: false,
511
+ verbose: false,
512
+ trendingSince: since,
513
+ },
514
+ );
515
+ if (outputFormat) {
516
+ console.log(formatOutput(response.repos, outputFormat));
517
+ } else if (flags.count) {
518
+ console.log(response.totalCount);
519
+ } else {
520
+ for (const r of response.repos) {
521
+ console.log(
522
+ r.fullName +
523
+ " ★ " +
524
+ r.stars +
525
+ " ▲ +" +
526
+ r.score +
527
+ " " +
528
+ since +
529
+ " ● " +
530
+ (r.language ?? ""),
531
+ );
532
+ }
533
+ }
534
+ },
535
+ watch: async (ctx) => {
536
+ let tick = 0;
537
+ await runWatch(
538
+ {
539
+ query: ctx.query,
540
+ sort: flags.sort,
541
+ limit: flags.limit,
542
+ token: flags.token,
543
+ intervalMs: flags.interval * 1000,
544
+ trending: flags.trending,
545
+ },
546
+ (repos, delta) => {
547
+ tick++;
548
+ if (outputFormat) {
549
+ console.log(
550
+ `[Watch #${tick}] ${repos.length} results (${delta >= 0 ? "+" : ""}${delta} since last check)`,
551
+ );
552
+ console.log(formatOutput(repos, outputFormat));
553
+ } else {
554
+ console.log(
555
+ `[Watch #${tick}] ${repos.length} results — ${delta >= 0 ? "+" : ""}${delta} since last check`,
556
+ );
557
+ }
558
+ },
559
+ (err) => console.error(`[Watch error] ${err.message}`),
560
+ );
561
+ },
562
+ pipe: async (ctx) => {
563
+ const response = await ctx.provider.search(ctx.parsed, {
564
+ limit: flags.limit,
565
+ sort: flags.sort,
566
+ json: false,
567
+ verbose: false,
568
+ token: flags.token,
569
+ });
570
+ const repos = rankRepos(response.repos, flags.sort);
571
+ await pipeExec(repos, flags.pipe!);
572
+ },
573
+ format: async (ctx) => {
574
+ const response = await ctx.provider.search(ctx.parsed, {
575
+ limit: flags.limit,
576
+ sort: flags.sort,
577
+ json: false,
578
+ verbose: false,
579
+ token: flags.token,
580
+ });
581
+ const repos = rankRepos(response.repos, flags.sort);
582
+ console.log(formatOutput(repos, flags.format as Format));
583
+ },
584
+ search: async (ctx) => {
585
+ const response = await ctx.provider.search(ctx.parsed, {
586
+ limit: flags.limit,
587
+ sort: flags.sort,
588
+ json: false,
589
+ verbose: false,
590
+ token: flags.token,
591
+ });
592
+ const repos = rankRepos(response.repos, flags.sort);
593
+ if (flags.count) {
594
+ console.log(response.totalCount);
595
+ return;
596
+ }
597
+ if (outputFormat) {
598
+ console.log(formatOutput(repos, outputFormat));
599
+ }
600
+ },
601
+ compare: async (_ctx) => {
602
+ const search = createGitHubSearch(undefined, [flags.token ?? ""]);
603
+ const repos: Repo[] = [];
604
+ for (const fullName of flags.compare) {
605
+ const res = await search.search(
606
+ { keywords: [fullName], qualifiers: [], raw: fullName },
607
+ {
608
+ limit: 1,
609
+ sort: "stars",
610
+ json: false,
611
+ verbose: false,
612
+ token: flags.token,
613
+ },
614
+ );
615
+ repos.push(...res.repos);
616
+ }
617
+ console.log(buildComparisonTable(repos));
618
+ },
619
+ };
620
+
621
+ // Determine which handler to run
622
+ const key =
623
+ flags.compare.length > 0
624
+ ? "compare"
625
+ : flags.trending && !flags.watch
626
+ ? "trending"
627
+ : flags.watch
628
+ ? "watch"
629
+ : flags.pipe
630
+ ? "pipe"
631
+ : flags.format
632
+ ? "format"
633
+ : "search";
634
+
635
+ const ctx = buildSearchContext(flags);
636
+ await handlers[key](ctx);
637
+ }
638
+
639
+ main().catch((err) => {
640
+ if (err instanceof Error && err.name === "SearchCliError") {
641
+ // ponytail: known user-facing errors already carry a friendly message; skip the report prompt
642
+ console.error(
643
+ err instanceof SearchCliError ? err.userMessage : err.message,
644
+ );
645
+ } else {
646
+ console.error(err instanceof Error ? err.message : String(err));
647
+ reportError(err);
648
+ }
649
+ process.exit(1);
650
+ });