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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-search-cli",
3
- "version": "9.4.3",
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 (allows multi-word quoted names)
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: " \u2191\u2193 Navigate \u21E9 Select [?]help [q]uit",
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 === "landing") {
531
- if (key.name === "up" || key.name === "k") {
532
- landingSelected = Math.max(0, landingSelected - 1);
533
- updateLandingCards();
534
- renderer.requestRender();
535
- } else if (key.name === "down" || key.name === "j") {
536
- landingSelected = Math.min(
537
- landingOptions.length - 1,
538
- landingSelected + 1,
539
- );
540
- updateLandingCards();
541
- renderer.requestRender();
542
- } else if (key.name === "enter" || key.name === "return") {
543
- landingOptions[landingSelected].action();
544
- } else if (key.name === "?" || key.name === "h") {
545
- showOverlay(currentOverlay === "help" ? "none" : "help");
546
- } else if (key.name === "q") {
547
- cleanup();
548
- }
549
- return;
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: 16,
1076
+ height: 24,
1053
1077
  left: "22.5%",
1054
- top: "25%",
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(updateText);
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: 60 })
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: 60 })
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) {