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