github-search-cli 9.4.3 → 9.5.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 +85 -21
- package/README.md +35 -10
- package/dist/cli.js +425 -86
- package/package.json +1 -1
- package/release-notes/9.5.0.md +30 -0
- package/src/cli.ts +55 -2
- package/src/help.ts +4 -0
- package/src/tui.ts +108 -28
- package/src/user.ts +356 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "github-search-cli",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.5.0",
|
|
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",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# What's New in ghfind 9.5.0
|
|
2
|
+
|
|
3
|
+
User profiles from the terminal, plus an org-lookup fix.
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
**`ghfind user <name>`** — the user-side counterpart to `ghfind org <name>`
|
|
8
|
+
|
|
9
|
+
- Fetches a GitHub user's metadata and public repos from the REST API and
|
|
10
|
+
aggregates them into one summary: public repo count, total stars/forks of
|
|
11
|
+
the fetched repos, followers/following, top languages by repo count,
|
|
12
|
+
top 5 repos by stars, and the 5 most recently pushed repos
|
|
13
|
+
- `ghfind user torvalds`, `ghfind user @torvalds` (leading `@` stripped)
|
|
14
|
+
- Same output flags as `org`: `--json`, `--csv`, `--markdown`, `--count`,
|
|
15
|
+
`--limit`, `--token`
|
|
16
|
+
- Organization / Bot accounts are labeled with an `[org]` / `[bot]` badge in
|
|
17
|
+
text output; site admins get `[staff]`
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- `ghfind org <name>` joined multi-word names with dashes (`"in tech"` →
|
|
22
|
+
`in-tech`) — now joined with spaces, which the API handles. Single-token
|
|
23
|
+
org lookups are unaffected.
|
|
24
|
+
|
|
25
|
+
### Internal
|
|
26
|
+
|
|
27
|
+
- `src/user.ts` mirrors the org-profile module shape: pure normalize/aggregate
|
|
28
|
+
functions, thin formatters, graceful degradation when the repos request
|
|
29
|
+
fails (user metadata is still returned)
|
|
30
|
+
- 27 new tests in `tests/user.test.ts`
|
package/src/cli.ts
CHANGED
|
@@ -36,6 +36,13 @@ import {
|
|
|
36
36
|
formatOrgMarkdown,
|
|
37
37
|
formatOrgText,
|
|
38
38
|
} from "./org";
|
|
39
|
+
import {
|
|
40
|
+
fetchUserProfile,
|
|
41
|
+
formatUserCsv,
|
|
42
|
+
formatUserJson,
|
|
43
|
+
formatUserMarkdown,
|
|
44
|
+
formatUserText,
|
|
45
|
+
} from "./user";
|
|
39
46
|
import { checkAndNotify, allCachedReleases } from "./releases";
|
|
40
47
|
import { runInitWizard } from "./init";
|
|
41
48
|
import { runLoginWizard } from "./login";
|
|
@@ -79,6 +86,7 @@ interface CLIFlags {
|
|
|
79
86
|
compare: string[]; // repo fullNames to compare
|
|
80
87
|
doctor: boolean;
|
|
81
88
|
org?: string; // org profile lookup (ghfind org <name>)
|
|
89
|
+
user?: string; // user profile lookup (ghfind user <name>)
|
|
82
90
|
}
|
|
83
91
|
|
|
84
92
|
function parseArgs(args: string[]): CLIFlags {
|
|
@@ -184,12 +192,22 @@ function parseArgs(args: string[]): CLIFlags {
|
|
|
184
192
|
flags.completion = args[++i];
|
|
185
193
|
break;
|
|
186
194
|
case "org": {
|
|
187
|
-
// Collect org name until next flag (
|
|
195
|
+
// Collect org name until next flag (single token — GitHub org logins
|
|
196
|
+
// contain no spaces; a quoted multi-word name is an error)
|
|
188
197
|
const parts: string[] = [];
|
|
189
198
|
while (i + 1 < args.length && !args[i + 1].startsWith("-")) {
|
|
190
199
|
parts.push(args[++i]);
|
|
191
200
|
}
|
|
192
|
-
flags.org = parts.join("
|
|
201
|
+
flags.org = parts.join(" ");
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
case "user": {
|
|
205
|
+
// Collect user name until next flag (single token, same as org)
|
|
206
|
+
const userParts: string[] = [];
|
|
207
|
+
while (i + 1 < args.length && !args[i + 1].startsWith("-")) {
|
|
208
|
+
userParts.push(args[++i]);
|
|
209
|
+
}
|
|
210
|
+
flags.user = userParts.join(" ");
|
|
193
211
|
break;
|
|
194
212
|
}
|
|
195
213
|
case "--compare": {
|
|
@@ -245,6 +263,8 @@ Usage:
|
|
|
245
263
|
ghfind --releases Check bookmarks for new releases
|
|
246
264
|
ghfind --compare <repo1> <repo2> Compare two+ repos side-by-side
|
|
247
265
|
ghfind org <name> --json Org profile: repos, stars, top languages
|
|
266
|
+
ghfind user <name> --json User profile: repos, stars, top languages
|
|
267
|
+
ghfind user <name> --json User profile: repos, stars, top languages
|
|
248
268
|
ghfind pkg <query> --json Search npm packages, output JSON
|
|
249
269
|
ghfind pkg <query> Search npm packages, text list
|
|
250
270
|
ghfind --watch <query> Watch mode (poll every Ns)
|
|
@@ -334,6 +354,12 @@ Options:
|
|
|
334
354
|
return;
|
|
335
355
|
}
|
|
336
356
|
|
|
357
|
+
// User profile mode — same shape as org profile
|
|
358
|
+
if (flags.user !== undefined) {
|
|
359
|
+
await runUserProfile(flags, outputFormat);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
337
363
|
const isNonInteractive =
|
|
338
364
|
outputFormat ||
|
|
339
365
|
flags.count ||
|
|
@@ -370,6 +396,33 @@ function buildSearchContext(flags: CLIFlags): SearchContext {
|
|
|
370
396
|
};
|
|
371
397
|
}
|
|
372
398
|
|
|
399
|
+
/** `ghfind user <name>` — aggregate user profile: metadata, stars, top languages, top repos. */
|
|
400
|
+
async function runUserProfile(
|
|
401
|
+
flags: CLIFlags,
|
|
402
|
+
outputFormat?: ExportFormat,
|
|
403
|
+
): Promise<void> {
|
|
404
|
+
try {
|
|
405
|
+
const profile = await fetchUserProfile(flags.user!, {
|
|
406
|
+
token: flags.token,
|
|
407
|
+
limit: flags.limit,
|
|
408
|
+
});
|
|
409
|
+
if (outputFormat === "json") {
|
|
410
|
+
console.log(formatUserJson(profile));
|
|
411
|
+
} else if (outputFormat === "csv") {
|
|
412
|
+
console.log(formatUserCsv(profile));
|
|
413
|
+
} else if (outputFormat === "markdown") {
|
|
414
|
+
console.log(formatUserMarkdown(profile));
|
|
415
|
+
} else if (flags.count) {
|
|
416
|
+
console.log(profile.publicRepoCount);
|
|
417
|
+
} else {
|
|
418
|
+
console.log(formatUserText(profile));
|
|
419
|
+
}
|
|
420
|
+
} catch (err) {
|
|
421
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
422
|
+
process.exit(1);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
373
426
|
/** `ghfind org <name>` — aggregate org profile: metadata, stars, top languages, top repos. */
|
|
374
427
|
async function runOrgProfile(
|
|
375
428
|
flags: CLIFlags,
|
package/src/help.ts
CHANGED
|
@@ -110,6 +110,10 @@ export function buildHelpSections(): HelpSection[] {
|
|
|
110
110
|
{ keys: "d", action: "Delete entry (history, saved)" },
|
|
111
111
|
{ keys: "↑ ↓ / j k", action: "Navigate overlay list" },
|
|
112
112
|
{ keys: "← → / h l", action: "Navigate tabs / panels" },
|
|
113
|
+
{
|
|
114
|
+
keys: "PgUp/PgDn",
|
|
115
|
+
action: "Scroll update notes; Home/End jump to ends",
|
|
116
|
+
},
|
|
113
117
|
],
|
|
114
118
|
},
|
|
115
119
|
{
|
package/src/tui.ts
CHANGED
|
@@ -122,6 +122,11 @@ import {
|
|
|
122
122
|
recordPreUpdateState,
|
|
123
123
|
} from "./update-check";
|
|
124
124
|
import { debugLog } from "./storage";
|
|
125
|
+
import {
|
|
126
|
+
landingConsumesKey,
|
|
127
|
+
landingKeysActive,
|
|
128
|
+
moveSelection,
|
|
129
|
+
} from "./landing";
|
|
125
130
|
const colors: Record<string, string> = {
|
|
126
131
|
bg: "#3D3B3B",
|
|
127
132
|
surface: "#4a4848",
|
|
@@ -494,7 +499,8 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
494
499
|
}
|
|
495
500
|
|
|
496
501
|
const landingHint = new TextRenderable(renderer, {
|
|
497
|
-
content:
|
|
502
|
+
content:
|
|
503
|
+
" \u2191\u2193 Navigate \u21E9 Select [b]ookmarks [?]help [q]uit",
|
|
498
504
|
fg: colors.muted,
|
|
499
505
|
bg: colors.bg,
|
|
500
506
|
height: 1,
|
|
@@ -525,29 +531,42 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
525
531
|
}
|
|
526
532
|
}
|
|
527
533
|
|
|
528
|
-
// Landing keyboard handler
|
|
534
|
+
// Landing keyboard handler. Registered before the global handler below,
|
|
535
|
+
// and both listeners receive the same key event — so keys the landing
|
|
536
|
+
// screen consumes must be claimed with stopPropagation(), or the global
|
|
537
|
+
// handler re-reads them (e.g. "/" focusing the search input on top of the
|
|
538
|
+
// menu, or "?" opening help and instantly closing it again).
|
|
529
539
|
renderer.keyInput.on("keypress", (key) => {
|
|
530
|
-
if (currentMode
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
landingOptions
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
540
|
+
if (!landingKeysActive(currentMode, currentOverlay)) return;
|
|
541
|
+
if (key.name === "up" || key.name === "k") {
|
|
542
|
+
landingSelected = moveSelection(
|
|
543
|
+
landingSelected,
|
|
544
|
+
-1,
|
|
545
|
+
landingOptions.length,
|
|
546
|
+
);
|
|
547
|
+
updateLandingCards();
|
|
548
|
+
renderer.requestRender();
|
|
549
|
+
} else if (key.name === "down" || key.name === "j") {
|
|
550
|
+
landingSelected = moveSelection(
|
|
551
|
+
landingSelected,
|
|
552
|
+
1,
|
|
553
|
+
landingOptions.length,
|
|
554
|
+
);
|
|
555
|
+
updateLandingCards();
|
|
556
|
+
renderer.requestRender();
|
|
557
|
+
} else if (key.name === "enter" || key.name === "return") {
|
|
558
|
+
landingOptions[landingSelected].action();
|
|
559
|
+
} else if (key.name === "b") {
|
|
560
|
+
refreshBookmarks();
|
|
561
|
+
showOverlay("bookmarks");
|
|
562
|
+
} else if (key.name === "?" || key.name === "h") {
|
|
563
|
+
showOverlay(currentOverlay === "help" ? "none" : "help");
|
|
564
|
+
} else if (key.name === "q") {
|
|
565
|
+
cleanup();
|
|
566
|
+
} else {
|
|
567
|
+
return; // not a landing key — let the global handler see it
|
|
550
568
|
}
|
|
569
|
+
if (landingConsumesKey(key.name)) key.stopPropagation();
|
|
551
570
|
});
|
|
552
571
|
|
|
553
572
|
// Hide main content so overlays can take 100% of the content area
|
|
@@ -598,6 +617,9 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
598
617
|
return;
|
|
599
618
|
}
|
|
600
619
|
currentOverlay = type;
|
|
620
|
+
// An overlay opened from the landing screen must hide the menu too
|
|
621
|
+
// (hideMainContent only covers the search/trending/toolbar boxes).
|
|
622
|
+
landingBox.visible = false;
|
|
601
623
|
// Leader menu is a floating overlay — main content stays visible behind it
|
|
602
624
|
if (type !== "leader") {
|
|
603
625
|
hideMainContent();
|
|
@@ -630,6 +652,8 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
630
652
|
} else if (type === "update") {
|
|
631
653
|
updateDim.visible = true;
|
|
632
654
|
updateBox.visible = true;
|
|
655
|
+
// Fresh notes start at the top of the changelog
|
|
656
|
+
updateScroll.scrollTop = 0;
|
|
633
657
|
// Selection is driven by updateSelectedOption + the global key handler
|
|
634
658
|
// (see the "Update panel" block); there is no focusable Select here.
|
|
635
659
|
}
|
|
@@ -1049,9 +1073,9 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1049
1073
|
visible: false,
|
|
1050
1074
|
position: "absolute",
|
|
1051
1075
|
width: "55%",
|
|
1052
|
-
height:
|
|
1076
|
+
height: 24,
|
|
1053
1077
|
left: "22.5%",
|
|
1054
|
-
top: "
|
|
1078
|
+
top: "20%",
|
|
1055
1079
|
backgroundColor: colors.surfaceDim,
|
|
1056
1080
|
border: true,
|
|
1057
1081
|
borderStyle: "rounded",
|
|
@@ -1069,6 +1093,25 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1069
1093
|
fg: colors.accent,
|
|
1070
1094
|
bg: colors.surfaceDim,
|
|
1071
1095
|
});
|
|
1096
|
+
// ponytail: notes render as plain text with \n; keep it a column so lines stack
|
|
1097
|
+
const updateScroll = new ScrollBoxRenderable(renderer, {
|
|
1098
|
+
flexGrow: 1,
|
|
1099
|
+
backgroundColor: colors.surfaceDim,
|
|
1100
|
+
scrollY: true,
|
|
1101
|
+
scrollX: false,
|
|
1102
|
+
viewportOptions: { backgroundColor: colors.surfaceDim },
|
|
1103
|
+
contentOptions: {
|
|
1104
|
+
backgroundColor: colors.surfaceDim,
|
|
1105
|
+
flexDirection: "column",
|
|
1106
|
+
},
|
|
1107
|
+
scrollbarOptions: {
|
|
1108
|
+
width: 1,
|
|
1109
|
+
trackOptions: {
|
|
1110
|
+
backgroundColor: colors.surfaceDim,
|
|
1111
|
+
foregroundColor: colors.muted,
|
|
1112
|
+
},
|
|
1113
|
+
},
|
|
1114
|
+
});
|
|
1072
1115
|
|
|
1073
1116
|
// Horizontal option boxes
|
|
1074
1117
|
let updateSelectedOption = 0;
|
|
@@ -1146,13 +1189,14 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1146
1189
|
updateOptionsRow.add(laterBox);
|
|
1147
1190
|
|
|
1148
1191
|
const updateFooter = new TextRenderable(renderer, {
|
|
1149
|
-
content: " ←→ select Enter Esc/q close",
|
|
1192
|
+
content: " ↑↓/jk/PgUp/PgDn scroll ←→ select Enter Esc/q close",
|
|
1150
1193
|
fg: colors.muted,
|
|
1151
1194
|
bg: colors.surfaceDim,
|
|
1152
1195
|
height: 1,
|
|
1153
1196
|
paddingX: 1,
|
|
1154
1197
|
});
|
|
1155
|
-
updateBox.add(
|
|
1198
|
+
updateBox.add(updateScroll);
|
|
1199
|
+
updateScroll.add(updateText);
|
|
1156
1200
|
updateBox.add(updateOptionsRow);
|
|
1157
1201
|
updateBox.add(updateFooter);
|
|
1158
1202
|
root.add(updateDim);
|
|
@@ -1558,8 +1602,14 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1558
1602
|
if (state.lastInstalledVersion === currentVersion) return;
|
|
1559
1603
|
// We just upgraded — show notes for the new version
|
|
1560
1604
|
const notes = fetchReleaseNotes(currentVersion);
|
|
1605
|
+
// Box is 55% of the terminal; subtract border + padding so wrapped
|
|
1606
|
+
// lines never clip horizontally.
|
|
1607
|
+
const notesWidth = Math.max(
|
|
1608
|
+
30,
|
|
1609
|
+
Math.floor((process.stdout.columns || 80) * 0.55) - 8,
|
|
1610
|
+
);
|
|
1561
1611
|
const notesBlock = notes
|
|
1562
|
-
? renderMarkdown(notes, { width:
|
|
1612
|
+
? renderMarkdown(notes, { width: notesWidth })
|
|
1563
1613
|
: " No release notes found for this version.";
|
|
1564
1614
|
updateSelectedOption = 0;
|
|
1565
1615
|
updateText.content = ` Updated ghfind ${state.lastInstalledVersion} → ${currentVersion}\n\n${notesBlock}`;
|
|
@@ -1580,8 +1630,12 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1580
1630
|
if (latest) {
|
|
1581
1631
|
updateSelectedOption = 0;
|
|
1582
1632
|
const notes = fetchReleaseNotes(latest);
|
|
1633
|
+
const notesWidth = Math.max(
|
|
1634
|
+
30,
|
|
1635
|
+
Math.floor((process.stdout.columns || 80) * 0.55) - 8,
|
|
1636
|
+
);
|
|
1583
1637
|
const notesBlock = notes
|
|
1584
|
-
? renderMarkdown(notes, { width:
|
|
1638
|
+
? renderMarkdown(notes, { width: notesWidth })
|
|
1585
1639
|
: ` A new version is available\n\n Current: ${currentVersion}\n Latest: ${latest}\n\n Run "npm install -g ghfind"`;
|
|
1586
1640
|
updateText.content = ` A new version is available\n\n Current: ${currentVersion}\n Latest: ${latest}\n\n${notesBlock}`;
|
|
1587
1641
|
renderUpdateOptions();
|
|
@@ -2755,6 +2809,24 @@ ${pack.description ?? ""}`;
|
|
|
2755
2809
|
renderer.requestRender();
|
|
2756
2810
|
return;
|
|
2757
2811
|
}
|
|
2812
|
+
// Notes area is scrollable — ↑↓/jk move a fifth of a page,
|
|
2813
|
+
// PageUp/PageDown/Home/End jump, all via the ScrollBox's native
|
|
2814
|
+
// handler (same semantics as the readme/org viewers); ←/→ still
|
|
2815
|
+
// drive the button row, not the scrollbar.
|
|
2816
|
+
if (
|
|
2817
|
+
key.name === "pageup" ||
|
|
2818
|
+
key.name === "pagedown" ||
|
|
2819
|
+
key.name === "home" ||
|
|
2820
|
+
key.name === "end" ||
|
|
2821
|
+
key.name === "up" ||
|
|
2822
|
+
key.name === "down" ||
|
|
2823
|
+
key.name === "j" ||
|
|
2824
|
+
key.name === "k"
|
|
2825
|
+
) {
|
|
2826
|
+
updateScroll.handleKeyPress(key);
|
|
2827
|
+
renderer.requestRender();
|
|
2828
|
+
return;
|
|
2829
|
+
}
|
|
2758
2830
|
if (key.name === "left" || key.name === "h") {
|
|
2759
2831
|
updateSelectedOption = Math.max(0, updateSelectedOption - 1);
|
|
2760
2832
|
renderUpdateOptions();
|
|
@@ -2803,6 +2875,14 @@ ${pack.description ?? ""}`;
|
|
|
2803
2875
|
|
|
2804
2876
|
// ── Main view handling (no overlay active) ────────────────────────
|
|
2805
2877
|
|
|
2878
|
+
// The landing screen owns the keyboard: its handler above already
|
|
2879
|
+
// consumed its keys, and everything else must be ignored here so hidden
|
|
2880
|
+
// main-view bindings (search focus, leader menu, theme, compare, quit)
|
|
2881
|
+
// can't fire through the landing menu.
|
|
2882
|
+
if (currentMode === "landing") {
|
|
2883
|
+
return;
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2806
2886
|
// q quits
|
|
2807
2887
|
if (key.name === "q") {
|
|
2808
2888
|
if (graphFullscreen) {
|