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/CHANGELOG.md +95 -0
- package/README.md +77 -27
- package/completions/ghfind.bash +5 -1
- package/completions/ghfind.fish +2 -1
- package/completions/ghfind.zsh +5 -0
- package/dist/cli.js +1117 -103
- package/package.json +1 -1
- package/release-notes/9.6.0.md +67 -0
- package/release-notes/9.7.0.md +53 -0
- package/scripts/brew.rb +31 -10
- package/scripts/install.sh +118 -0
- package/src/agent-skill.ts +141 -0
- package/src/cli.ts +176 -4
- package/src/compare.ts +76 -1
- package/src/deepdive.ts +195 -23
- package/src/doctor.ts +4 -0
- package/src/mcp-server.ts +865 -0
- package/src/search.ts +88 -5
- package/src/types.ts +2 -0
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 {
|
|
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
|
-
/**
|
|
31
|
-
export
|
|
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<
|
|
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
|
-
|
|
67
|
-
|
|
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:
|
|
91
|
-
|
|
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
|
|
96
|
-
.sort(([, a], [, b]) => b - a)
|
|
268
|
+
return langs
|
|
97
269
|
.slice(0, 8)
|
|
98
|
-
.map((
|
|
99
|
-
const pct =
|
|
100
|
-
const filled = Math.round((
|
|
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`;
|