github-search-cli 9.5.1 → 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.
package/src/cli.ts CHANGED
@@ -46,13 +46,33 @@ 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";
53
66
  import { fileURLToPath } from "url";
54
67
  import { launchBrowser } from "./tui";
55
68
  import { getVersion } from "./version";
69
+ import { runMcpServer } from "./mcp-server";
70
+ import {
71
+ ensureBuiltinSkill,
72
+ formatSkillCatalog,
73
+ getSkill,
74
+ listSkills,
75
+ } from "./agent-skill";
56
76
  import type { SearchOptions } from "./types";
57
77
  import { SearchCliError } from "./errors";
58
78
 
@@ -87,6 +107,10 @@ interface CLIFlags {
87
107
  doctor: boolean;
88
108
  org?: string; // org profile lookup (ghfind org <name>)
89
109
  user?: string; // user profile lookup (ghfind user <name>)
110
+ mcp: boolean; // run the MCP server (ghfind mcp)
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)
90
114
  }
91
115
 
92
116
  function parseArgs(args: string[]): CLIFlags {
@@ -112,6 +136,7 @@ function parseArgs(args: string[]): CLIFlags {
112
136
  registry: "npm",
113
137
  compare: [],
114
138
  doctor: false,
139
+ mcp: false,
115
140
  };
116
141
 
117
142
  let queryParts: string[] = [];
@@ -132,6 +157,23 @@ function parseArgs(args: string[]): CLIFlags {
132
157
  break;
133
158
  case "--trending":
134
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];
135
177
  break;
136
178
  case "--watch":
137
179
  flags.watch = true;
@@ -191,6 +233,29 @@ function parseArgs(args: string[]): CLIFlags {
191
233
  case "--completion":
192
234
  flags.completion = args[++i];
193
235
  break;
236
+ case "mcp":
237
+ flags.mcp = true;
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
+ }
248
+ case "skill": {
249
+ // Optional skill id (ids contain no spaces); a following flag means none.
250
+ const next = args[i + 1];
251
+ if (next && !next.startsWith("-")) {
252
+ flags.skill = next;
253
+ i++;
254
+ } else {
255
+ flags.skill = "";
256
+ }
257
+ break;
258
+ }
194
259
  case "org": {
195
260
  // Collect org name until next flag (single token — GitHub org logins
196
261
  // contain no spaces; a quoted multi-word name is an error)
@@ -260,12 +325,18 @@ Usage:
260
325
  ghfind <query> --format <fmt> Format lines (urls|names|ssh-urls|clone-commands|ids)
261
326
  ghfind <query> --pipe <target> Pipe to clone/open
262
327
  ghfind --trending --json Trending repos as JSON
328
+ ghfind trending <lang> Trending repos, optionally by language
263
329
  ghfind --releases Check bookmarks for new releases
264
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
265
334
  ghfind org <name> --json Org profile: repos, stars, top languages
266
335
  ghfind user <name> --json User profile: repos, stars, top languages
267
336
  ghfind pkg <query> --json Search npm packages, output JSON
268
337
  ghfind pkg <query> Search npm packages, text list
338
+ ghfind mcp Run the MCP server for AI agents (stdio)
339
+ ghfind skill [name] Print the agent skill guide (or catalog)
269
340
  ghfind --watch <query> Watch mode (poll every Ns)
270
341
  ghfind init Run setup wizard
271
342
  ghfind login Import the gh CLI token or paste one
@@ -280,6 +351,7 @@ Options:
280
351
  --sort <strategy> Sort: best-match|stars|updated|forks
281
352
  --theme <name> Theme: tokyo-night|premium-dark (default: tokyo-night)
282
353
  --trending Trending mode
354
+ --language <lang> Trending language filter (e.g. rust)
283
355
  --since <period> Trending period: daily|weekly|monthly
284
356
  --pipe <target> Pipe target: clone|open
285
357
  --format <fmt> Line format: urls|names|ssh-urls|...
@@ -320,6 +392,18 @@ Options:
320
392
  await runLoginWizard();
321
393
  return;
322
394
  }
395
+
396
+ // MCP server — expose ghfind as tools to AI agents over stdio
397
+ if (flags.mcp) {
398
+ await runMcpServer({ token: flags.token });
399
+ return;
400
+ }
401
+
402
+ // Agent skill guide — token-efficient usage doc for coding agents
403
+ if (flags.skill !== undefined) {
404
+ printAgentSkill(flags.skill);
405
+ return;
406
+ }
323
407
  // Package search (prototype, ticket 002-008)
324
408
  if (flags.pkg) {
325
409
  const interactive =
@@ -346,6 +430,12 @@ Options:
346
430
  ? "markdown"
347
431
  : undefined;
348
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
+
349
439
  // Org profile mode — independent of repo search handlers
350
440
  // (checks presence, not truthiness, so `ghfind org` with no name errors helpfully)
351
441
  if (flags.org !== undefined) {
@@ -365,7 +455,10 @@ Options:
365
455
  flags.format ||
366
456
  flags.pipe ||
367
457
  flags.watch ||
368
- 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));
369
462
 
370
463
  // Non-interactive mode
371
464
  if (isNonInteractive) {
@@ -395,6 +488,25 @@ function buildSearchContext(flags: CLIFlags): SearchContext {
395
488
  };
396
489
  }
397
490
 
491
+ /** `ghfind skill [name]` — print the agent skill catalog or one skill's content. */
492
+ function printAgentSkill(name: string): void {
493
+ ensureBuiltinSkill();
494
+ if (!name) {
495
+ console.log(formatSkillCatalog());
496
+ return;
497
+ }
498
+ const skill = getSkill(name);
499
+ if (!skill) {
500
+ console.error(
501
+ `Unknown skill: ${name}. Available: ${listSkills()
502
+ .map((s) => s.name)
503
+ .join(", ")}`,
504
+ );
505
+ process.exit(1);
506
+ }
507
+ console.log(skill.content);
508
+ }
509
+
398
510
  /** `ghfind user <name>` — aggregate user profile: metadata, stars, top languages, top repos. */
399
511
  async function runUserProfile(
400
512
  flags: CLIFlags,
@@ -449,6 +561,32 @@ async function runOrgProfile(
449
561
  }
450
562
  }
451
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
+
452
590
  /** `ghfind pkg "<query>"` — npm package search. Pass-through query to the registry. */
453
591
  async function runPackageSearch(flags: CLIFlags): Promise<void> {
454
592
  if (flags.registry !== "npm") {
@@ -554,6 +692,20 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
554
692
  ? "monthly"
555
693
  : "daily";
556
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
+ }
557
709
  const response = await trendingSearch.search(
558
710
  { keywords: [], qualifiers: [], raw: "trending" },
559
711
  {
@@ -562,6 +714,7 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
562
714
  json: false,
563
715
  verbose: false,
564
716
  trendingSince: since,
717
+ ...(language ? { trendingLanguage: language } : {}),
565
718
  },
566
719
  );
567
720
  if (outputFormat) {
@@ -653,6 +806,7 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
653
806
  compare: async (_ctx) => {
654
807
  const search = createGitHubSearch(undefined, [flags.token ?? ""]);
655
808
  const repos: Repo[] = [];
809
+ const missing: string[] = [];
656
810
  for (const fullName of flags.compare) {
657
811
  const res = await search.search(
658
812
  { keywords: [fullName], qualifiers: [], raw: fullName },
@@ -664,9 +818,27 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
664
818
  token: flags.token,
665
819
  },
666
820
  );
667
- 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);
668
841
  }
669
- console.log(buildComparisonTable(repos));
670
842
  },
671
843
  };
672
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
  })
package/src/doctor.ts CHANGED
@@ -237,7 +237,11 @@ function checkDirs(): void {
237
237
  }
238
238
 
239
239
  function printReport(): void {
240
+ // Respect NO_COLOR (https://no-color.org/): plain text when set (any value,
241
+ // including the empty string) or when stdout is not a TTY.
242
+ const noColor = process.env.NO_COLOR !== undefined || !process.stdout.isTTY;
240
243
  const colors = (s: string, n: CheckStatus) => {
244
+ if (noColor) return s;
241
245
  const code =
242
246
  n === "PASS" ? "\x1b[32m" : n === "WARN" ? "\x1b[33m" : "\x1b[31m";
243
247
  return `${code}${s}\x1b[0m`;