github-search-cli 9.1.1 → 9.3.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/package.ts CHANGED
@@ -1,137 +1,137 @@
1
- /**
2
- * package.ts — package-registry search provider (prototype for ticket 002-008).
3
- *
4
- * npm-only for the MVP (ticket 002-005). Query is passed through verbatim to npm's
5
- * native `/-/v1/search` (ticket 002-006) and the response normalized to the parallel
6
- * `Package` shape (ticket 002-007). Rough on purpose — this is the artifact to react to.
7
- */
8
- import type { Package } from "./types";
9
-
10
- export interface PackageSearchResult {
11
- totalCount: number;
12
- packages: Package[];
13
- }
14
-
15
- /** The npm `/-/v1/search` object shape we actually consume (research 002-001). */
16
- interface NpmSearchObject {
17
- package: {
18
- name?: string;
19
- scope?: string;
20
- version?: string;
21
- description?: string | null;
22
- keywords?: string[];
23
- date?: string;
24
- links?: { npm?: string; homepage?: string; repository?: string };
25
- publisher?: { username?: string };
26
- author?: { name?: string };
27
- };
28
- score?: { final?: number; detail?: { quality?: number; popularity?: number; maintenance?: number } };
29
- searchScore?: number;
30
- downloads?: { npm?: number; total?: number; monthly?: number };
31
- }
32
-
33
- export function normalizeNpmObject(obj: NpmSearchObject): Package {
34
- const p = obj.package;
35
- const name = p.name ?? "";
36
- return {
37
- name,
38
- version: p.version ?? "",
39
- description: p.description ?? null,
40
- url: p.links?.npm ?? `https://www.npmjs.com/package/${name}`,
41
- downloads: obj.downloads?.monthly ?? 0,
42
- score: obj.score?.final ?? obj.searchScore ?? 0,
43
- publisher: p.publisher?.username ?? p.author?.name ?? null,
44
- keywords: p.keywords ?? [],
45
- registry: "npm",
46
- };
47
- }
48
-
49
- export function createPackageSearch() {
50
- return {
51
- async searchPackage(rawQuery: string, limit: number): Promise<PackageSearchResult> {
52
- const size = Math.min(Math.max(Math.trunc(limit) || 50, 1), 250); // npm caps size at 250
53
- const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(rawQuery)}&size=${size}`;
54
- const res = await fetch(url, {
55
- headers: { "User-Agent": "ghfind", Accept: "application/json" },
56
- });
57
- if (!res.ok) {
58
- throw new Error(`npm search failed: HTTP ${res.status}`);
59
- }
60
- const data = (await res.json()) as { objects?: NpmSearchObject[]; total?: number };
61
- const packages = (data.objects ?? []).map(normalizeNpmObject);
62
- return { totalCount: data.total ?? packages.length, packages };
63
- },
64
- };
65
- }
66
-
67
- /** Sort strategies for package results (npm registry order IS best-match). */
68
- export type PackageSortMode = "best-match" | "score" | "downloads" | "name";
69
-
70
- /** Sort modes in cycle order for the TUI Sort menu. */
71
- export const PACKAGE_SORT_MODES: { key: PackageSortMode; label: string }[] = [
72
- { key: "best-match", label: "best-match" },
73
- { key: "downloads", label: "downloads" },
74
- { key: "score", label: "score" },
75
- { key: "name", label: "name" },
76
- ];
77
-
78
- /**
79
- * Sort fetched packages. "best-match" is a no-op: it preserves the registry's
80
- * relevance order, which is the canonical npm ranking.
81
- */
82
- export function sortPackages(pkgs: Package[], sort: PackageSortMode): Package[] {
83
- if (sort === "best-match" || pkgs.length < 2) return pkgs;
84
- const arr = [...pkgs];
85
- if (sort === "score") {
86
- arr.sort((a, b) => b.score - a.score);
87
- } else if (sort === "downloads") {
88
- arr.sort((a, b) => b.downloads - a.downloads);
89
- } else {
90
- arr.sort((a, b) => a.name.localeCompare(b.name));
91
- }
92
- return arr;
93
- }
94
-
95
- // ── Package output formatters (parallel to output.ts for repo) ─────────
96
- // Entity model (ticket 002-007): json/text/count/limit are shared generics
97
- // (json uses JSON.stringify on Package[]); csv/markdown/urls/names get package
98
- // variants here. git-only formats (ssh-urls, clone-commands, ids) stay repo-only.
99
-
100
- function csvEscape(val: unknown): string {
101
- const str = String(val ?? "");
102
- if (str.includes('"') || str.includes(",") || str.includes("\n")) {
103
- return `"${str.replace(/"/g, '""')}"`;
104
- }
105
- return str;
106
- }
107
-
108
- export function formatPackagesJson(pkgs: Package[]): string {
109
- return JSON.stringify(pkgs, null, 2);
110
- }
111
-
112
- export function formatPackagesCsv(pkgs: Package[]): string {
113
- const header = "rank,name,version,downloads,score,url";
114
- const rows = pkgs.map(
115
- (p, i) =>
116
- `${i + 1},${csvEscape(p.name)},${p.version},${p.downloads},${p.score.toFixed(2)},${csvEscape(p.url)}`,
117
- );
118
- return [header, ...rows].join("\n");
119
- }
120
-
121
- export function formatPackagesMarkdown(pkgs: Package[]): string {
122
- const header = "| # | Package | Version | Downloads | Score |";
123
- const sep = "|---|---------|---------|-----------|-------|";
124
- const rows = pkgs.map(
125
- (p, i) =>
126
- `| ${i + 1} | [${p.name}@${p.version}](${p.url}) | ${p.version} | ${p.downloads.toLocaleString()} | ${p.score.toFixed(2)} |`,
127
- );
128
- return [header, sep, ...rows].join("\n");
129
- }
130
-
131
- export function formatPackagesUrls(pkgs: Package[]): string {
132
- return pkgs.map((p) => p.url).join("\n");
133
- }
134
-
135
- export function formatPackagesNames(pkgs: Package[]): string {
136
- return pkgs.map((p) => p.name).join("\n");
137
- }
1
+ /**
2
+ * package.ts — package-registry search provider (prototype for ticket 002-008).
3
+ *
4
+ * npm-only for the MVP (ticket 002-005). Query is passed through verbatim to npm's
5
+ * native `/-/v1/search` (ticket 002-006) and the response normalized to the parallel
6
+ * `Package` shape (ticket 002-007). Rough on purpose — this is the artifact to react to.
7
+ */
8
+ import type { Package } from "./types";
9
+
10
+ export interface PackageSearchResult {
11
+ totalCount: number;
12
+ packages: Package[];
13
+ }
14
+
15
+ /** The npm `/-/v1/search` object shape we actually consume (research 002-001). */
16
+ interface NpmSearchObject {
17
+ package: {
18
+ name?: string;
19
+ scope?: string;
20
+ version?: string;
21
+ description?: string | null;
22
+ keywords?: string[];
23
+ date?: string;
24
+ links?: { npm?: string; homepage?: string; repository?: string };
25
+ publisher?: { username?: string };
26
+ author?: { name?: string };
27
+ };
28
+ score?: { final?: number; detail?: { quality?: number; popularity?: number; maintenance?: number } };
29
+ searchScore?: number;
30
+ downloads?: { npm?: number; total?: number; monthly?: number };
31
+ }
32
+
33
+ export function normalizeNpmObject(obj: NpmSearchObject): Package {
34
+ const p = obj.package;
35
+ const name = p.name ?? "";
36
+ return {
37
+ name,
38
+ version: p.version ?? "",
39
+ description: p.description ?? null,
40
+ url: p.links?.npm ?? `https://www.npmjs.com/package/${name}`,
41
+ downloads: obj.downloads?.monthly ?? 0,
42
+ score: obj.score?.final ?? obj.searchScore ?? 0,
43
+ publisher: p.publisher?.username ?? p.author?.name ?? null,
44
+ keywords: p.keywords ?? [],
45
+ registry: "npm",
46
+ };
47
+ }
48
+
49
+ export function createPackageSearch() {
50
+ return {
51
+ async searchPackage(rawQuery: string, limit: number): Promise<PackageSearchResult> {
52
+ const size = Math.min(Math.max(Math.trunc(limit) || 50, 1), 250); // npm caps size at 250
53
+ const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(rawQuery)}&size=${size}`;
54
+ const res = await fetch(url, {
55
+ headers: { "User-Agent": "ghfind", Accept: "application/json" },
56
+ });
57
+ if (!res.ok) {
58
+ throw new Error(`npm search failed: HTTP ${res.status}`);
59
+ }
60
+ const data = (await res.json()) as { objects?: NpmSearchObject[]; total?: number };
61
+ const packages = (data.objects ?? []).map(normalizeNpmObject);
62
+ return { totalCount: data.total ?? packages.length, packages };
63
+ },
64
+ };
65
+ }
66
+
67
+ /** Sort strategies for package results (npm registry order IS best-match). */
68
+ export type PackageSortMode = "best-match" | "score" | "downloads" | "name";
69
+
70
+ /** Sort modes in cycle order for the TUI Sort menu. */
71
+ export const PACKAGE_SORT_MODES: { key: PackageSortMode; label: string }[] = [
72
+ { key: "best-match", label: "best-match" },
73
+ { key: "downloads", label: "downloads" },
74
+ { key: "score", label: "score" },
75
+ { key: "name", label: "name" },
76
+ ];
77
+
78
+ /**
79
+ * Sort fetched packages. "best-match" is a no-op: it preserves the registry's
80
+ * relevance order, which is the canonical npm ranking.
81
+ */
82
+ export function sortPackages(pkgs: Package[], sort: PackageSortMode): Package[] {
83
+ if (sort === "best-match" || pkgs.length < 2) return pkgs;
84
+ const arr = [...pkgs];
85
+ if (sort === "score") {
86
+ arr.sort((a, b) => b.score - a.score);
87
+ } else if (sort === "downloads") {
88
+ arr.sort((a, b) => b.downloads - a.downloads);
89
+ } else {
90
+ arr.sort((a, b) => a.name.localeCompare(b.name));
91
+ }
92
+ return arr;
93
+ }
94
+
95
+ // ── Package output formatters (parallel to output.ts for repo) ─────────
96
+ // Entity model (ticket 002-007): json/text/count/limit are shared generics
97
+ // (json uses JSON.stringify on Package[]); csv/markdown/urls/names get package
98
+ // variants here. git-only formats (ssh-urls, clone-commands, ids) stay repo-only.
99
+
100
+ function csvEscape(val: unknown): string {
101
+ const str = String(val ?? "");
102
+ if (str.includes('"') || str.includes(",") || str.includes("\n")) {
103
+ return `"${str.replace(/"/g, '""')}"`;
104
+ }
105
+ return str;
106
+ }
107
+
108
+ export function formatPackagesJson(pkgs: Package[]): string {
109
+ return JSON.stringify(pkgs, null, 2);
110
+ }
111
+
112
+ export function formatPackagesCsv(pkgs: Package[]): string {
113
+ const header = "rank,name,version,downloads,score,url";
114
+ const rows = pkgs.map(
115
+ (p, i) =>
116
+ `${i + 1},${csvEscape(p.name)},${p.version},${p.downloads},${p.score.toFixed(2)},${csvEscape(p.url)}`,
117
+ );
118
+ return [header, ...rows].join("\n");
119
+ }
120
+
121
+ export function formatPackagesMarkdown(pkgs: Package[]): string {
122
+ const header = "| # | Package | Version | Downloads | Score |";
123
+ const sep = "|---|---------|---------|-----------|-------|";
124
+ const rows = pkgs.map(
125
+ (p, i) =>
126
+ `| ${i + 1} | [${p.name}@${p.version}](${p.url}) | ${p.version} | ${p.downloads.toLocaleString()} | ${p.score.toFixed(2)} |`,
127
+ );
128
+ return [header, sep, ...rows].join("\n");
129
+ }
130
+
131
+ export function formatPackagesUrls(pkgs: Package[]): string {
132
+ return pkgs.map((p) => p.url).join("\n");
133
+ }
134
+
135
+ export function formatPackagesNames(pkgs: Package[]): string {
136
+ return pkgs.map((p) => p.name).join("\n");
137
+ }
package/src/search.ts CHANGED
@@ -612,6 +612,7 @@ export class SearchModule implements SearchProvider {
612
612
  options.sort,
613
613
  String(options.limit),
614
614
  String(options.page ?? 1),
615
+ options.trendingSince ?? "none",
615
616
  );
616
617
  const cached = SearchModule.cache.get(cacheKey);
617
618
  if (cached) {
package/src/trending.ts CHANGED
@@ -253,6 +253,14 @@ export async function launchTrending(): Promise<void> {
253
253
  paddingX: 1,
254
254
  });
255
255
  const tabTexts: TextRenderable[] = [];
256
+ const underlineBox = new BoxRenderable(renderer, {
257
+ height: 1,
258
+ backgroundColor: C.bg,
259
+ paddingX: 1,
260
+ });
261
+ function tabWidth(name: string): number {
262
+ return name.length + 2; // " name " padding
263
+ }
256
264
  function renderTabBar() {
257
265
  for (const t of tabTexts) tabBox.remove(t);
258
266
  tabTexts.length = 0;
@@ -261,8 +269,8 @@ export async function launchTrending(): Promise<void> {
261
269
  const label = ` ${name} `;
262
270
  const tt = new TextRenderable(renderer, {
263
271
  content: isActive ? t`${bold(fg(C.bg)(label))}` : t`${fg(C.muted)(label)}`,
264
- color: isActive ? C.bg : C.muted,
265
- backgroundColor: isActive ? C.cyan : C.bg,
272
+ fg: isActive ? C.bg : C.muted,
273
+ bg: isActive ? C.cyan : C.bg,
266
274
  height: 1,
267
275
  });
268
276
  tabTexts.push(tt);
@@ -270,17 +278,35 @@ export async function launchTrending(): Promise<void> {
270
278
  if (i < TAB_NAMES.length - 1) {
271
279
  const sp = new TextRenderable(renderer, {
272
280
  content: " ",
273
- color: C.muted,
274
- backgroundColor: C.bg,
281
+ fg: C.muted,
282
+ bg: C.bg,
275
283
  height: 1,
276
284
  });
277
285
  tabTexts.push(sp);
278
286
  tabBox.add(sp);
279
287
  }
280
288
  });
289
+ // ── cyan underline bar under active tab ──
290
+ let offset = 1; // paddingX
291
+ for (let i = 0; i < selectedTab; i++) {
292
+ offset += tabWidth(TAB_NAMES[i]) + 2; // tab + separator
293
+ }
294
+ const w = tabWidth(TAB_NAMES[selectedTab]);
295
+ const dots = "┉".repeat(w);
296
+ underlineBox.remove(underlineText);
297
+ underlineText.content = t`${fg(C.cyan)(dots)}`;
298
+ underlineText.left = offset;
281
299
  }
300
+ const underlineText = new TextRenderable(renderer, {
301
+ content: "",
302
+ fg: C.cyan,
303
+ bg: C.bg,
304
+ height: 1,
305
+ });
306
+ underlineBox.add(underlineText);
282
307
  renderTabBar();
283
308
  outerBox.add(tabBox);
309
+ outerBox.add(underlineBox);
284
310
  const divider = new TextRenderable(renderer, {
285
311
  content: "─".repeat(80),
286
312
  color: C.border,
@@ -357,7 +383,7 @@ export async function launchTrending(): Promise<void> {
357
383
  paddingX: 1,
358
384
  });
359
385
  const footerText = new TextRenderable(renderer, {
360
- content: " ↑↓ navigate ↵ open / search 1-5 tab q quit r refresh",
386
+ content: " ↑↓ navigate ↵ open / search 1-3 tab q quit r refresh",
361
387
  color: C.muted,
362
388
  height: 1,
363
389
  });
package/src/tui.ts CHANGED
@@ -79,6 +79,7 @@ import {
79
79
  import { saveSession, restoreSession } from "./session";
80
80
  import { fetchDeepDive, buildDeepDiveText } from "./deepdive";
81
81
  import { buildComparisonTable } from "./compare";
82
+ import { renderMarkdown } from "./markdown-render";
82
83
  import { fetchTopics, type TopicItem } from "./explore";
83
84
  import { exportToFile, type ExportFormat } from "./output";
84
85
  import { listThemes, loadTheme } from "./themes";
@@ -457,28 +458,28 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
457
458
  for (let i = 0; i < landingOptions.length; i++) {
458
459
  const opt = landingOptions[i];
459
460
  const card = new BoxRenderable(renderer, {
460
- flexDirection: "row",
461
- height: 1,
461
+ flexDirection: "column",
462
+ height: 5,
462
463
  width: "60%",
463
464
  marginTop: 1,
464
- backgroundColor: undefined,
465
+ border: true,
465
466
  borderColor: i === 0 ? colors.accent : colors.border,
466
- borderWidth: 1,
467
467
  paddingX: 1,
468
- alignItems: "center",
469
468
  focusable: false,
470
469
  });
471
470
  landingCards.push(card);
472
471
  landingBox.add(card);
473
472
 
473
+ const spacerTop = new TextRenderable(renderer, { content: "", height: 1 });
474
474
  const titleLine = new TextRenderable(renderer, {
475
475
  content: opt.title,
476
476
  color: i === 0 ? colors.accent : colors.text,
477
- backgroundColor: undefined,
478
477
  height: 1,
479
- flexGrow: 1,
480
478
  });
479
+ const spacerBot = new TextRenderable(renderer, { content: "", height: 1 });
480
+ card.add(spacerTop);
481
481
  card.add(titleLine);
482
+ card.add(spacerBot);
482
483
  }
483
484
 
484
485
  const landingHint = new TextRenderable(renderer, {
@@ -503,38 +504,12 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
503
504
 
504
505
  function updateLandingCards() {
505
506
  for (let i = 0; i < landingCards.length; i++) {
506
- const opt = landingOptions[i];
507
507
  const isSel = i === landingSelected;
508
- landingCards[i].backgroundColor = isSel ? colors.selectionBg : colors.surface;
509
508
  landingCards[i].borderColor = isSel ? colors.accent : colors.border;
510
- landingCards[i].borderWidth = isSel ? 1 : 0;
511
509
  const children = landingCards[i].children ?? [];
512
- if (children[0]) {
513
- const iconCol = children[0] as BoxRenderable;
514
- const iconChildren = iconCol.children ?? [];
515
- const iconTextChild = iconChildren[0] as TextRenderable;
516
- if (iconTextChild) {
517
- iconTextChild.color = isSel ? colors.accent : colors.text;
518
- iconTextChild.backgroundColor = isSel ? colors.selectionBg : colors.surface;
519
- }
520
- const iconSpacerChild = iconChildren[1] as TextRenderable;
521
- if (iconSpacerChild) {
522
- iconSpacerChild.backgroundColor = isSel ? colors.selectionBg : colors.surface;
523
- }
524
- }
525
- if (children[1]) {
526
- const contentCol = children[1] as BoxRenderable;
527
- const contentChildren = contentCol.children ?? [];
528
- const titleChild = contentChildren[0] as TextRenderable;
529
- if (titleChild) {
530
- titleChild.color = isSel ? colors.accent : colors.text;
531
- titleChild.backgroundColor = isSel ? colors.selectionBg : colors.surface;
532
- }
533
- const descChild = contentChildren[1] as TextRenderable;
534
- if (descChild) {
535
- descChild.backgroundColor = isSel ? colors.selectionBg : colors.surface;
536
- }
537
- const hintChild = contentChildren[2] as TextRenderable;
510
+ const titleChild = children[1] as TextRenderable;
511
+ if (titleChild) {
512
+ titleChild.color = isSel ? colors.accent : colors.text;
538
513
  }
539
514
  }
540
515
  }
@@ -1735,17 +1710,8 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
1735
1710
  if (!text) {
1736
1711
  readmeText.content = ` (no README found for ${repo.fullName})`;
1737
1712
  } else {
1738
- // ponytail: just strip markdown formatting, show full text
1739
- const stripped = text
1740
- .replace(/```[\s\S]*?```/g, "[code block]")
1741
- .replace(/#{1,6}\s/g, "")
1742
- .replace(/\*\*(.+?)\*\*/g, "$1")
1743
- .replace(/\[(.+?)\]\(.+?\)/g, "$1")
1744
- .replace(/`([^`]+)`/g, "$1")
1745
- .split("\n")
1746
- .map((l) => l.trimEnd())
1747
- .join("\n");
1748
- readmeText.content = stripped;
1713
+ const termW = Math.max(40, (process.stdout.columns || 80) - 4);
1714
+ readmeText.content = renderMarkdown(text, { width: termW });
1749
1715
  }
1750
1716
  } catch {
1751
1717
  readmeText.content = " Failed to load README";
@@ -2041,6 +2007,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
2041
2007
  searchBox.visible = false;
2042
2008
  toolbarText.visible = false;
2043
2009
  trendingTabBox.visible = true;
2010
+ body.visible = true;
2044
2011
  renderTrendingTabs();
2045
2012
  resultsSelect.options = [
2046
2013
  { name: " Loading trending...", description: "", value: null },
@@ -2671,6 +2638,8 @@ ${pack.description ?? ""}`;
2671
2638
  showLeaderMenu();
2672
2639
  }
2673
2640
  return;
2641
+ }
2642
+
2674
2643
  // 't' toggles theme and persists to config
2675
2644
  if (key.name === "t" && !searchInput.focused) {
2676
2645
  const current = config.theme;
@@ -2684,6 +2653,17 @@ ${pack.description ?? ""}`;
2684
2653
  renderer.requestRender();
2685
2654
  return;
2686
2655
  }
2656
+
2657
+ // 'c' toggles compare on selected repo (when not typing in search)
2658
+ if (key.name === "c" && !searchInput.focused) {
2659
+ if (currentOverlay === "compare") {
2660
+ showOverlay("none");
2661
+ } else {
2662
+ toggleCompareOnSelected();
2663
+ refreshCompare();
2664
+ showOverlay("compare");
2665
+ }
2666
+ return;
2687
2667
  }
2688
2668
 
2689
2669
  // '?' / Ctrl+H toggle help overlay (fast path)
package/vendor/bun.exe DELETED
Binary file