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/README.md CHANGED
@@ -22,14 +22,15 @@
22
22
  npm install -g github-search-cli
23
23
  ```
24
24
 
25
- > **No separate runtime needed!** The package bundles a Bun binary, so the TUI
26
- > works out of the box on Windows, macOS, and Linux — no Node FFI, no `--experimental-ffi`.
25
+ > **No separate runtime needed!** Only requires Node.js 20+. The TUI needs Bun,
26
+ > which is downloaded automatically at install time. Non-interactive modes work
27
+ > with just Node.js.
27
28
  >
28
- > | Runtime | TUI | CLI modes |
29
- > | ----------- | --- | --------- |
30
- > | Bundled Bun | ✅ | ✅ |
31
- > | System Bun | ✅ | ✅ |
32
- > | Node 20+ | ✅ | ✅ |
29
+ > | Platform | TUI | CLI modes |
30
+ > | --------------------- | ---------------- | --------- |
31
+ > | Node 20+ | ⚠ Bun required | ✅ |
32
+ > | Bun | ✅ | ✅ |
33
+ > | Downloaded Bun | ✅ auto-installed| ✅ |
33
34
 
34
35
  ## Use
35
36
 
@@ -191,17 +192,31 @@ cd ghfind
191
192
  # With Bun (recommended for TUI)
192
193
  bun install
193
194
  bun start # TUI
194
- bun test # 102 tests
195
+ bun test # 131 tests
195
196
  bun run build # build dist/
196
197
 
197
198
  # Or with npm
198
199
  npm install
199
- npm start # TUI (requires Node 20-22 for native FFI)
200
- npm test # vitest (TUI render test needs Bun)
200
+ npm start # TUI (requires Node 20+)
201
+ npm test # 131 tests
201
202
  npm run build # build dist/
202
203
  ```
203
204
 
204
205
  ---
206
+ ### v0.9.3
207
+ - Fixed `c` keybinding in TUI not adding repos to comparison — missing closing brace in space-key handler broke all subsequent key handlers (`c`, `t`, `?`)
208
+ - Widened compare table columns (min 35 chars) and added Description + URL rows for richer side-by-side comparison
209
+ - Compare table now shows up to 5 topics per repo (was 3)
210
+ - README viewer now renders full markdown (headings, code blocks, lists, tables, blockquotes, rules) instead of stripping formatting
211
+ ---
212
+ ### v0.9.2
213
+
214
+ - Remove bundled Bun binary from npm package (now downloaded at install time for all platforms)
215
+ - Package size reduced from 39.5 MB to 100 KB
216
+ - Postinstall no longer fails npm install on Bun download failure (graceful fallback)
217
+ - Removed `bun` from engines field (users no longer need Bun pre-installed)
218
+ - Non-interactive modes (`--json`, `--csv`, etc.) work with just Node.js 20+
219
+
205
220
  ### v0.9.1
206
221
 
207
222
  - bug fix TUI layout issues on small terminals
package/bin/ghfind.js CHANGED
@@ -3,9 +3,9 @@
3
3
  * ghfind — npm bin entry point.
4
4
  * Requires Node.js >= 20 or Bun.
5
5
  *
6
- * Uses the bundled Bun binary (vendor/bun.exe) to run the TypeScript source
7
- * directly, so users don't need to install Bun separately. Falls back to
8
- * the bundled dist/cli.js if present, or system Bun/Node.
6
+ * Strategy 1: Use a Bun binary from vendor/ (downloaded by postinstall) to
7
+ * run TypeScript source directly. Strategy 2: Fall back to the bundled
8
+ * dist/cli.js with Node.js (works for non-interactive modes).
9
9
  */
10
10
  import { existsSync } from "fs";
11
11
  import { spawnSync } from "child_process";
package/dist/cli.js CHANGED
@@ -490,7 +490,8 @@ var SearchModule = class _SearchModule {
490
490
  query.raw,
491
491
  options.sort,
492
492
  String(options.limit),
493
- String(options.page ?? 1)
493
+ String(options.page ?? 1),
494
+ options.trendingSince ?? "none"
494
495
  );
495
496
  const cached = _SearchModule.cache.get(cacheKey);
496
497
  if (cached) {
@@ -1133,6 +1134,35 @@ async function runInitWizard() {
1133
1134
  console.log("");
1134
1135
  }
1135
1136
 
1137
+ // src/compare.ts
1138
+ function buildComparisonTable(repos) {
1139
+ if (repos.length < 2) return "Select at least 2 repos to compare (press c to add).";
1140
+ const rows = [];
1141
+ const names = repos.map((r) => r.fullName);
1142
+ rows.push({ label: "Stars", values: repos.map((r) => `\u2605 ${r.stars.toLocaleString()}`) });
1143
+ rows.push({ label: "Forks", values: repos.map((r) => r.forks.toLocaleString()) });
1144
+ rows.push({ label: "Language", values: repos.map((r) => r.language ?? "\u2014") });
1145
+ rows.push({ label: "Created", values: repos.map((r) => r.createdAt?.slice(0, 7) ?? "\u2014") });
1146
+ rows.push({ label: "Updated", values: repos.map((r) => r.updatedAt?.slice(0, 10) ?? "\u2014") });
1147
+ rows.push({ label: "Topics", values: repos.map((r) => (r.topics?.slice(0, 5).join(", ") ?? "\u2014") || "\u2014") });
1148
+ rows.push({ label: "Description", values: repos.map((r) => r.description?.slice(0, 50) ?? "\u2014") });
1149
+ rows.push({ label: "URL", values: repos.map((r) => r.url || "\u2014") });
1150
+ const colW = Math.max(35, ...names.map((n) => n.length));
1151
+ const header = ["".padEnd(14), ...names.map((n) => n.padEnd(colW))];
1152
+ const sep = ["".padEnd(14, "\u2500"), ...names.map(() => "".padEnd(colW, "\u2500"))];
1153
+ const lines = [
1154
+ "\u250C" + sep.map((s) => "\u2500" + s + "\u2500").join("\u252C") + "\u2510",
1155
+ "\u2502" + header.map((h) => ` ${h} `).join("\u2502") + "\u2502",
1156
+ "\u251C" + sep.map((s) => "\u2500" + s + "\u2500").join("\u253C") + "\u2524"
1157
+ ];
1158
+ for (const row of rows) {
1159
+ const cells = [row.label.padEnd(14), ...row.values.map((v) => v.padEnd(colW))];
1160
+ lines.push("\u2502" + cells.map((c) => ` ${c} `).join("\u2502") + "\u2502");
1161
+ }
1162
+ lines.push("\u2514" + sep.map((s) => "\u2500" + s + "\u2500").join("\u2534") + "\u2518");
1163
+ return lines.join("\n");
1164
+ }
1165
+
1136
1166
  // src/cli.ts
1137
1167
  import { readFileSync as readFileSync3 } from "fs";
1138
1168
  import { join as join3, dirname as dirname2 } from "path";
@@ -1546,32 +1576,175 @@ function buildDeepDiveText(data) {
1546
1576
  ].join("\n");
1547
1577
  }
1548
1578
 
1549
- // src/compare.ts
1550
- function buildComparisonTable(repos) {
1551
- if (repos.length < 2) return "Select at least 2 repos to compare (press c to add).";
1552
- const rows = [];
1553
- const names = repos.map((r) => r.fullName);
1554
- rows.push({ label: "Stars", values: repos.map((r) => `\u2605 ${r.stars.toLocaleString()}`) });
1555
- rows.push({ label: "Forks", values: repos.map((r) => r.forks.toLocaleString()) });
1556
- rows.push({ label: "Language", values: repos.map((r) => r.language ?? "\u2014") });
1557
- rows.push({ label: "Created", values: repos.map((r) => r.createdAt?.slice(0, 7) ?? "\u2014") });
1558
- rows.push({ label: "Updated", values: repos.map((r) => r.updatedAt?.slice(0, 10) ?? "\u2014") });
1559
- rows.push({ label: "Topics", values: repos.map((r) => (r.topics?.slice(0, 3).join(", ") ?? "\u2014") || "\u2014") });
1560
- const colW = Math.max(15, ...names.map((n) => n.length));
1561
- const valW = Math.max(8, ...rows.flatMap((r) => r.values.map((v) => v.length)));
1562
- const header = ["".padEnd(14), ...names.map((n) => n.padEnd(colW))];
1563
- const sep = ["".padEnd(14, "\u2500"), ...names.map(() => "".padEnd(colW, "\u2500"))];
1564
- const lines = [
1565
- "\u250C" + sep.map((s) => "\u2500" + s + "\u2500").join("\u252C") + "\u2510",
1566
- "\u2502" + header.map((h) => ` ${h} `).join("\u2502") + "\u2502",
1567
- "\u251C" + sep.map((s) => "\u2500" + s + "\u2500").join("\u253C") + "\u2524"
1568
- ];
1569
- for (const row of rows) {
1570
- const cells = [row.label.padEnd(14), ...row.values.map((v) => v.padEnd(colW))];
1571
- lines.push("\u2502" + cells.map((c) => ` ${c} `).join("\u2502") + "\u2502");
1579
+ // src/markdown-render.ts
1580
+ function renderMarkdown(md, opts = {}) {
1581
+ const width = opts.width ?? 80;
1582
+ const lines = md.split("\n");
1583
+ const out = [];
1584
+ let inCodeBlock = false;
1585
+ let codeLang = "";
1586
+ let codeLines = [];
1587
+ let inTable = false;
1588
+ let tableRows = [];
1589
+ function flushCode() {
1590
+ if (codeLines.length === 0) return;
1591
+ const maxLen = Math.max(...codeLines.map((l) => l.length), codeLang.length);
1592
+ const barW = Math.min(maxLen + 4, width - 2);
1593
+ out.push("\u250C" + "\u2500".repeat(barW) + "\u2510");
1594
+ if (codeLang) out.push("\u2502 " + codeLang.padEnd(barW - 1) + "\u2502");
1595
+ for (const cl of codeLines) {
1596
+ out.push("\u2502 " + cl.padEnd(barW - 1) + "\u2502");
1597
+ }
1598
+ out.push("\u2514" + "\u2500".repeat(barW) + "\u2518");
1599
+ out.push("");
1600
+ codeLines = [];
1601
+ codeLang = "";
1602
+ }
1603
+ function flushTable() {
1604
+ if (tableRows.length === 0) return;
1605
+ const dataRows = tableRows.filter(
1606
+ (r) => !r.every((c) => /^[\s\-:|]+$/.test(c))
1607
+ );
1608
+ if (dataRows.length === 0) {
1609
+ tableRows = [];
1610
+ return;
1611
+ }
1612
+ const colCount = Math.max(...dataRows.map((r) => r.length));
1613
+ const colW = [];
1614
+ for (let ci = 0; ci < colCount; ci++) {
1615
+ colW.push(
1616
+ Math.max(
1617
+ 3,
1618
+ ...dataRows.map((r) => (r[ci] ?? "").length)
1619
+ )
1620
+ );
1621
+ }
1622
+ const totalW = colW.reduce((a, b) => a + b, 0) + colCount * 3 - 1;
1623
+ if (totalW > width) {
1624
+ for (const row of dataRows) {
1625
+ out.push(" " + row.map((c) => c.trim()).join(" \u2502 "));
1626
+ }
1627
+ } else {
1628
+ const sep = "\u250C" + colW.map((w) => "\u2500".repeat(w + 2)).join("\u252C") + "\u2510";
1629
+ const mid = "\u251C" + colW.map((w) => "\u2500".repeat(w + 2)).join("\u253C") + "\u2524";
1630
+ const end = "\u2514" + colW.map((w) => "\u2500".repeat(w + 2)).join("\u2534") + "\u2518";
1631
+ out.push(sep);
1632
+ dataRows.forEach((row, i) => {
1633
+ const cells = colW.map((w, ci) => (row[ci] ?? "").trim().padEnd(w));
1634
+ out.push("\u2502 " + cells.map((c) => ` ${c} `).join("\u2502") + "\u2502");
1635
+ if (i === 0) out.push(mid);
1636
+ });
1637
+ out.push(end);
1638
+ }
1639
+ out.push("");
1640
+ tableRows = [];
1641
+ }
1642
+ function renderInline(line) {
1643
+ line = line.replace(/\*\*(.+?)\*\*/g, "$1");
1644
+ line = line.replace(/\*([^*]+?)\*/g, "$1");
1645
+ line = line.replace(/`([^`]+)`/g, "\u27E8$1\u27E9");
1646
+ line = line.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)");
1647
+ line = line.replace(/!\[([^\]]*)\]\([^)]+\)/g, "\u{1F5BC} $1");
1648
+ return line;
1649
+ }
1650
+ function wrapLine(line, maxW) {
1651
+ if (line.length <= maxW) return [line];
1652
+ const parts = [];
1653
+ let remaining = line;
1654
+ while (remaining.length > maxW) {
1655
+ let breakAt = maxW;
1656
+ const spaceIdx = remaining.lastIndexOf(" ", maxW);
1657
+ if (spaceIdx > maxW / 2) breakAt = spaceIdx;
1658
+ parts.push(remaining.slice(0, breakAt));
1659
+ remaining = remaining.slice(breakAt).replace(/^ /, "");
1660
+ }
1661
+ if (remaining) parts.push(remaining);
1662
+ return parts;
1663
+ }
1664
+ for (let i = 0; i < lines.length; i++) {
1665
+ let line = lines[i];
1666
+ if (/^```/.test(line)) {
1667
+ if (inCodeBlock) {
1668
+ flushCode();
1669
+ inCodeBlock = false;
1670
+ } else {
1671
+ inCodeBlock = true;
1672
+ codeLang = line.slice(3).trim();
1673
+ }
1674
+ continue;
1675
+ }
1676
+ if (inCodeBlock) {
1677
+ codeLines.push(line);
1678
+ continue;
1679
+ }
1680
+ if (line.includes("|")) {
1681
+ const cells = line.split("|").map((c) => c.trim()).filter((_, idx, arr) => idx > 0 && idx < arr.length - 1);
1682
+ if (cells.length >= 2) {
1683
+ inTable = true;
1684
+ tableRows.push(cells);
1685
+ continue;
1686
+ }
1687
+ } else if (inTable) {
1688
+ flushTable();
1689
+ inTable = false;
1690
+ }
1691
+ if (/^(\s*[-*_]){3,}\s*$/.test(line)) {
1692
+ out.push("\u2500".repeat(Math.min(width, 40)));
1693
+ out.push("");
1694
+ continue;
1695
+ }
1696
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)/);
1697
+ if (headingMatch) {
1698
+ const level = headingMatch[1].length;
1699
+ const text = renderInline(headingMatch[2]);
1700
+ const prefix = "\u2588 ".repeat(level);
1701
+ out.push("");
1702
+ out.push(prefix + text);
1703
+ out.push("");
1704
+ continue;
1705
+ }
1706
+ const bqMatch = line.match(/^>\s?(.*)/);
1707
+ if (bqMatch) {
1708
+ const inner = renderInline(bqMatch[1]);
1709
+ for (const wl of wrapLine(inner, width - 4)) {
1710
+ out.push("\u2502 " + wl);
1711
+ }
1712
+ continue;
1713
+ }
1714
+ const ulMatch = line.match(/^(\s*)[-*+]\s+(.*)/);
1715
+ if (ulMatch) {
1716
+ const indent = ulMatch[1].length;
1717
+ const depth = Math.floor(indent / 2);
1718
+ const prefix = " ".repeat(depth) + "\u2022 ";
1719
+ const inner = renderInline(ulMatch[2]);
1720
+ for (const wl of wrapLine(inner, width - prefix.length)) {
1721
+ out.push(prefix + wl);
1722
+ }
1723
+ continue;
1724
+ }
1725
+ const olMatch = line.match(/^(\s*)\d+\.\s+(.*)/);
1726
+ if (olMatch) {
1727
+ const indent = olMatch[1].length;
1728
+ const depth = Math.floor(indent / 2);
1729
+ const prefix = " ".repeat(depth) + "\u25E6 ";
1730
+ const inner = renderInline(olMatch[2]);
1731
+ for (const wl of wrapLine(inner, width - prefix.length)) {
1732
+ out.push(prefix + wl);
1733
+ }
1734
+ continue;
1735
+ }
1736
+ if (line.trim() === "") {
1737
+ out.push("");
1738
+ continue;
1739
+ }
1740
+ const rendered = renderInline(line);
1741
+ for (const wl of wrapLine(rendered, width)) {
1742
+ out.push(wl);
1743
+ }
1572
1744
  }
1573
- lines.push("\u2514" + sep.map((s) => "\u2500" + s + "\u2500").join("\u2534") + "\u2518");
1574
- return lines.join("\n");
1745
+ if (inCodeBlock) flushCode();
1746
+ if (inTable) flushTable();
1747
+ return out.join("\n");
1575
1748
  }
1576
1749
 
1577
1750
  // src/explore.ts
@@ -2134,27 +2307,27 @@ async function launchBrowser(theme_override) {
2134
2307
  for (let i = 0; i < landingOptions.length; i++) {
2135
2308
  const opt = landingOptions[i];
2136
2309
  const card = new BoxRenderable3(renderer, {
2137
- flexDirection: "row",
2138
- height: 1,
2310
+ flexDirection: "column",
2311
+ height: 5,
2139
2312
  width: "60%",
2140
2313
  marginTop: 1,
2141
- backgroundColor: void 0,
2314
+ border: true,
2142
2315
  borderColor: i === 0 ? colors.accent : colors.border,
2143
- borderWidth: 1,
2144
2316
  paddingX: 1,
2145
- alignItems: "center",
2146
2317
  focusable: false
2147
2318
  });
2148
2319
  landingCards.push(card);
2149
2320
  landingBox.add(card);
2321
+ const spacerTop = new TextRenderable3(renderer, { content: "", height: 1 });
2150
2322
  const titleLine = new TextRenderable3(renderer, {
2151
2323
  content: opt.title,
2152
2324
  color: i === 0 ? colors.accent : colors.text,
2153
- backgroundColor: void 0,
2154
- height: 1,
2155
- flexGrow: 1
2325
+ height: 1
2156
2326
  });
2327
+ const spacerBot = new TextRenderable3(renderer, { content: "", height: 1 });
2328
+ card.add(spacerTop);
2157
2329
  card.add(titleLine);
2330
+ card.add(spacerBot);
2158
2331
  }
2159
2332
  const landingHint = new TextRenderable3(renderer, {
2160
2333
  content: " \u2191\u2193 Navigate \u21E9 Select [?]help [q]uit",
@@ -2176,38 +2349,12 @@ async function launchBrowser(theme_override) {
2176
2349
  }
2177
2350
  function updateLandingCards() {
2178
2351
  for (let i = 0; i < landingCards.length; i++) {
2179
- const opt = landingOptions[i];
2180
2352
  const isSel = i === landingSelected;
2181
- landingCards[i].backgroundColor = isSel ? colors.selectionBg : colors.surface;
2182
2353
  landingCards[i].borderColor = isSel ? colors.accent : colors.border;
2183
- landingCards[i].borderWidth = isSel ? 1 : 0;
2184
2354
  const children = landingCards[i].children ?? [];
2185
- if (children[0]) {
2186
- const iconCol = children[0];
2187
- const iconChildren = iconCol.children ?? [];
2188
- const iconTextChild = iconChildren[0];
2189
- if (iconTextChild) {
2190
- iconTextChild.color = isSel ? colors.accent : colors.text;
2191
- iconTextChild.backgroundColor = isSel ? colors.selectionBg : colors.surface;
2192
- }
2193
- const iconSpacerChild = iconChildren[1];
2194
- if (iconSpacerChild) {
2195
- iconSpacerChild.backgroundColor = isSel ? colors.selectionBg : colors.surface;
2196
- }
2197
- }
2198
- if (children[1]) {
2199
- const contentCol = children[1];
2200
- const contentChildren = contentCol.children ?? [];
2201
- const titleChild = contentChildren[0];
2202
- if (titleChild) {
2203
- titleChild.color = isSel ? colors.accent : colors.text;
2204
- titleChild.backgroundColor = isSel ? colors.selectionBg : colors.surface;
2205
- }
2206
- const descChild = contentChildren[1];
2207
- if (descChild) {
2208
- descChild.backgroundColor = isSel ? colors.selectionBg : colors.surface;
2209
- }
2210
- const hintChild = contentChildren[2];
2355
+ const titleChild = children[1];
2356
+ if (titleChild) {
2357
+ titleChild.color = isSel ? colors.accent : colors.text;
2211
2358
  }
2212
2359
  }
2213
2360
  }
@@ -3290,8 +3437,8 @@ async function launchBrowser(theme_override) {
3290
3437
  if (!text) {
3291
3438
  readmeText.content = ` (no README found for ${repo.fullName})`;
3292
3439
  } else {
3293
- const stripped = text.replace(/```[\s\S]*?```/g, "[code block]").replace(/#{1,6}\s/g, "").replace(/\*\*(.+?)\*\*/g, "$1").replace(/\[(.+?)\]\(.+?\)/g, "$1").replace(/`([^`]+)`/g, "$1").split("\n").map((l) => l.trimEnd()).join("\n");
3294
- readmeText.content = stripped;
3440
+ const termW = Math.max(40, (process.stdout.columns || 80) - 4);
3441
+ readmeText.content = renderMarkdown(text, { width: termW });
3295
3442
  }
3296
3443
  } catch {
3297
3444
  readmeText.content = " Failed to load README";
@@ -3525,6 +3672,7 @@ async function launchBrowser(theme_override) {
3525
3672
  searchBox.visible = false;
3526
3673
  toolbarText.visible = false;
3527
3674
  trendingTabBox.visible = true;
3675
+ body.visible = true;
3528
3676
  renderTrendingTabs();
3529
3677
  resultsSelect.options = [
3530
3678
  { name: " Loading trending...", description: "", value: null }
@@ -4074,18 +4222,28 @@ ${pack.description ?? ""}`;
4074
4222
  showLeaderMenu();
4075
4223
  }
4076
4224
  return;
4077
- if (key.name === "t" && !searchInput.focused) {
4078
- const current = config.theme;
4079
- const themes = listThemes();
4080
- const idx = themes.indexOf(current);
4081
- const next = themes[(idx + 1) % themes.length];
4082
- const nextTheme = loadTheme(next);
4083
- Object.assign(colors, nextTheme);
4084
- config.theme = next;
4085
- saveConfig(config);
4086
- renderer.requestRender();
4087
- return;
4225
+ }
4226
+ if (key.name === "t" && !searchInput.focused) {
4227
+ const current = config.theme;
4228
+ const themes = listThemes();
4229
+ const idx = themes.indexOf(current);
4230
+ const next = themes[(idx + 1) % themes.length];
4231
+ const nextTheme = loadTheme(next);
4232
+ Object.assign(colors, nextTheme);
4233
+ config.theme = next;
4234
+ saveConfig(config);
4235
+ renderer.requestRender();
4236
+ return;
4237
+ }
4238
+ if (key.name === "c" && !searchInput.focused) {
4239
+ if (currentOverlay === "compare") {
4240
+ showOverlay("none");
4241
+ } else {
4242
+ toggleCompareOnSelected();
4243
+ refreshCompare();
4244
+ showOverlay("compare");
4088
4245
  }
4246
+ return;
4089
4247
  }
4090
4248
  if (key.name === "?" || key.name === "h" && key.ctrl) {
4091
4249
  showOverlay("help");
@@ -4236,7 +4394,8 @@ function parseArgs(args) {
4236
4394
  version: false,
4237
4395
  help: false,
4238
4396
  pkg: false,
4239
- registry: "npm"
4397
+ registry: "npm",
4398
+ compare: []
4240
4399
  };
4241
4400
  let queryParts = [];
4242
4401
  for (let i = 0; i < args.length; i++) {
@@ -4305,6 +4464,14 @@ function parseArgs(args) {
4305
4464
  case "--completion":
4306
4465
  flags.completion = args[++i];
4307
4466
  break;
4467
+ case "--compare": {
4468
+ const repos = [];
4469
+ while (i + 1 < args.length && !args[i + 1].startsWith("-")) {
4470
+ repos.push(args[++i]);
4471
+ }
4472
+ flags.compare = repos;
4473
+ break;
4474
+ }
4308
4475
  default:
4309
4476
  if (!arg.startsWith("-")) queryParts.push(arg);
4310
4477
  }
@@ -4336,6 +4503,7 @@ Usage:
4336
4503
  ghfind <query> --format <fmt> Format lines (urls|names|ssh-urls|clone-commands|ids)
4337
4504
  ghfind <query> --pipe <target> Pipe to clone/open
4338
4505
  ghfind --trending --json Trending repos as JSON
4506
+ ghfind --compare <repo1> <repo2> Compare two+ repos side-by-side
4339
4507
  ghfind pkg <query> --json Search npm packages, output JSON
4340
4508
  ghfind pkg <query> Search npm packages, text list
4341
4509
  ghfind --watch <query> Watch mode (poll every Ns)
@@ -4516,9 +4684,21 @@ async function runNonInteractive(flags, outputFormat) {
4516
4684
  if (outputFormat) {
4517
4685
  console.log(format(repos, outputFormat));
4518
4686
  }
4687
+ },
4688
+ compare: async (_ctx) => {
4689
+ const search = createGitHubSearch(void 0, [flags.token ?? ""]);
4690
+ const repos = [];
4691
+ for (const fullName of flags.compare) {
4692
+ const res = await search.search(
4693
+ { keywords: [fullName], qualifiers: [], raw: fullName },
4694
+ { limit: 1, sort: "stars", json: false, verbose: false, token: flags.token }
4695
+ );
4696
+ repos.push(...res.repos);
4697
+ }
4698
+ console.log(buildComparisonTable(repos));
4519
4699
  }
4520
4700
  };
4521
- const key = flags.trending && !flags.watch ? "trending" : flags.watch ? "watch" : flags.pipe ? "pipe" : flags.format ? "format" : "search";
4701
+ const key = flags.compare.length > 0 ? "compare" : flags.trending && !flags.watch ? "trending" : flags.watch ? "watch" : flags.pipe ? "pipe" : flags.format ? "format" : "search";
4522
4702
  const ctx = buildSearchContext(flags);
4523
4703
  await handlers[key](ctx);
4524
4704
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-search-cli",
3
- "version": "9.1.1",
3
+ "version": "9.3.0",
4
4
  "description": "Terminal-native GitHub search — search, trending, bookmarks, history, deep-dive, compare, export, and more. All keyboard-driven, no browser needed.",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1",
@@ -16,7 +16,6 @@
16
16
  "files": [
17
17
  "bin/",
18
18
  "dist/",
19
- "vendor/",
20
19
  "src/",
21
20
  "scripts/",
22
21
  "completions/",
@@ -45,8 +44,7 @@
45
44
  "typescript": "^5.3.0"
46
45
  },
47
46
  "engines": {
48
- "node": ">=20.0.0",
49
- "bun": ">=1.0.0"
47
+ "node": ">=20.0.0"
50
48
  },
51
49
  "repository": {
52
50
  "type": "git",
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Postinstall script — ensures a Bun binary is available for ghfind.
3
+ * Postinstall script — downloads a Bun binary for ghfind's TUI.
4
4
  *
5
- * If vendor/bun or vendor/bun.exe already exists (bundled with the package),
6
- * this is a no-op. Otherwise, it attempts to download the correct binary
7
- * for the user's platform.
5
+ * If vendor/bun or vendor/bun.exe already exists, this is a no-op.
6
+ * Otherwise, it downloads the correct Bun binary for the user's platform.
7
+ * On failure, prints a warning and exits 0 so npm install still succeeds.
8
8
  */
9
9
  import { existsSync, mkdirSync, chmodSync, writeFileSync } from "fs";
10
10
  import { join, dirname } from "path";
@@ -100,7 +100,10 @@ try {
100
100
 
101
101
  throw new Error("Bun binary not found in archive");
102
102
  } catch (err) {
103
- console.error(`[ghfind] Failed to download Bun: ${err instanceof Error ? err.message : String(err)}`);
104
- console.error("[ghfind] Install Bun manually: https://bun.sh/docs/install");
105
- process.exit(1);
103
+ console.warn(`[ghfind] ⚠ Could not download Bun: ${err instanceof Error ? err.message : String(err)}`);
104
+ console.warn("[ghfind] The interactive TUI requires Bun. Install it manually:");
105
+ console.warn("[ghfind] curl -fsSL https://bun.sh/install | bash");
106
+ console.warn("[ghfind] # or: npm install -g bun");
107
+ console.warn("[ghfind] Non-interactive modes (--json, --csv, etc.) will still work with Node.js 20+.");
108
+ process.exit(0);
106
109
  }
package/src/cli.ts CHANGED
@@ -15,6 +15,8 @@ import { launchPackageBrowser } from "./package-browser";
15
15
  import { format as formatOutput, exportToFile, pipeExec, type Format, type ExportFormat, type FormatLine } from "./output";
16
16
  import { runWatch } from "./watch";
17
17
  import { runInitWizard } from "./init";
18
+ import { buildComparisonTable } from "./compare";
19
+ import type { Repo } from "./types";
18
20
  import { readFileSync } from "fs";
19
21
  import { join, dirname } from "path";
20
22
  import { fileURLToPath } from "url";
@@ -46,6 +48,7 @@ interface CLIFlags {
46
48
  pkg: boolean;
47
49
  theme?: string;
48
50
  registry: string;
51
+ compare: string[]; // repo fullNames to compare
49
52
  }
50
53
 
51
54
  function parseArgs(args: string[]): CLIFlags {
@@ -67,6 +70,7 @@ function parseArgs(args: string[]): CLIFlags {
67
70
  help: false,
68
71
  pkg: false,
69
72
  registry: "npm",
73
+ compare: [],
70
74
  };
71
75
 
72
76
  let queryParts: string[] = [];
@@ -100,6 +104,15 @@ function parseArgs(args: string[]): CLIFlags {
100
104
  case "--registry": flags.registry = args[++i] ?? "npm"; break;
101
105
  case "--theme": flags.theme = args[++i] ?? "tokyo-night"; break;
102
106
  case "--completion": flags.completion = args[++i]; break;
107
+ case "--compare": {
108
+ // Collect repo names until next flag
109
+ const repos: string[] = [];
110
+ while (i + 1 < args.length && !args[i + 1].startsWith("-")) {
111
+ repos.push(args[++i]);
112
+ }
113
+ flags.compare = repos;
114
+ break;
115
+ }
103
116
  default:
104
117
  if (!arg.startsWith("-")) queryParts.push(arg);
105
118
  }
@@ -136,6 +149,7 @@ Usage:
136
149
  ghfind <query> --format <fmt> Format lines (urls|names|ssh-urls|clone-commands|ids)
137
150
  ghfind <query> --pipe <target> Pipe to clone/open
138
151
  ghfind --trending --json Trending repos as JSON
152
+ ghfind --compare <repo1> <repo2> Compare two+ repos side-by-side
139
153
  ghfind pkg <query> --json Search npm packages, output JSON
140
154
  ghfind pkg <query> Search npm packages, text list
141
155
  ghfind --watch <query> Watch mode (poll every Ns)
@@ -328,10 +342,23 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
328
342
  console.log(formatOutput(repos, outputFormat));
329
343
  }
330
344
  },
345
+ compare: async (_ctx) => {
346
+ const search = createGitHubSearch(undefined, [flags.token ?? ""]);
347
+ const repos: Repo[] = [];
348
+ for (const fullName of flags.compare) {
349
+ const res = await search.search(
350
+ { keywords: [fullName], qualifiers: [], raw: fullName },
351
+ { limit: 1, sort: "stars", json: false, verbose: false, token: flags.token },
352
+ );
353
+ repos.push(...res.repos);
354
+ }
355
+ console.log(buildComparisonTable(repos));
356
+ },
331
357
  };
332
358
 
333
359
  // Determine which handler to run
334
- const key = flags.trending && !flags.watch ? "trending"
360
+ const key = flags.compare.length > 0 ? "compare"
361
+ : flags.trending && !flags.watch ? "trending"
335
362
  : flags.watch ? "watch"
336
363
  : flags.pipe ? "pipe"
337
364
  : flags.format ? "format"
package/src/compare.ts CHANGED
@@ -20,11 +20,12 @@ export function buildComparisonTable(repos: Repo[]): string {
20
20
  rows.push({ label: "Language", values: repos.map((r) => r.language ?? "—") });
21
21
  rows.push({ label: "Created", values: repos.map((r) => r.createdAt?.slice(0, 7) ?? "—") });
22
22
  rows.push({ label: "Updated", values: repos.map((r) => r.updatedAt?.slice(0, 10) ?? "—") });
23
- rows.push({ label: "Topics", values: repos.map((r) => (r.topics?.slice(0, 3).join(", ") ?? "—") || "—") });
23
+ rows.push({ label: "Topics", values: repos.map((r) => (r.topics?.slice(0, 5).join(", ") ?? "—") || "—") });
24
+ rows.push({ label: "Description", values: repos.map((r) => (r.description?.slice(0, 50) ?? "—")) });
25
+ rows.push({ label: "URL", values: repos.map((r) => r.url || "—") });
24
26
 
25
27
  // Calculate the max width for each column
26
- const colW = Math.max(15, ...names.map((n) => n.length));
27
- const valW = Math.max(8, ...rows.flatMap((r) => r.values.map((v) => v.length)));
28
+ const colW = Math.max(35, ...names.map((n) => n.length));
28
29
 
29
30
  // Header row
30
31
  const header = ["".padEnd(14), ...names.map((n) => n.padEnd(colW))];