github-search-cli 9.6.0 → 9.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-search-cli",
3
- "version": "9.6.0",
3
+ "version": "9.7.1",
4
4
  "description": "Search GitHub from your terminal — repo search, trending repos, npm package search, bookmarks, deep-dive, compare, export. Keyboard-driven TUI + pipeable CLI.",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1",
@@ -37,13 +37,14 @@
37
37
  "format:check": "prettier --check .",
38
38
  "format": "prettier --write .",
39
39
  "typecheck": "tsc --noEmit",
40
- "lint": "bunx biome@2.5.13 check .",
40
+ "lint": "biome check .",
41
41
  "check": "prettier --check . && bun test"
42
42
  },
43
43
  "dependencies": {
44
44
  "@opentui/core": "^0.4.3"
45
45
  },
46
46
  "devDependencies": {
47
+ "@biomejs/biome": "^2.5.14",
47
48
  "@types/bun": "latest",
48
49
  "@types/node": "^20.0.0",
49
50
  "prettier": "^3.2.0",
@@ -0,0 +1,53 @@
1
+ # What's New in ghfind 9.7.0
2
+
3
+ CLI parity with the MCP tools — everything `ghfind mcp` exposes to coding
4
+ agents is now a first-class terminal command — plus two fixes: `ghfind mcp`
5
+ compiles again, and deep-dive shows real repo metadata.
6
+
7
+ ### Added
8
+
9
+ **`ghfind deep-dive <owner/repo>`** — the MCP-only deep dive, on the CLI
10
+
11
+ - Language breakdown with percentages, top contributors, and a README
12
+ excerpt (main → master → README.rst fallback)
13
+ - Previously the agent skill doc literally told agents to `curl` GitHub
14
+ instead — not anymore
15
+ - `--json` exposes a structured payload (language percents, contributor
16
+ counts, raw README) suitable for agents and scripting
17
+ - Resolves real repo metadata first: `ghfind deep-dive vercel/next.js
18
+ --json` reports actual stars, description, topics, and timestamps
19
+ - A 404 exits with `Repo not found: owner/name` instead of silently
20
+ printing empty sections; network failures still degrade gracefully
21
+
22
+ **`ghfind --compare` export formats** — `--json`, `--csv`, `--markdown`
23
+
24
+ - Compare was text-only ("no JSON formatter for it yet"); all three
25
+ formatters now exist, with columns matching the other export modes
26
+ - Repos that fail to resolve are reported in text output and excluded from
27
+ the table; fewer than 2 resolvable repos exits non-zero
28
+
29
+ **Trending language filter on the CLI**
30
+
31
+ - `ghfind trending rust` — the form the README already advertised but the
32
+ parser never handled — and `ghfind --trending rust` both work now
33
+ - `ghfind --trending --language <lang>` names the flag explicitly
34
+ - Unknown filters are rejected with the accepted list, the same validation
35
+ the `ghfind_trending` MCP tool applies
36
+
37
+ ### Changed
38
+
39
+ - The MCP server now shares its repo-ref parsing, trending-language
40
+ validation, and deep-dive data pipeline with the CLI (new seams in
41
+ `deepdive.ts` and `search.ts`) — one source of truth, no protocol
42
+ behavior change
43
+
44
+ ### Fixed
45
+
46
+ - **`ghfind mcp` failed to typecheck** — the id handed to
47
+ `respond`/`respondError` stayed `JsonRpcId | undefined` because
48
+ TypeScript's aliased-conditional narrowing does not apply through a `let`
49
+ binding; the id is now a `const`. Runtime behavior was already correct —
50
+ all 31 MCP tests pass unchanged.
51
+ - **Deep-dive ran on a zeroed repo stub** — stars, forks, description, and
52
+ topics were all empty in both the CLI and the `ghfind_deep_dive` MCP
53
+ tool; real metadata is now fetched first (see above).
@@ -0,0 +1,62 @@
1
+ # What's New in ghfind 9.7.1
2
+
3
+ The in-app update finally works — plus a Windows URL fix and a new doctor
4
+ check that names every stale install shadowing your updates.
5
+
6
+ ### Fixed
7
+
8
+ **In-app update installed to the wrong location**
9
+
10
+ - When the TUI runs under Bun, `performUpdate()` ran `bun install -g
11
+ github-search-cli`, which targets Bun's own global dir (`~/.bun`) — not
12
+ the npm prefix where the running copy and its `ghfind` shim live. The
13
+ command exited 0, "update succeeded", and the running install never
14
+ changed.
15
+ - The updater now detects which package manager owns the running tree
16
+ (`detectInstallLocation`: npm global prefix vs `~/.bun` global) and
17
+ installs with `npm install -g --prefix <prefix>` for npm-owned installs,
18
+ so the update lands exactly where the running install is.
19
+ - Compiled binaries and dev checkouts (no `node_modules` ancestor) refuse
20
+ self-update honestly instead of npm-installing a copy you aren't running.
21
+
22
+ **Node fallback couldn't launch npm on Windows**
23
+
24
+ - `child_process.spawn` without a shell cannot execute `npm.cmd` — recent
25
+ Node rejects it outright with `EINVAL`. The update spawn now goes through
26
+ `node:child_process` on every runtime with `shell: true` on win32, so the
27
+ Node fallback path works on Windows too.
28
+
29
+ **Windows: URLs truncated at the first `&`**
30
+
31
+ - Opening URLs via `cmd /c start "" <url>` split the URL at the first
32
+ unquoted `&` — a query-string separator — and tried to execute the rest
33
+ as a command. Every pre-filled crash-report/issue link lost its body and
34
+ labels.
35
+ - Both URL openers now use `explorer.exe` on Windows, which receives the
36
+ URL as a single argument, so `&` is never interpreted.
37
+
38
+ **New `ghfind --doctor` check: stale/PATH-shadowing installs**
39
+
40
+ - Multiple `ghfind` copies on PATH resolving to different versions are the
41
+ classic "updater said OK but nothing changed" trap: the update lands in
42
+ one prefix while the shell keeps launching another. `ghfind --doctor` now
43
+ lists every install with its version and prints the exact
44
+ `npm uninstall -g github-search-cli --prefix <dir>` command to remove the
45
+ stale ones.
46
+
47
+ ### Changed
48
+
49
+ - **CI enforces lint + typecheck** — the repo's first green Biome baseline
50
+ (198 diagnostics triaged, real `@biomejs/biome` 2.5.14 dependency replacing
51
+ the phantom `biome@2.5.13` pin) and a `.gitattributes` forcing LF so
52
+ `prettier --check` passes on Windows runners (main had been red on
53
+ windows-latest for weeks). Previously both gates were excluded as
54
+ known-broken; the typecheck-opentui-drift ticket is closed.
55
+
56
+ ### Internal
57
+
58
+ - `open-url.ts` is runtime-agnostic with a detached Node fallback (it
59
+ previously crashed under Node via unconditional `Bun.spawn`).
60
+ - Housekeeping: `mcp-server.ts` assignment-in-expressions, `trending-parser.ts`
61
+ stateful regex loop → `matchAll`, typed casts replacing `as any`, and a
62
+ repaired unsafe-autofix regression in `TrendingAdapter`.
package/scripts/build.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Build script — compile ghfind for distribution.
3
3
  * Usage: npm run build
4
4
  */
5
- import { execFileSync } from "child_process";
5
+ import { execFileSync } from "node:child_process";
6
6
 
7
7
  async function main() {
8
8
  console.log("Building ghfind...");
@@ -13,12 +13,12 @@ import {
13
13
  chmodSync,
14
14
  writeFileSync,
15
15
  readFileSync,
16
- } from "fs";
17
- import { join, dirname } from "path";
18
- import { fileURLToPath } from "url";
19
- import { platform, arch } from "os";
20
- import { spawnSync } from "child_process";
21
- import { inflateRawSync } from "zlib";
16
+ } from "node:fs";
17
+ import { join, dirname } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { platform, arch } from "node:os";
20
+ import { spawnSync } from "node:child_process";
21
+ import { inflateRawSync } from "node:zlib";
22
22
 
23
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
24
24
  const ROOT = join(__dirname, "..");
@@ -121,8 +121,8 @@ try {
121
121
  let cdOffset = buffer.indexOf(cdSig, 0);
122
122
  while (cdOffset !== -1) {
123
123
  const nameLen = buffer.readUInt16LE(cdOffset + 28);
124
- const extraLen = buffer.readUInt16LE(cdOffset + 30);
125
- const commentLen = buffer.readUInt16LE(cdOffset + 32);
124
+ const _extraLen = buffer.readUInt16LE(cdOffset + 30);
125
+ const _commentLen = buffer.readUInt16LE(cdOffset + 32);
126
126
  const localHeaderOffset = buffer.readUInt32LE(cdOffset + 42);
127
127
  const compSize = buffer.readUInt32LE(cdOffset + 20);
128
128
  const compMethod = buffer.readUInt16LE(cdOffset + 10);
@@ -54,7 +54,8 @@ Formats: --json | --csv | --markdown | --count | --format urls|names|ssh-urls|cl
54
54
 
55
55
  ## Trending repositories
56
56
 
57
- ghfind --trending --json [--since daily|weekly|monthly]
57
+ ghfind --trending --json [--since daily|weekly|monthly] [--language rust]
58
+ ghfind trending rust --json # subcommand form of the same thing
58
59
 
59
60
  ## npm packages
60
61
 
@@ -67,14 +68,14 @@ Formats: --json | --csv | --markdown | --count | --format urls|names|ssh-urls|cl
67
68
 
68
69
  ## Compare repositories
69
70
 
70
- ghfind --compare owner/a owner/b [--json]
71
- Compare is text-only; there is no JSON formatter for it yet.
71
+ ghfind --compare owner/a owner/b [--json|--csv|--markdown]
72
+ Errors if fewer than 2 of the requested repos resolve; unresolved names are
73
+ reported in text output and via the error message.
72
74
 
73
75
  ## Deep-dive a repo (languages, contributors, README)
74
76
 
75
- Not exposed on the CLI. Fetch directly instead:
76
- curl -s https://api.github.com/repos/OWNER/REPO/languages
77
- curl -s https://raw.githubusercontent.com/OWNER/REPO/main/README.md
77
+ ghfind deep-dive owner/repo --json
78
+ Text output (no flag) prints the same sections formatted for the terminal.
78
79
 
79
80
  ## Rate limits
80
81
 
package/src/cli.ts CHANGED
@@ -22,11 +22,9 @@ import { runDoctor } from "./doctor";
22
22
  import { reportError } from "./error-report";
23
23
  import {
24
24
  format as formatOutput,
25
- exportToFile,
26
25
  pipeExec,
27
26
  type Format,
28
27
  type ExportFormat,
29
- type FormatLine,
30
28
  } from "./output";
31
29
  import { runWatch } from "./watch";
32
30
  import {
@@ -46,11 +44,24 @@ import {
46
44
  import { checkAndNotify, allCachedReleases } from "./releases";
47
45
  import { runInitWizard } from "./init";
48
46
  import { runLoginWizard } from "./login";
49
- import { buildComparisonTable } from "./compare";
47
+ import {
48
+ buildComparisonTable,
49
+ comparisonJson,
50
+ comparisonCsv,
51
+ comparisonMarkdown,
52
+ } from "./compare";
53
+ import {
54
+ fetchDeepDiveRaw,
55
+ buildDeepDiveData,
56
+ buildDeepDiveJson,
57
+ buildDeepDiveText,
58
+ resolveRepoFromRef,
59
+ } from "./deepdive";
60
+ import { resolveTrendingLanguage } from "./search";
50
61
  import type { Repo } from "./types";
51
- import { readFileSync } from "fs";
52
- import { join, dirname } from "path";
53
- import { fileURLToPath } from "url";
62
+ import { readFileSync } from "node:fs";
63
+ import { join, dirname } from "node:path";
64
+ import { fileURLToPath } from "node:url";
54
65
  import { launchBrowser } from "./tui";
55
66
  import { getVersion } from "./version";
56
67
  import { runMcpServer } from "./mcp-server";
@@ -96,6 +107,8 @@ interface CLIFlags {
96
107
  user?: string; // user profile lookup (ghfind user <name>)
97
108
  mcp: boolean; // run the MCP server (ghfind mcp)
98
109
  skill?: string; // agent skill guide (ghfind skill [name])
110
+ deepDive?: string; // repo deep-dive (ghfind deep-dive <owner/repo>)
111
+ language?: string; // trending language filter (--trending rust / ghfind trending rust)
99
112
  }
100
113
 
101
114
  function parseArgs(args: string[]): CLIFlags {
@@ -124,7 +137,7 @@ function parseArgs(args: string[]): CLIFlags {
124
137
  mcp: false,
125
138
  };
126
139
 
127
- let queryParts: string[] = [];
140
+ const queryParts: string[] = [];
128
141
  for (let i = 0; i < args.length; i++) {
129
142
  const arg = args[i];
130
143
  switch (arg) {
@@ -142,6 +155,23 @@ function parseArgs(args: string[]): CLIFlags {
142
155
  break;
143
156
  case "--trending":
144
157
  flags.trending = true;
158
+ // `ghfind --trending rust` — first bare word is the language filter
159
+ break;
160
+ case "trending": {
161
+ // `ghfind trending rust` — subcommand form of --trending (README has
162
+ // claimed this works; now it does).
163
+ flags.trending = true;
164
+ if (
165
+ flags.query === "" &&
166
+ i + 1 < args.length &&
167
+ !args[i + 1].startsWith("-")
168
+ ) {
169
+ flags.language = args[++i];
170
+ }
171
+ break;
172
+ }
173
+ case "--language":
174
+ flags.language = args[++i];
145
175
  break;
146
176
  case "--watch":
147
177
  flags.watch = true;
@@ -168,8 +198,8 @@ function parseArgs(args: string[]): CLIFlags {
168
198
  flags.login = true;
169
199
  break;
170
200
  case "--limit": {
171
- const parsed = parseInt(args[++i]);
172
- if (isNaN(parsed) || parsed < 1 || parsed > 100) {
201
+ const parsed = parseInt(args[++i], 10);
202
+ if (Number.isNaN(parsed) || parsed < 1 || parsed > 100) {
173
203
  console.error(`Invalid limit: must be 1-100. Using default 50.`);
174
204
  flags.limit = 50;
175
205
  } else {
@@ -204,6 +234,15 @@ function parseArgs(args: string[]): CLIFlags {
204
234
  case "mcp":
205
235
  flags.mcp = true;
206
236
  break;
237
+ case "deep-dive": {
238
+ // Single repo ref (owner/name); a following flag means none.
239
+ const next = args[i + 1];
240
+ if (next && !next.startsWith("-")) {
241
+ flags.deepDive = next;
242
+ i++;
243
+ }
244
+ break;
245
+ }
207
246
  case "skill": {
208
247
  // Optional skill id (ids contain no spaces); a following flag means none.
209
248
  const next = args[i + 1];
@@ -284,8 +323,12 @@ Usage:
284
323
  ghfind <query> --format <fmt> Format lines (urls|names|ssh-urls|clone-commands|ids)
285
324
  ghfind <query> --pipe <target> Pipe to clone/open
286
325
  ghfind --trending --json Trending repos as JSON
326
+ ghfind trending <lang> Trending repos, optionally by language
287
327
  ghfind --releases Check bookmarks for new releases
288
328
  ghfind --compare <r1> <r2> Compare two+ repos side-by-side
329
+ ghfind --compare a/b c/d --json Compare as JSON|CSV|Markdown
330
+ ghfind deep-dive <owner/repo> Deep-dive: languages, contributors, README
331
+ ghfind deep-dive a/b --json Deep-dive as JSON
289
332
  ghfind org <name> --json Org profile: repos, stars, top languages
290
333
  ghfind user <name> --json User profile: repos, stars, top languages
291
334
  ghfind pkg <query> --json Search npm packages, output JSON
@@ -306,6 +349,7 @@ Options:
306
349
  --sort <strategy> Sort: best-match|stars|updated|forks
307
350
  --theme <name> Theme: tokyo-night|premium-dark (default: tokyo-night)
308
351
  --trending Trending mode
352
+ --language <lang> Trending language filter (e.g. rust)
309
353
  --since <period> Trending period: daily|weekly|monthly
310
354
  --pipe <target> Pipe target: clone|open
311
355
  --format <fmt> Line format: urls|names|ssh-urls|...
@@ -384,6 +428,12 @@ Options:
384
428
  ? "markdown"
385
429
  : undefined;
386
430
 
431
+ // Repo deep-dive — parity with the MCP ghfind_deep_dive tool
432
+ if (flags.deepDive !== undefined) {
433
+ await runDeepDive(flags, outputFormat);
434
+ return;
435
+ }
436
+
387
437
  // Org profile mode — independent of repo search handlers
388
438
  // (checks presence, not truthiness, so `ghfind org` with no name errors helpfully)
389
439
  if (flags.org !== undefined) {
@@ -403,7 +453,10 @@ Options:
403
453
  flags.format ||
404
454
  flags.pipe ||
405
455
  flags.watch ||
406
- flags.releases;
456
+ flags.releases ||
457
+ // `ghfind trending rust` / `ghfind --trending rust` print a list —
458
+ // a language filter implies non-interactive output
459
+ (flags.trending && (!!flags.language || !!flags.query));
407
460
 
408
461
  // Non-interactive mode
409
462
  if (isNonInteractive) {
@@ -458,7 +511,12 @@ async function runUserProfile(
458
511
  outputFormat?: ExportFormat,
459
512
  ): Promise<void> {
460
513
  try {
461
- const profile = await fetchUserProfile(flags.user!, {
514
+ const user = flags.user ?? "";
515
+ if (!user) {
516
+ console.error("Usage: ghfind user <name> [--json] [--token <t>]");
517
+ process.exit(1);
518
+ }
519
+ const profile = await fetchUserProfile(user, {
462
520
  token: flags.token,
463
521
  limit: flags.limit,
464
522
  });
@@ -485,7 +543,12 @@ async function runOrgProfile(
485
543
  outputFormat?: ExportFormat,
486
544
  ): Promise<void> {
487
545
  try {
488
- const profile = await fetchOrgProfile(flags.org!, {
546
+ const org = flags.org ?? "";
547
+ if (!org) {
548
+ console.error("Usage: ghfind org <name> [--json] [--token <t>]");
549
+ process.exit(1);
550
+ }
551
+ const profile = await fetchOrgProfile(org, {
489
552
  token: flags.token,
490
553
  limit: flags.limit,
491
554
  });
@@ -506,6 +569,32 @@ async function runOrgProfile(
506
569
  }
507
570
  }
508
571
 
572
+ /** `ghfind deep-dive <owner/repo>` — rich repo detail: languages, contributors, README. */
573
+ async function runDeepDive(
574
+ flags: CLIFlags,
575
+ outputFormat?: ExportFormat,
576
+ ): Promise<void> {
577
+ const ref = flags.deepDive;
578
+ if (!ref) {
579
+ console.error(
580
+ "Usage: ghfind deep-dive <owner/repo> [--json] [--token <t>]",
581
+ );
582
+ process.exit(1);
583
+ }
584
+ try {
585
+ const repo = await resolveRepoFromRef(ref, flags.token); // validates + real metadata
586
+ const raw = await fetchDeepDiveRaw(repo, flags.token);
587
+ if (outputFormat === "json") {
588
+ console.log(buildDeepDiveJson(raw));
589
+ } else {
590
+ console.log(buildDeepDiveText(buildDeepDiveData(repo, raw)));
591
+ }
592
+ } catch (err) {
593
+ console.error(err instanceof Error ? err.message : String(err));
594
+ process.exit(1);
595
+ }
596
+ }
597
+
509
598
  /** `ghfind pkg "<query>"` — npm package search. Pass-through query to the registry. */
510
599
  async function runPackageSearch(flags: CLIFlags): Promise<void> {
511
600
  if (flags.registry !== "npm") {
@@ -603,7 +692,7 @@ async function runReleases(
603
692
  async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
604
693
  // Dispatch table: each handler receives shared context + flags, returns void.
605
694
  const handlers: Record<string, (ctx: SearchContext) => Promise<void>> = {
606
- trending: async (ctx) => {
695
+ trending: async (_ctx) => {
607
696
  const since =
608
697
  flags.since === "weekly"
609
698
  ? "weekly"
@@ -611,6 +700,20 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
611
700
  ? "monthly"
612
701
  : "daily";
613
702
  const trendingSearch = createTrendingSearch();
703
+ // Language filter: validated against github.com/trending's accepted
704
+ // set (same validation as the MCP ghfind_trending tool).
705
+ let language: string | undefined;
706
+ try {
707
+ // `ghfind trending rust` puts the language in flags.language;
708
+ // `ghfind --trending rust` leaves it as the query's first word.
709
+ language = resolveTrendingLanguage(
710
+ flags.language ??
711
+ (flags.query ? flags.query.split(/\s+/)[0] : undefined),
712
+ );
713
+ } catch (err) {
714
+ console.error(err instanceof Error ? err.message : String(err));
715
+ process.exit(1);
716
+ }
614
717
  const response = await trendingSearch.search(
615
718
  { keywords: [], qualifiers: [], raw: "trending" },
616
719
  {
@@ -619,6 +722,7 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
619
722
  json: false,
620
723
  verbose: false,
621
724
  trendingSince: since,
725
+ ...(language ? { trendingLanguage: language } : {}),
622
726
  },
623
727
  );
624
728
  if (outputFormat) {
@@ -677,7 +781,7 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
677
781
  token: flags.token,
678
782
  });
679
783
  const repos = rankRepos(response.repos, flags.sort);
680
- await pipeExec(repos, flags.pipe!);
784
+ if (flags.pipe) await pipeExec(repos, flags.pipe);
681
785
  },
682
786
  format: async (ctx) => {
683
787
  const response = await ctx.provider.search(ctx.parsed, {
@@ -710,6 +814,7 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
710
814
  compare: async (_ctx) => {
711
815
  const search = createGitHubSearch(undefined, [flags.token ?? ""]);
712
816
  const repos: Repo[] = [];
817
+ const missing: string[] = [];
713
818
  for (const fullName of flags.compare) {
714
819
  const res = await search.search(
715
820
  { keywords: [fullName], qualifiers: [], raw: fullName },
@@ -721,9 +826,27 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
721
826
  token: flags.token,
722
827
  },
723
828
  );
724
- repos.push(...res.repos);
829
+ if (res.repos.length > 0) repos.push(...res.repos);
830
+ else missing.push(fullName);
831
+ }
832
+ if (repos.length < 2) {
833
+ console.error(
834
+ `Could not resolve enough repos to compare (found ${repos.length}${missing.length ? `; not found: ${missing.join(", ")}` : ""}).`,
835
+ );
836
+ process.exit(1);
837
+ }
838
+ const notFound = missing.length
839
+ ? `\n\nNot found: ${missing.join(", ")}`
840
+ : "";
841
+ if (outputFormat === "json") {
842
+ console.log(comparisonJson(repos));
843
+ } else if (outputFormat === "csv") {
844
+ console.log(comparisonCsv(repos));
845
+ } else if (outputFormat === "markdown") {
846
+ console.log(comparisonMarkdown(repos));
847
+ } else {
848
+ console.log(buildComparisonTable(repos) + notFound);
725
849
  }
726
- console.log(buildComparisonTable(repos));
727
850
  },
728
851
  };
729
852
 
package/src/compare.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Repo comparison — side-by-side table for 2+ repos.
2
+ * Repo comparison — side-by-side table for 2+ repos, plus JSON/CSV/Markdown
3
+ * exports (shared by the CLI `--compare` formatters and the MCP server).
3
4
  */
4
5
  import type { Repo } from "./types";
5
6
 
@@ -51,9 +52,9 @@ export function buildComparisonTable(repos: Repo[]): string {
51
52
  const sep = ["".padEnd(14, "─"), ...names.map(() => "".padEnd(colW, "─"))];
52
53
 
53
54
  const lines: string[] = [
54
- "┌" + sep.map((s) => "─" + s + "─").join("┬") + "┐",
55
- "│" + header.map((h) => ` ${h} `).join("│") + "│",
56
- "├" + sep.map((s) => "─" + s + "─").join("┼") + "┤",
55
+ `┌${sep.map((s) => `─${s}─`).join("┬")}┐`,
56
+ `│${header.map((h) => ` ${h} `).join("│")}│`,
57
+ `├${sep.map((s) => `─${s}─`).join("┼")}┤`,
57
58
  ];
58
59
 
59
60
  for (const row of rows) {
@@ -61,9 +62,83 @@ export function buildComparisonTable(repos: Repo[]): string {
61
62
  row.label.padEnd(14),
62
63
  ...row.values.map((v) => v.padEnd(colW)),
63
64
  ];
64
- lines.push("│" + cells.map((c) => ` ${c} `).join("│") + "│");
65
+ lines.push(`│${cells.map((c) => ` ${c} `).join("│")}│`);
65
66
  }
66
67
 
67
- lines.push("└" + sep.map((s) => "─" + s + "─").join("┴") + "┘");
68
+ lines.push(`└${sep.map((s) => `─${s}─`).join("┴")}┘`);
68
69
  return lines.join("\n");
69
70
  }
71
+
72
+ // ─── Export formatters (parity with org/user/package modules) ──────────
73
+
74
+ /** The repo fields the comparison surface exposes. */
75
+ export interface ComparisonRepo {
76
+ fullName: string;
77
+ url: string;
78
+ description: string | null;
79
+ stars: number;
80
+ forks: number;
81
+ language: string | null;
82
+ createdAt: string;
83
+ updatedAt: string;
84
+ topics: string[];
85
+ archived: boolean;
86
+ }
87
+
88
+ function comparisonRepo(r: Repo): ComparisonRepo {
89
+ return {
90
+ fullName: r.fullName,
91
+ url: r.url,
92
+ description: r.description,
93
+ stars: r.stars,
94
+ forks: r.forks,
95
+ language: r.language,
96
+ createdAt: r.createdAt,
97
+ updatedAt: r.updatedAt,
98
+ topics: r.topics,
99
+ archived: r.archived,
100
+ };
101
+ }
102
+
103
+ export function comparisonJson(repos: Repo[]): string {
104
+ return JSON.stringify(repos.map(comparisonRepo), null, 2);
105
+ }
106
+
107
+ function csvEscape(val: unknown): string {
108
+ const str = String(val ?? "");
109
+ if (str.includes('"') || str.includes(",") || str.includes("\n")) {
110
+ return `"${str.replace(/"/g, '""')}"`;
111
+ }
112
+ return str;
113
+ }
114
+
115
+ export function comparisonCsv(repos: Repo[]): string {
116
+ const header = "full_name,stars,forks,language,created,updated,archived,url";
117
+ const rows = repos.map((r) =>
118
+ [
119
+ r.fullName,
120
+ r.stars,
121
+ r.forks,
122
+ r.language ?? "",
123
+ r.createdAt?.slice(0, 10) ?? "",
124
+ r.updatedAt?.slice(0, 10) ?? "",
125
+ String(r.archived),
126
+ r.url,
127
+ ]
128
+ .map(csvEscape)
129
+ .join(","),
130
+ );
131
+ return [header, ...rows].join("\n");
132
+ }
133
+
134
+ export function comparisonMarkdown(repos: Repo[]): string {
135
+ const header =
136
+ "| Repo | Stars | Forks | Language | Created | Updated | Topics |";
137
+ const sep =
138
+ "|------|-------|-------|----------|---------|---------|--------|";
139
+ const rows = repos.map(
140
+ (r) =>
141
+ `| [${r.fullName}](${r.url}) | ${r.stars.toLocaleString()} | ${r.forks.toLocaleString()} | ${r.language ?? "—"} | ${r.createdAt?.slice(0, 7) ?? "—"} | ${r.updatedAt?.slice(0, 10) ?? "—"} | ${(r.topics?.slice(0, 5).join(", ") ?? "") || "—"} |`,
142
+ );
143
+ return [header, sep, ...rows].join("\n");
144
+ }
package/src/config.ts CHANGED
@@ -7,9 +7,9 @@
7
7
  * 3. ~/.config/ghfind/config.json
8
8
  */
9
9
  import type { Config } from "./types";
10
- import { homedir } from "os";
11
- import { join } from "path";
12
- import { mkdirSync, readFileSync, writeFileSync } from "fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
13
 
14
14
  /** Directory for persistent state (history, bookmarks, session). */
15
15
  export function stateDir(): string {