github-search-cli 9.6.0 → 9.7.0

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.
@@ -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).
@@ -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
@@ -46,7 +46,20 @@ import {
46
46
  import { checkAndNotify, allCachedReleases } from "./releases";
47
47
  import { runInitWizard } from "./init";
48
48
  import { runLoginWizard } from "./login";
49
- import { buildComparisonTable } from "./compare";
49
+ import {
50
+ buildComparisonTable,
51
+ comparisonJson,
52
+ comparisonCsv,
53
+ comparisonMarkdown,
54
+ } from "./compare";
55
+ import {
56
+ fetchDeepDiveRaw,
57
+ buildDeepDiveData,
58
+ buildDeepDiveJson,
59
+ buildDeepDiveText,
60
+ resolveRepoFromRef,
61
+ } from "./deepdive";
62
+ import { resolveTrendingLanguage } from "./search";
50
63
  import type { Repo } from "./types";
51
64
  import { readFileSync } from "fs";
52
65
  import { join, dirname } from "path";
@@ -96,6 +109,8 @@ interface CLIFlags {
96
109
  user?: string; // user profile lookup (ghfind user <name>)
97
110
  mcp: boolean; // run the MCP server (ghfind mcp)
98
111
  skill?: string; // agent skill guide (ghfind skill [name])
112
+ deepDive?: string; // repo deep-dive (ghfind deep-dive <owner/repo>)
113
+ language?: string; // trending language filter (--trending rust / ghfind trending rust)
99
114
  }
100
115
 
101
116
  function parseArgs(args: string[]): CLIFlags {
@@ -142,6 +157,23 @@ function parseArgs(args: string[]): CLIFlags {
142
157
  break;
143
158
  case "--trending":
144
159
  flags.trending = true;
160
+ // `ghfind --trending rust` — first bare word is the language filter
161
+ break;
162
+ case "trending": {
163
+ // `ghfind trending rust` — subcommand form of --trending (README has
164
+ // claimed this works; now it does).
165
+ flags.trending = true;
166
+ if (
167
+ flags.query === "" &&
168
+ i + 1 < args.length &&
169
+ !args[i + 1].startsWith("-")
170
+ ) {
171
+ flags.language = args[++i];
172
+ }
173
+ break;
174
+ }
175
+ case "--language":
176
+ flags.language = args[++i];
145
177
  break;
146
178
  case "--watch":
147
179
  flags.watch = true;
@@ -204,6 +236,15 @@ function parseArgs(args: string[]): CLIFlags {
204
236
  case "mcp":
205
237
  flags.mcp = true;
206
238
  break;
239
+ case "deep-dive": {
240
+ // Single repo ref (owner/name); a following flag means none.
241
+ const next = args[i + 1];
242
+ if (next && !next.startsWith("-")) {
243
+ flags.deepDive = next;
244
+ i++;
245
+ }
246
+ break;
247
+ }
207
248
  case "skill": {
208
249
  // Optional skill id (ids contain no spaces); a following flag means none.
209
250
  const next = args[i + 1];
@@ -284,8 +325,12 @@ Usage:
284
325
  ghfind <query> --format <fmt> Format lines (urls|names|ssh-urls|clone-commands|ids)
285
326
  ghfind <query> --pipe <target> Pipe to clone/open
286
327
  ghfind --trending --json Trending repos as JSON
328
+ ghfind trending <lang> Trending repos, optionally by language
287
329
  ghfind --releases Check bookmarks for new releases
288
330
  ghfind --compare <r1> <r2> Compare two+ repos side-by-side
331
+ ghfind --compare a/b c/d --json Compare as JSON|CSV|Markdown
332
+ ghfind deep-dive <owner/repo> Deep-dive: languages, contributors, README
333
+ ghfind deep-dive a/b --json Deep-dive as JSON
289
334
  ghfind org <name> --json Org profile: repos, stars, top languages
290
335
  ghfind user <name> --json User profile: repos, stars, top languages
291
336
  ghfind pkg <query> --json Search npm packages, output JSON
@@ -306,6 +351,7 @@ Options:
306
351
  --sort <strategy> Sort: best-match|stars|updated|forks
307
352
  --theme <name> Theme: tokyo-night|premium-dark (default: tokyo-night)
308
353
  --trending Trending mode
354
+ --language <lang> Trending language filter (e.g. rust)
309
355
  --since <period> Trending period: daily|weekly|monthly
310
356
  --pipe <target> Pipe target: clone|open
311
357
  --format <fmt> Line format: urls|names|ssh-urls|...
@@ -384,6 +430,12 @@ Options:
384
430
  ? "markdown"
385
431
  : undefined;
386
432
 
433
+ // Repo deep-dive — parity with the MCP ghfind_deep_dive tool
434
+ if (flags.deepDive !== undefined) {
435
+ await runDeepDive(flags, outputFormat);
436
+ return;
437
+ }
438
+
387
439
  // Org profile mode — independent of repo search handlers
388
440
  // (checks presence, not truthiness, so `ghfind org` with no name errors helpfully)
389
441
  if (flags.org !== undefined) {
@@ -403,7 +455,10 @@ Options:
403
455
  flags.format ||
404
456
  flags.pipe ||
405
457
  flags.watch ||
406
- flags.releases;
458
+ flags.releases ||
459
+ // `ghfind trending rust` / `ghfind --trending rust` print a list —
460
+ // a language filter implies non-interactive output
461
+ (flags.trending && (!!flags.language || !!flags.query));
407
462
 
408
463
  // Non-interactive mode
409
464
  if (isNonInteractive) {
@@ -506,6 +561,32 @@ async function runOrgProfile(
506
561
  }
507
562
  }
508
563
 
564
+ /** `ghfind deep-dive <owner/repo>` — rich repo detail: languages, contributors, README. */
565
+ async function runDeepDive(
566
+ flags: CLIFlags,
567
+ outputFormat?: ExportFormat,
568
+ ): Promise<void> {
569
+ const ref = flags.deepDive!;
570
+ if (!ref) {
571
+ console.error(
572
+ "Usage: ghfind deep-dive <owner/repo> [--json] [--token <t>]",
573
+ );
574
+ process.exit(1);
575
+ }
576
+ try {
577
+ const repo = await resolveRepoFromRef(ref, flags.token); // validates + real metadata
578
+ const raw = await fetchDeepDiveRaw(repo, flags.token);
579
+ if (outputFormat === "json") {
580
+ console.log(buildDeepDiveJson(raw));
581
+ } else {
582
+ console.log(buildDeepDiveText(buildDeepDiveData(repo, raw)));
583
+ }
584
+ } catch (err) {
585
+ console.error(err instanceof Error ? err.message : String(err));
586
+ process.exit(1);
587
+ }
588
+ }
589
+
509
590
  /** `ghfind pkg "<query>"` — npm package search. Pass-through query to the registry. */
510
591
  async function runPackageSearch(flags: CLIFlags): Promise<void> {
511
592
  if (flags.registry !== "npm") {
@@ -611,6 +692,20 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
611
692
  ? "monthly"
612
693
  : "daily";
613
694
  const trendingSearch = createTrendingSearch();
695
+ // Language filter: validated against github.com/trending's accepted
696
+ // set (same validation as the MCP ghfind_trending tool).
697
+ let language: string | undefined;
698
+ try {
699
+ // `ghfind trending rust` puts the language in flags.language;
700
+ // `ghfind --trending rust` leaves it as the query's first word.
701
+ language = resolveTrendingLanguage(
702
+ flags.language ??
703
+ (flags.query ? flags.query.split(/\s+/)[0] : undefined),
704
+ );
705
+ } catch (err) {
706
+ console.error(err instanceof Error ? err.message : String(err));
707
+ process.exit(1);
708
+ }
614
709
  const response = await trendingSearch.search(
615
710
  { keywords: [], qualifiers: [], raw: "trending" },
616
711
  {
@@ -619,6 +714,7 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
619
714
  json: false,
620
715
  verbose: false,
621
716
  trendingSince: since,
717
+ ...(language ? { trendingLanguage: language } : {}),
622
718
  },
623
719
  );
624
720
  if (outputFormat) {
@@ -710,6 +806,7 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
710
806
  compare: async (_ctx) => {
711
807
  const search = createGitHubSearch(undefined, [flags.token ?? ""]);
712
808
  const repos: Repo[] = [];
809
+ const missing: string[] = [];
713
810
  for (const fullName of flags.compare) {
714
811
  const res = await search.search(
715
812
  { keywords: [fullName], qualifiers: [], raw: fullName },
@@ -721,9 +818,27 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
721
818
  token: flags.token,
722
819
  },
723
820
  );
724
- repos.push(...res.repos);
821
+ if (res.repos.length > 0) repos.push(...res.repos);
822
+ else missing.push(fullName);
823
+ }
824
+ if (repos.length < 2) {
825
+ console.error(
826
+ `Could not resolve enough repos to compare (found ${repos.length}${missing.length ? `; not found: ${missing.join(", ")}` : ""}).`,
827
+ );
828
+ process.exit(1);
829
+ }
830
+ const notFound = missing.length
831
+ ? `\n\nNot found: ${missing.join(", ")}`
832
+ : "";
833
+ if (outputFormat === "json") {
834
+ console.log(comparisonJson(repos));
835
+ } else if (outputFormat === "csv") {
836
+ console.log(comparisonCsv(repos));
837
+ } else if (outputFormat === "markdown") {
838
+ console.log(comparisonMarkdown(repos));
839
+ } else {
840
+ console.log(buildComparisonTable(repos) + notFound);
725
841
  }
726
- console.log(buildComparisonTable(repos));
727
842
  },
728
843
  };
729
844
 
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
 
@@ -67,3 +68,77 @@ export function buildComparisonTable(repos: Repo[]): string {
67
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/deepdive.ts CHANGED
@@ -10,6 +10,119 @@ function buildHeaders(token?: string): Record<string, string> {
10
10
  return headers;
11
11
  }
12
12
 
13
+ // ─── Repo ref parsing (shared by the CLI deep-dive and the MCP server) ─
14
+
15
+ const REPO_REF_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
16
+
17
+ /**
18
+ * Parse a repo reference ("owner/name") into its parts. Tolerates a trailing
19
+ * `.git` and a leading `@`. Throws with a user-facing message for anything else.
20
+ */
21
+ export function parseRepoRef(input: string): { owner: string; name: string } {
22
+ const fullName = input
23
+ .trim()
24
+ .replace(/\.git$/, "")
25
+ .replace(/^@/, "");
26
+ if (!REPO_REF_RE.test(fullName)) {
27
+ throw new Error(
28
+ `"${input}" is not a repo name — use owner/name, e.g. facebook/react.`,
29
+ );
30
+ }
31
+ const [owner, name] = fullName.split("/");
32
+ return { owner, name };
33
+ }
34
+
35
+ /** Minimal Repo for deep-dive (the fetchers only read owner/name). */
36
+ export function repoFromRef(fullName: string): Repo {
37
+ const { owner, name } = parseRepoRef(fullName);
38
+ return {
39
+ id: 0,
40
+ fullName: `${owner}/${name}`,
41
+ name,
42
+ owner,
43
+ description: null,
44
+ url: `https://github.com/${owner}/${name}`,
45
+ stars: 0,
46
+ forks: 0,
47
+ watchers: 0,
48
+ language: null,
49
+ topics: [],
50
+ archived: false,
51
+ isFork: false,
52
+ private: false,
53
+ createdAt: "",
54
+ updatedAt: "",
55
+ pushedAt: "",
56
+ score: 0,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Resolve a repo ref to a real Repo by fetching canonical metadata from the
62
+ * GitHub API. Returns a stub via repoFromRef when the metadata fetch fails
63
+ * (offline, rate limit) — the ref is still validated first. Deep-dive
64
+ * sub-fetches tolerate missing data either way.
65
+ */
66
+ export async function resolveRepoFromRef(
67
+ fullName: string,
68
+ token?: string,
69
+ ): Promise<Repo> {
70
+ const ref = parseRepoRef(fullName); // throws for malformed refs
71
+ const canonical = `${ref.owner}/${ref.name}`;
72
+ try {
73
+ const res = await fetch(`https://api.github.com/repos/${canonical}`, {
74
+ headers: buildHeaders(token),
75
+ });
76
+ if (res.status === 404) {
77
+ throw new Error(`Repo not found: ${canonical}`);
78
+ }
79
+ if (res.ok) {
80
+ const api = (await res.json()) as {
81
+ description: string | null;
82
+ html_url: string;
83
+ stargazers_count: number;
84
+ forks_count: number;
85
+ subscribers_count?: number;
86
+ language: string | null;
87
+ topics?: string[];
88
+ archived: boolean;
89
+ fork: boolean;
90
+ created_at: string;
91
+ updated_at: string;
92
+ pushed_at: string;
93
+ };
94
+ return {
95
+ id: 0,
96
+ fullName: canonical,
97
+ name: ref.name,
98
+ owner: ref.owner,
99
+ description: api.description,
100
+ url: api.html_url,
101
+ stars: api.stargazers_count ?? 0,
102
+ forks: api.forks_count ?? 0,
103
+ watchers: api.subscribers_count ?? 0,
104
+ language: api.language,
105
+ topics: api.topics ?? [],
106
+ archived: api.archived ?? false,
107
+ isFork: api.fork ?? false,
108
+ private: false,
109
+ createdAt: api.created_at ?? "",
110
+ updatedAt: api.updated_at ?? "",
111
+ pushedAt: api.pushed_at ?? "",
112
+ score: 0,
113
+ };
114
+ }
115
+ } catch (err) {
116
+ // Let the not-found error propagate; only network/parse failures degrade
117
+ // to the stub below.
118
+ if (err instanceof Error && err.message.startsWith("Repo not found")) {
119
+ throw err;
120
+ }
121
+ // Fall through to the stub.
122
+ }
123
+ return repoFromRef(canonical);
124
+ }
125
+
13
126
  /** Language breakdown: { [lang]: bytes } */
14
127
  type LanguageMap = Record<string, number>;
15
128
 
@@ -27,11 +140,31 @@ export interface DeepDiveData {
27
140
  readme: string;
28
141
  }
29
142
 
30
- /** Fetch all deep-dive data for a repo. Returns formatted sections. */
31
- export async function fetchDeepDive(
143
+ /** Structured deep-dive payload — JSON-ready (for `ghfind deep-dive --json`). */
144
+ export interface DeepDiveJson {
145
+ repo: {
146
+ fullName: string;
147
+ url: string;
148
+ description: string | null;
149
+ stars: number;
150
+ forks: number;
151
+ language: string | null;
152
+ topics: string[];
153
+ };
154
+ /** Language breakdown sorted by bytes, descending. */
155
+ languages: { name: string; bytes: number; percent: number }[];
156
+ contributors: { login: string; contributions: number }[];
157
+ /** Raw README markdown (capped at README_MAX_CHARS). */
158
+ readme: string;
159
+ }
160
+
161
+ const README_MAX_CHARS = 5_000;
162
+
163
+ /** Fetch deep-dive data as structured JSON: language percents, contributors, raw README. */
164
+ export async function fetchDeepDiveRaw(
32
165
  repo: Repo,
33
166
  token?: string,
34
- ): Promise<DeepDiveData> {
167
+ ): Promise<DeepDiveJson> {
35
168
  const headers = buildHeaders(token);
36
169
 
37
170
  const [languagesRes, contributorsRes] = await Promise.all([
@@ -60,22 +193,64 @@ export async function fetchDeepDive(
60
193
  }
61
194
  }
62
195
 
196
+ const langEntries: [string, number][] = languagesRes.ok
197
+ ? Object.entries(
198
+ (await languagesRes.json().catch(() => ({}))) as LanguageMap,
199
+ )
200
+ : [];
201
+ const totalBytes = langEntries.reduce((s, [, v]) => s + v, 0);
202
+ const contributors: Contributor[] = contributorsRes.ok
203
+ ? ((await contributorsRes.json().catch(() => [])) as Contributor[])
204
+ : [];
205
+
206
+ return {
207
+ repo: {
208
+ fullName: repo.fullName,
209
+ url: repo.url,
210
+ description: repo.description,
211
+ stars: repo.stars,
212
+ forks: repo.forks,
213
+ language: repo.language,
214
+ topics: repo.topics,
215
+ },
216
+ languages: langEntries
217
+ .sort(([, a], [, b]) => b - a)
218
+ .map(([name, bytes]) => ({
219
+ name,
220
+ bytes,
221
+ percent: totalBytes > 0 ? (bytes / totalBytes) * 100 : 0,
222
+ })),
223
+ contributors: contributors.map((c) => ({
224
+ login: c.login,
225
+ contributions: c.contributions,
226
+ })),
227
+ readme: readmeText.slice(0, README_MAX_CHARS),
228
+ };
229
+ }
230
+
231
+ /** JSON string for `ghfind deep-dive --json`. */
232
+ export function buildDeepDiveJson(data: DeepDiveJson): string {
233
+ return JSON.stringify(data, null, 2);
234
+ }
235
+
236
+ /** Format structured deep-dive data into display sections. */
237
+ export function buildDeepDiveData(repo: Repo, raw: DeepDiveJson): DeepDiveData {
63
238
  return {
64
239
  summary: formatSummary(repo),
65
- languages: formatLanguages(
66
- languagesRes.ok
67
- ? ((await languagesRes.json().catch(() => ({}))) as LanguageMap)
68
- : {},
69
- ),
70
- contributors: formatContributors(
71
- contributorsRes.ok
72
- ? ((await contributorsRes.json().catch(() => [])) as Contributor[])
73
- : [],
74
- ),
75
- readme: formatReadme(readmeText),
240
+ languages: formatLanguages(raw.languages),
241
+ contributors: formatContributors(raw.contributors),
242
+ readme: formatReadme(raw.readme),
76
243
  };
77
244
  }
78
245
 
246
+ /** Fetch all deep-dive data for a repo. Returns formatted sections. */
247
+ export async function fetchDeepDive(
248
+ repo: Repo,
249
+ token?: string,
250
+ ): Promise<DeepDiveData> {
251
+ return buildDeepDiveData(repo, await fetchDeepDiveRaw(repo, token));
252
+ }
253
+
79
254
  function formatSummary(repo: Repo): string {
80
255
  const lines = [
81
256
  ` ${repo.fullName}`,
@@ -87,17 +262,14 @@ function formatSummary(repo: Repo): string {
87
262
  return lines.filter(Boolean).join("\n");
88
263
  }
89
264
 
90
- function formatLanguages(langs: LanguageMap): string {
91
- const entries = Object.entries(langs);
92
- if (entries.length === 0) return " (no language data)";
93
- const total = entries.reduce((s, [, v]) => s + v, 0);
265
+ function formatLanguages(langs: DeepDiveJson["languages"]): string {
266
+ if (langs.length === 0) return " (no language data)";
94
267
  const barW = 20;
95
- return entries
96
- .sort(([, a], [, b]) => b - a)
268
+ return langs
97
269
  .slice(0, 8)
98
- .map(([name, bytes]) => {
99
- const pct = ((bytes / total) * 100).toFixed(1);
100
- const filled = Math.round((bytes / total) * barW);
270
+ .map(({ name, percent }) => {
271
+ const pct = percent.toFixed(1);
272
+ const filled = Math.round((percent / 100) * barW);
101
273
  const bar = "█".repeat(filled) + "░".repeat(barW - filled);
102
274
  return ` ${name.padEnd(16)} ${bar} ${pct}%`;
103
275
  })