github-search-cli 9.10.0 → 9.10.1

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 CHANGED
@@ -6,6 +6,49 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Publish
6
6
 
7
7
  ---
8
8
 
9
+ ## [9.10.1] — 2026-10-08 (released)
10
+
11
+ ### Fixed
12
+
13
+ - **`--interval` is parsed again.** The flag was documented in `--help`, the
14
+ README, the shell completions and `docs/HOW-TO.md`, but `parseArgs` had no
15
+ case for it — so `ghfind --watch --interval 60` silently searched for
16
+ `"--interval 60"` and polled at the default 300s. It now parses a positive
17
+ whole number of seconds and rejects anything else.
18
+ - **`--format` and `--pipe` validate up front.** A typo like `--format jsonn`
19
+ used to throw `Unknown format` mid-run and route into the crash-report flow
20
+ (which prints "ghfind encountered an unexpected error" and opens a GitHub
21
+ issue tab); `--pipe clonee` silently did nothing and exited 0. Both now fail
22
+ fast with the list of valid values and exit 1.
23
+ - **An empty query no longer reaches GitHub.** `ghfind --count` sent `q=" "`,
24
+ got a 422 back, and surfaced it as "⚠ Network error — check your connection.
25
+ Retrying in 5s…". The search/format/pipe/watch handlers now fail fast with a
26
+ usage hint instead.
27
+ - **Bare `ghfind --trending` launches the trending view** (as TRACKER.md
28
+ documents) instead of falling through to the landing menu.
29
+ - **`--since` validates its value** (`daily|weekly|monthly`) instead of
30
+ silently falling back to daily for anything it did not recognize — matching
31
+ the existing `--sort` validation.
32
+ - **Watch mode honors `--since`.** The trending period was derived from the
33
+ query string (`ghfind --watch --trending weekly`), so `--since weekly` was
34
+ ignored in watch-trending. An explicit `--since` now wins; the query-string
35
+ convention still applies when the flag is absent.
36
+ - **The MCP `ghfind_user_profile` tool returns formatted text** like
37
+ `ghfind_org_profile` (and like the CLI's default `ghfind user`), instead of
38
+ raw JSON; the structured profile still rides along in `data`.
39
+ - **Trending TUI footer matches the `/` key** (scroll-to-top: `/ top`, not
40
+ `/ search`), and the quit paths of the main TUI, trending and package browser
41
+ call `renderer.destroy()` before `process.exit(0)` so the terminal is
42
+ released (raw mode, alternate screen).
43
+
44
+ ### Removed
45
+
46
+ - Dead modules with no importers: `src/search-state.ts` (stale
47
+ `config.githubTokens` reference from before token unification),
48
+ `src/export.ts` and `src/pipe.ts` (byte-for-byte duplicates of the formats
49
+ in `src/output.ts`, which is what everything imports), and the unused
50
+ `historyCount()` export.
51
+
9
52
  ## [9.10.0] — 2026-10-03 (released)
10
53
 
11
54
  ### Added
package/README.md CHANGED
@@ -347,14 +347,14 @@ cd ghfind
347
347
  # With Bun (recommended for TUI)
348
348
  bun install
349
349
  bun start # TUI
350
- bun test # 657 tests
350
+ bun test # 682 tests
351
351
  bun run check # format check + tests — the green gate
352
352
  bun run build # build dist/
353
353
 
354
354
  # Or with npm
355
355
  npm install
356
356
  npm start # TUI (requires Node 20+)
357
- npm test # 657 tests
357
+ npm test # 682 tests
358
358
  npm run build # build dist/
359
359
 
360
360
  > Want to contribute? See [CONTRIBUTING.md](CONTRIBUTING.md) for commands,
@@ -388,6 +388,17 @@ for the architecture, measurements, and remaining work.
388
388
 
389
389
  Recent releases (full history in [CHANGELOG.md](CHANGELOG.md)):
390
390
 
391
+ ### v9.10.1
392
+
393
+ - **`--interval` is parsed again** — it was documented in `--help`, the README, the how-to guide and all three shell completions, but `parseArgs` had no case for it, so `ghfind --watch --interval 60` searched for the literal string `--interval 60` and polled at the default 300s anyway
394
+ - **A flag typo no longer opens a GitHub issue tab** — `--format jsonn` threw mid-run and routed into the crash-report flow (printing "ghfind encountered an unexpected error" and opening a prefilled issue page); `--pipe clonee` silently did nothing and exited 0; both now validate up front, list the valid values, and exit 1
395
+ - **An empty query fails fast instead of faking a network error** — `ghfind --count` with no query sent `q=" "`, got a 422 back, and looped on `⚠ Network error — check your connection. Retrying in 5s...`; the search/format/pipe/watch handlers now print a usage hint instead
396
+ - **Bare `ghfind --trending` opens the trending view** instead of falling through to the landing menu, as documented
397
+ - **`--since` validates `daily|weekly|monthly`** instead of silently accepting anything and falling back to daily — the same treatment `--sort` already had
398
+ - **`--watch --trending --since weekly` polls weekly** — the trending period used to be derived from the query string, so `--since` was ignored in watch mode
399
+ - **The MCP `ghfind_user_profile` tool returns formatted text**, matching `ghfind_org_profile` and the CLI's default `ghfind user` output; the raw JSON still rides along in `data`
400
+ - **Quit paths release the terminal** — the main TUI, the trending view and the package browser call `renderer.destroy()` before exiting, and the trending footer's `/` hint now matches the scroll-to-top key it actually binds
401
+
391
402
  ### v9.10.0
392
403
 
393
404
  - **The TUI is now fully clickable** — the mouse was already enabled (OpenTUI emitted and hit-tested every event) but nothing consumed them, so they were silently discarded; click selects, double-click opens, the wheel scrolls, and clicking outside an overlay closes it
@@ -412,25 +423,6 @@ Recent releases (full history in [CHANGELOG.md](CHANGELOG.md)):
412
423
  - **A prerelease is no longer offered as an upgrade** — the version comparison used by Node and the compiled binaries reported `9.9.0-beta.1` as newer than `9.9.0`, i.e. a downgrade; it now follows npm ordering and ignores build metadata
413
424
  - **A new [how-to guide](docs/HOW-TO.md)** — token setup, writing queries, reading results, recovering from errors, and copy-paste recipes
414
425
 
415
- ### v9.8.3
416
-
417
- - **401/403 say what actually happened** — a rejected token and an authenticated-but-forbidden request (e.g. SAML/SSO enforcement) both used to report "Network error", naming a connectivity problem you didn't have; only genuine connectivity and server failures keep that message now
418
- - **A rate-limited search no longer looks like an empty one** — the adapter used to swallow the error and render a silent, successful "no repositories found"; the real message reaches the status bar, and the `ghfind_search_repos` MCP tool returns `rateLimited: true` with backoff advice instead of a tool failure
419
- - **Recover from errors without leaving the TUI** — while the matching error is in the status bar, `t` opens an inline token prompt (saves it and re-runs the failed query), `u` clears the token and retries unauthenticated, `c` swaps in a different token, `r` retries
420
- - **A corrupted session no longer hijacks startup** — a `session.json` holding an MCP handshake blob used to skip the landing menu and auto-run that garbage as a search on every launch; the menu always comes first now, and both save and restore sanitize the session
421
- - **Images wrapped in a link render now** — the badge-and-screenshot shape `[![alt](shot.png)](url)` rendered nothing at all, which cost `sst/opencode` its only image and `withastro/astro` 23 of its 25; the image cells carry an OSC 8 hyperlink, so clicking it opens the target
422
- - **A README full of small images no longer floods the screen** — images the README declares under 100px on any edge are skipped before a request is made, and undeclared `![]()` images are measured from the image header before any pixel decode; on `openclaw/openclaw` (648 sponsor avatars) that is 649 fetches / 28.3s → 1 fetch / ~0.5s
423
-
424
- ### v9.8.2
425
-
426
- - **Security: tokens no longer leak into crash reports** — an uncaught error auto-built a GitHub issue URL from raw `process.argv`, so a `--token` value ended up in a public URL; every field is redacted now, and ghfind prints the URL to copy instead of opening a browser when it finds a credential
427
- - **Security: your token is never sent to `raw.githubusercontent.com`** when fetching a README, only to `api.github.com` and only for a private repo
428
- - **Security: untrusted skill sources are validated** before reaching a shell command or URL, so a hostile skills.sh entry cannot inject into `npx skills add`
429
- - **Recoverable errors no longer open a bug-report tab** — rate limits, network blips and layout changes print a short message and exit
430
- - **`--compare` resolves repositories exactly** in both the CLI and the `ghfind_compare_repos` MCP tool; it used a free-text search that could silently compare the wrong repo
431
- - **State files are written atomically** — a crash mid-write used to leave truncated JSON that was silently replaced with defaults, losing bookmarks and history
432
- - Fixed notification IDs restarting after "dismiss all", `history --delete` removing every duplicate, an invalid `--sort` degrading silently, a failed "load more" clearing the TUI's results, `--markdown` not escaping `|`, and a not-quite-LRU memory cache
433
-
434
426
  ## License
435
427
 
436
428
  MIT
package/dist/cli.js CHANGED
@@ -1569,6 +1569,7 @@ async function launchPackageBrowser() {
1569
1569
  renderer.keyInput.on("keypress", (key) => {
1570
1570
  const name = key.name || "";
1571
1571
  if (name === "q" || name === "escape") {
1572
+ renderer.destroy();
1572
1573
  process.exit(0);
1573
1574
  return;
1574
1575
  }
@@ -2153,7 +2154,7 @@ var cached = null;
2153
2154
  function getVersion() {
2154
2155
  if (cached) return cached;
2155
2156
  if (true) {
2156
- cached = "9.10.0";
2157
+ cached = "9.10.1";
2157
2158
  return cached;
2158
2159
  }
2159
2160
  try {
@@ -2322,6 +2323,7 @@ function reportError(err, context = {
2322
2323
 
2323
2324
  // src/output.ts
2324
2325
  import { writeFileSync as writeFileSync2 } from "fs";
2326
+ var PIPE_TARGETS = ["open", "clone"];
2325
2327
  var FORMATS = /* @__PURE__ */ new Map([
2326
2328
  // Export formats
2327
2329
  ["json", formatJson],
@@ -2335,6 +2337,7 @@ var FORMATS = /* @__PURE__ */ new Map([
2335
2337
  ["clone-commands", formatCloneCommands],
2336
2338
  ["ids", formatIds]
2337
2339
  ]);
2340
+ var FORMAT_VALUES = [...FORMATS.keys()];
2338
2341
  function formatJson(repos) {
2339
2342
  return JSON.stringify(repos, null, 2);
2340
2343
  }
@@ -2407,6 +2410,13 @@ async function pipeExec(repos, target) {
2407
2410
  }
2408
2411
 
2409
2412
  // src/watch.ts
2413
+ function resolveTrendingSince(opts) {
2414
+ if (opts.since === "weekly" || opts.since === "monthly") return opts.since;
2415
+ if (opts.since === "daily") return "daily";
2416
+ if (opts.query === "weekly") return "weekly";
2417
+ if (opts.query === "monthly") return "monthly";
2418
+ return "daily";
2419
+ }
2410
2420
  var MAX_FAILS = 5;
2411
2421
  async function runWatch(opts, onChange, onError) {
2412
2422
  let previous = [];
@@ -2419,7 +2429,7 @@ async function runWatch(opts, onChange, onError) {
2419
2429
  try {
2420
2430
  let repos;
2421
2431
  if (opts.trending) {
2422
- const since = opts.query === "weekly" ? "weekly" : opts.query === "monthly" ? "monthly" : "daily";
2432
+ const since = resolveTrendingSince(opts);
2423
2433
  const trendingSearch = createTrendingSearch();
2424
2434
  const response = await trendingSearch.search(trendingQuery(), {
2425
2435
  limit: opts.limit,
@@ -4870,10 +4880,12 @@ var StatusManager = class {
4870
4880
  type = "idle";
4871
4881
  message = "";
4872
4882
  onUpdate;
4883
+ disposed = false;
4873
4884
  constructor(onUpdate) {
4874
4885
  this.onUpdate = onUpdate;
4875
4886
  }
4876
4887
  set(type, message) {
4888
+ if (this.disposed) return;
4877
4889
  this.stopSpinner();
4878
4890
  this.type = type;
4879
4891
  this.message = message;
@@ -4886,6 +4898,7 @@ var StatusManager = class {
4886
4898
  }
4887
4899
  }
4888
4900
  startSpinner(frames) {
4901
+ if (this.disposed) return;
4889
4902
  this.spinnerIndex = 0;
4890
4903
  this.emit(frames[0]);
4891
4904
  this.spinnerInterval = setInterval(() => {
@@ -4900,14 +4913,23 @@ var StatusManager = class {
4900
4913
  }
4901
4914
  }
4902
4915
  emit(spinner) {
4916
+ if (this.disposed) return;
4903
4917
  const icon = this.type === "searching" ? "\u{1F50D}" : this.type === "loading" ? "\u{1F4E6}" : this.type === "success" ? "\u2713" : this.type === "error" ? "\u26A0" : " ";
4904
4918
  const prefix = spinner ?? "";
4905
4919
  this.onUpdate(`${icon} ${this.message} ${prefix}`.trim());
4906
4920
  }
4907
4921
  dispose() {
4922
+ this.disposed = true;
4908
4923
  this.stopSpinner();
4909
4924
  }
4910
4925
  };
4926
+ function createStatusManager(bar, onFrame) {
4927
+ return new StatusManager((text) => {
4928
+ if (bar.isDestroyed) return;
4929
+ bar.content = ` ${text}`;
4930
+ onFrame();
4931
+ });
4932
+ }
4911
4933
 
4912
4934
  // src/share.ts
4913
4935
  import { spawn as spawn3 } from "child_process";
@@ -5270,13 +5292,15 @@ function createTuiLogger() {
5270
5292
  };
5271
5293
  }
5272
5294
  var logger = createTuiLogger();
5295
+ var activeStatusMgr = null;
5296
+ var activeRenderer = null;
5273
5297
  var SORT_MODES = [
5274
5298
  { key: "best-match", label: "best-match" },
5275
5299
  { key: "stars", label: "stars" },
5276
5300
  { key: "updated", label: "updated" },
5277
5301
  { key: "forks", label: "forks" }
5278
5302
  ];
5279
- async function launchBrowser(theme_override) {
5303
+ async function launchBrowser(theme_override, startMode) {
5280
5304
  const terminalBg = await detectTerminalBackground();
5281
5305
  let renderer;
5282
5306
  try {
@@ -5312,6 +5336,7 @@ async function launchBrowser(theme_override) {
5312
5336
  return;
5313
5337
  }
5314
5338
  const root = renderer.root;
5339
+ activeRenderer = renderer;
5315
5340
  root.flexDirection = "column";
5316
5341
  const config = loadConfig();
5317
5342
  let githubToken = config.githubToken || process.env.GITHUB_TOKEN;
@@ -5677,6 +5702,7 @@ async function launchBrowser(theme_override) {
5677
5702
  leaderBox.visible = false;
5678
5703
  leaderDim.visible = false;
5679
5704
  readmeBox.visible = false;
5705
+ orgBox.visible = false;
5680
5706
  updateBox.visible = false;
5681
5707
  updateDim.visible = false;
5682
5708
  if (type === "none") {
@@ -6761,6 +6787,7 @@ async function launchBrowser(theme_override) {
6761
6787
  }
6762
6788
  });
6763
6789
  const orgText = new TextRenderable5(renderer, {
6790
+ ...NON_INTERACTIVE_TEXT,
6764
6791
  content: "",
6765
6792
  fg: colors.text,
6766
6793
  bg: colors.bg
@@ -6768,6 +6795,7 @@ async function launchBrowser(theme_override) {
6768
6795
  orgScroll.add(orgText);
6769
6796
  orgBox.add(orgScroll);
6770
6797
  const orgFooter = new TextRenderable5(renderer, {
6798
+ ...NON_INTERACTIVE_TEXT,
6771
6799
  content: " \u2191\u2193/jk scroll Esc/q close",
6772
6800
  fg: colors.muted,
6773
6801
  bg: colors.bg,
@@ -6941,10 +6969,12 @@ async function launchBrowser(theme_override) {
6941
6969
  menuButtonLabel.fg = colors.muted;
6942
6970
  renderer.requestRender();
6943
6971
  };
6944
- const statusMgr = new StatusManager((text) => {
6945
- statusBar.content = ` ${text}`;
6946
- renderer.requestRender();
6947
- });
6972
+ activeStatusMgr = createStatusManager(
6973
+ statusBar,
6974
+ () => renderer.requestRender()
6975
+ );
6976
+ const statusMgr = activeStatusMgr;
6977
+ renderer.on("destroy", () => statusMgr.dispose());
6948
6978
  setTimeout(() => {
6949
6979
  if (currentOverlay === "none") {
6950
6980
  setStatus(nextTip());
@@ -8037,7 +8067,12 @@ ${pack.description ?? ""}`;
8037
8067
  }
8038
8068
  });
8039
8069
  const _currentVersion = getVersion();
8040
- showLanding(true);
8070
+ if (startMode === "trending") {
8071
+ showLanding(false);
8072
+ loadTrending();
8073
+ } else {
8074
+ showLanding(true);
8075
+ }
8041
8076
  checkForUpdateAndShow();
8042
8077
  checkPostUpgradePanel();
8043
8078
  renderer.start();
@@ -8054,10 +8089,17 @@ function formatToolbar(sort, limit, totalCount) {
8054
8089
  return ` sort: ${sortLabel} limit: ${limit} results: ${totalCount.toLocaleString()}`;
8055
8090
  }
8056
8091
  function cleanup() {
8092
+ activeStatusMgr?.dispose();
8093
+ activeStatusMgr = null;
8057
8094
  try {
8058
8095
  rotateHistory();
8059
8096
  } catch {
8060
8097
  }
8098
+ try {
8099
+ activeRenderer?.destroy();
8100
+ } catch {
8101
+ }
8102
+ activeRenderer = null;
8061
8103
  process.exit(0);
8062
8104
  }
8063
8105
  if (import.meta.main && false) {
@@ -8729,7 +8771,7 @@ var TOOLS = [
8729
8771
  token: resolveToken(state, args.token),
8730
8772
  limit
8731
8773
  });
8732
- return { text: formatUserJson(profile), data: { ...profile } };
8774
+ return { text: formatUserText(profile), data: { ...profile } };
8733
8775
  }
8734
8776
  },
8735
8777
  {
@@ -9076,6 +9118,7 @@ var SORT_VALUES = [
9076
9118
  "downloads",
9077
9119
  "name"
9078
9120
  ];
9121
+ var SINCE_VALUES = ["daily", "weekly", "monthly"];
9079
9122
  function parseArgs(args) {
9080
9123
  const flags = {
9081
9124
  query: "",
@@ -9088,6 +9131,7 @@ function parseArgs(args) {
9088
9131
  token: process.env.GITHUB_TOKEN,
9089
9132
  trending: false,
9090
9133
  since: "daily",
9134
+ sinceExplicit: false,
9091
9135
  watch: false,
9092
9136
  releases: false,
9093
9137
  interval: 300,
@@ -9188,15 +9232,51 @@ function parseArgs(args) {
9188
9232
  case "--token":
9189
9233
  flags.token = args[++i];
9190
9234
  break;
9191
- case "--since":
9192
- flags.since = args[++i];
9235
+ case "--interval": {
9236
+ const parsed = parseInt(args[++i], 10);
9237
+ if (Number.isNaN(parsed) || parsed < 1) {
9238
+ console.error(
9239
+ `Invalid interval: ${args[i] ?? "(missing)"} (must be a whole number of seconds, e.g. 300).`
9240
+ );
9241
+ process.exit(1);
9242
+ }
9243
+ flags.interval = parsed;
9244
+ break;
9245
+ }
9246
+ case "--since": {
9247
+ const since = args[++i];
9248
+ if (!since || !SINCE_VALUES.includes(since)) {
9249
+ console.error(
9250
+ `Invalid since: ${since ?? "(missing)"} (use daily|weekly|monthly).`
9251
+ );
9252
+ process.exit(1);
9253
+ }
9254
+ flags.since = since;
9255
+ flags.sinceExplicit = true;
9193
9256
  break;
9194
- case "--pipe":
9195
- flags.pipe = args[++i];
9257
+ }
9258
+ case "--pipe": {
9259
+ const target = args[++i];
9260
+ if (!target || !PIPE_TARGETS.includes(target)) {
9261
+ console.error(
9262
+ `Invalid pipe target: ${target ?? "(missing)"} (use open|clone).`
9263
+ );
9264
+ process.exit(1);
9265
+ }
9266
+ flags.pipe = target;
9196
9267
  break;
9197
- case "--format":
9198
- flags.format = args[++i];
9268
+ }
9269
+ case "--format": {
9270
+ const fmt = args[++i];
9271
+ if (!fmt || !FORMAT_VALUES.includes(fmt)) {
9272
+ console.error(
9273
+ `Invalid format: ${fmt ?? "(missing)"} (use ${FORMAT_VALUES.join("|")}).`
9274
+ );
9275
+ process.exit(1);
9276
+ }
9277
+ flags.format = fmt;
9199
9278
  break;
9279
+ }
9200
9280
  case "--registry":
9201
9281
  flags.registry = args[++i] ?? "npm";
9202
9282
  break;
@@ -9485,7 +9565,13 @@ Options:
9485
9565
  await runNonInteractive(flags, outputFormat);
9486
9566
  return;
9487
9567
  }
9488
- await launchBrowser(flags.theme);
9568
+ await launchBrowser(
9569
+ flags.theme,
9570
+ // `ghfind --trending` with no query/format promises the trending view
9571
+ // (TRACKER.md), but the bare flag used to fall through to the landing
9572
+ // menu. An explicit flag is explicit intent — skip the menu.
9573
+ flags.trending ? "trending" : void 0
9574
+ );
9489
9575
  }
9490
9576
  function buildSearchContext(flags) {
9491
9577
  const parsed = applyFlagFilters(parseQuery(flags.query), {});
@@ -9950,7 +10036,8 @@ async function runNonInteractive(flags, outputFormat) {
9950
10036
  limit: flags.limit,
9951
10037
  token: flags.token,
9952
10038
  intervalMs: flags.interval * 1e3,
9953
- trending: flags.trending
10039
+ trending: flags.trending,
10040
+ since: flags.sinceExplicit ? flags.since : void 0
9954
10041
  },
9955
10042
  (repos, delta) => {
9956
10043
  tick++;
@@ -10038,6 +10125,16 @@ Not found: ${missing.join(", ")}` : "";
10038
10125
  }
10039
10126
  };
10040
10127
  const key = flags.compare.length > 0 ? "compare" : flags.trending && !flags.watch ? "trending" : flags.watch ? "watch" : flags.pipe ? "pipe" : flags.format ? "format" : "search";
10128
+ const needsQuery = key === "search" || key === "format" || key === "pipe" || key === "watch" && !flags.trending;
10129
+ if (needsQuery && flags.query.trim() === "") {
10130
+ console.error("\u26A0 No search query given.");
10131
+ console.error(
10132
+ ' Usage: ghfind "<query>" [--count|--format <fmt>|--pipe <tgt>|--watch]'
10133
+ );
10134
+ console.error(' Example: ghfind "language:Rust stars:>500" --count');
10135
+ console.error(" Run `ghfind --help` for all options.");
10136
+ process.exit(1);
10137
+ }
10041
10138
  const ctx = buildSearchContext(flags);
10042
10139
  await handlers[key](ctx);
10043
10140
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-search-cli",
3
- "version": "9.10.0",
3
+ "version": "9.10.1",
4
4
  "description": "Search GitHub from your terminal — repo search, trending repos, npm package search, bookmarks, deep-dive, compare, export. Keyboard + mouse TUI + pipeable CLI.",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1",
@@ -0,0 +1,59 @@
1
+ # v9.10.1
2
+
3
+ A bug-fix release, found by crawling the whole source against its own docs.
4
+ Three of the eight issues were the kind that quietly cost you time: a flag
5
+ that was documented everywhere but never parsed, a typo that opened a GitHub
6
+ issue tab instead of printing an error, and an empty query that came back as
7
+ a network problem you didn't have.
8
+
9
+ ## `--interval` was never parsed
10
+
11
+ `--interval` appeared in `--help`, the README, the how-to guide and all
12
+ three shell completions. `parseArgs` had no case for it. So
13
+ `ghfind --watch --interval 60` searched for the literal string
14
+ `"--interval 60"` and polled at the default 300s anyway. It now parses a
15
+ positive whole number of seconds — and rejects anything else with a message
16
+ instead of a mangled query.
17
+
18
+ ## A typo used to open a GitHub issue tab
19
+
20
+ `--format jsonn` threw `Unknown format` from inside the run, which the top
21
+ level error handler classified as unexpected: it printed "ghfind encountered
22
+ an unexpected error" and opened a prefilled GitHub issue page in your
23
+ browser. `--pipe clonee` was the opposite failure — it did nothing at all
24
+ and exited 0.
25
+
26
+ Both flags now validate up front, print the list of valid values, and exit 1
27
+ before any search happens.
28
+
29
+ ## An empty query was a network error
30
+
31
+ `ghfind --count` with no query sent `q=" "` to GitHub, got a 422 back, and
32
+ surfaced it as `⚠ Network error — check your connection. Retrying in 5s...`
33
+ — a connectivity problem you didn't have, on a loop. The search, format,
34
+ pipe and watch handlers now fail fast with a usage hint instead.
35
+
36
+ ## The rest
37
+
38
+ - **Bare `ghfind --trending` opens the trending view.** It used to fall
39
+ through to the landing menu, contradicting TRACKER.md.
40
+ - **`--since` validates its value** (`daily|weekly|monthly`) instead of
41
+ silently falling back to daily — the same treatment `--sort` already had.
42
+ - **Watch mode honors `--since`.** The trending period was derived from the
43
+ query string, so `--watch --trending --since weekly` polled daily. An
44
+ explicit `--since` wins; the query-string convention still works when the
45
+ flag is absent.
46
+ - **The MCP `ghfind_user_profile` tool returns formatted text**, matching
47
+ `ghfind_org_profile` and the CLI's default `ghfind user` output — the raw
48
+ JSON still rides along in `data`.
49
+ - **The trending footer matches its `/` key** (`/ top` — it scrolls to the
50
+ top, it never opened search), and the quit paths of the main TUI, trending
51
+ and package browser now call `renderer.destroy()` before exiting, so the
52
+ terminal is released properly.
53
+
54
+ ## Removed
55
+
56
+ Dead code with no importers: `src/search-state.ts` (still referenced the
57
+ pre-unification `config.githubTokens`), `src/export.ts` and `src/pipe.ts`
58
+ (byte-for-byte duplicates of `src/output.ts`, which is what everything
59
+ imports), and the unused `historyCount()` export.
package/src/cli.ts CHANGED
@@ -25,6 +25,8 @@ import { reportError } from "./error-report";
25
25
  import {
26
26
  format as formatOutput,
27
27
  pipeExec,
28
+ FORMAT_VALUES,
29
+ PIPE_TARGETS,
28
30
  type Format,
29
31
  type ExportFormat,
30
32
  } from "./output";
@@ -104,6 +106,8 @@ interface CLIFlags {
104
106
  token?: string;
105
107
  trending: boolean;
106
108
  since: string;
109
+ /** True when --since was passed (watch falls back to the query-string convention otherwise). */
110
+ sinceExplicit: boolean;
107
111
  pipe?: string;
108
112
  format?: string;
109
113
  watch: boolean;
@@ -156,6 +160,9 @@ const SORT_VALUES = [
156
160
  "name",
157
161
  ];
158
162
 
163
+ /** Valid `--since` values — the periods github.com/trending accepts. */
164
+ const SINCE_VALUES: readonly string[] = ["daily", "weekly", "monthly"];
165
+
159
166
  function parseArgs(args: string[]): CLIFlags {
160
167
  const flags: CLIFlags = {
161
168
  query: "",
@@ -168,6 +175,7 @@ function parseArgs(args: string[]): CLIFlags {
168
175
  token: process.env.GITHUB_TOKEN,
169
176
  trending: false,
170
177
  since: "daily",
178
+ sinceExplicit: false,
171
179
  watch: false,
172
180
  releases: false,
173
181
  interval: 300,
@@ -276,15 +284,51 @@ function parseArgs(args: string[]): CLIFlags {
276
284
  case "--token":
277
285
  flags.token = args[++i];
278
286
  break;
279
- case "--since":
280
- flags.since = args[++i];
287
+ case "--interval": {
288
+ const parsed = parseInt(args[++i], 10);
289
+ if (Number.isNaN(parsed) || parsed < 1) {
290
+ console.error(
291
+ `Invalid interval: ${args[i] ?? "(missing)"} (must be a whole number of seconds, e.g. 300).`,
292
+ );
293
+ process.exit(1);
294
+ }
295
+ flags.interval = parsed;
281
296
  break;
282
- case "--pipe":
283
- flags.pipe = args[++i];
297
+ }
298
+ case "--since": {
299
+ const since = args[++i];
300
+ if (!since || !SINCE_VALUES.includes(since)) {
301
+ console.error(
302
+ `Invalid since: ${since ?? "(missing)"} (use daily|weekly|monthly).`,
303
+ );
304
+ process.exit(1);
305
+ }
306
+ flags.since = since;
307
+ flags.sinceExplicit = true;
284
308
  break;
285
- case "--format":
286
- flags.format = args[++i];
309
+ }
310
+ case "--pipe": {
311
+ const target = args[++i];
312
+ if (!target || !PIPE_TARGETS.includes(target)) {
313
+ console.error(
314
+ `Invalid pipe target: ${target ?? "(missing)"} (use open|clone).`,
315
+ );
316
+ process.exit(1);
317
+ }
318
+ flags.pipe = target;
319
+ break;
320
+ }
321
+ case "--format": {
322
+ const fmt = args[++i];
323
+ if (!fmt || !FORMAT_VALUES.includes(fmt)) {
324
+ console.error(
325
+ `Invalid format: ${fmt ?? "(missing)"} (use ${FORMAT_VALUES.join("|")}).`,
326
+ );
327
+ process.exit(1);
328
+ }
329
+ flags.format = fmt;
287
330
  break;
331
+ }
288
332
  case "--registry":
289
333
  flags.registry = args[++i] ?? "npm";
290
334
  break;
@@ -636,7 +680,13 @@ Options:
636
680
  return;
637
681
  }
638
682
 
639
- await launchBrowser(flags.theme);
683
+ await launchBrowser(
684
+ flags.theme,
685
+ // `ghfind --trending` with no query/format promises the trending view
686
+ // (TRACKER.md), but the bare flag used to fall through to the landing
687
+ // menu. An explicit flag is explicit intent — skip the menu.
688
+ flags.trending ? "trending" : undefined,
689
+ );
640
690
  }
641
691
 
642
692
  interface SearchContext {
@@ -1197,6 +1247,7 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
1197
1247
  token: flags.token,
1198
1248
  intervalMs: flags.interval * 1000,
1199
1249
  trending: flags.trending,
1250
+ since: flags.sinceExplicit ? flags.since : undefined,
1200
1251
  },
1201
1252
  (repos, delta) => {
1202
1253
  tick++;
@@ -1298,6 +1349,25 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
1298
1349
  ? "format"
1299
1350
  : "search";
1300
1351
 
1352
+ // The search/format/pipe/watch handlers all need a query. Without this
1353
+ // gate an empty query was sent to GitHub as `q=" "`, which returns 422 and
1354
+ // surfaced to the user as a misleading "Network error — check your
1355
+ // connection" retry loop.
1356
+ const needsQuery =
1357
+ key === "search" ||
1358
+ key === "format" ||
1359
+ key === "pipe" ||
1360
+ (key === "watch" && !flags.trending);
1361
+ if (needsQuery && flags.query.trim() === "") {
1362
+ console.error("⚠ No search query given.");
1363
+ console.error(
1364
+ ' Usage: ghfind "<query>" [--count|--format <fmt>|--pipe <tgt>|--watch]',
1365
+ );
1366
+ console.error(' Example: ghfind "language:Rust stars:>500" --count');
1367
+ console.error(" Run `ghfind --help` for all options.");
1368
+ process.exit(1);
1369
+ }
1370
+
1301
1371
  const ctx = buildSearchContext(flags);
1302
1372
  await handlers[key](ctx);
1303
1373
  }
package/src/history.ts CHANGED
@@ -29,6 +29,10 @@ export function readHistory(): HistoryEntry[] {
29
29
 
30
30
  /** Delete a single history entry by index (0 = newest). */
31
31
  export function deleteHistoryEntry(index: number): void {
32
+ // The index comes from the overlay, which lists readHistory()'s deduplicated
33
+ // newest-first view — so delete against that same view. Raw lines can't be
34
+ // addressed by this index: consecutive duplicate queries are collapsed in
35
+ // the view, which shifts every raw-line position after them.
32
36
  const entries = readHistory();
33
37
  if (index < 0 || index >= entries.length) return;
34
38
  entries.splice(index, 1);
@@ -59,8 +63,3 @@ export function rotateHistory(): void {
59
63
  .join("\n")}\n`,
60
64
  );
61
65
  }
62
-
63
- /** Count entries in history file. */
64
- export function historyCount(): number {
65
- return readJSONL<HistoryEntry>(HISTORY_FILE).length;
66
- }
package/src/mcp-server.ts CHANGED
@@ -38,7 +38,7 @@ import {
38
38
  type PackageSortMode,
39
39
  } from "./package";
40
40
  import { fetchOrgProfile, formatOrgText } from "./org";
41
- import { fetchUserProfile, formatUserJson } from "./user";
41
+ import { fetchUserProfile, formatUserText } from "./user";
42
42
  import {
43
43
  fetchDeepDive,
44
44
  buildDeepDiveText,
@@ -511,7 +511,9 @@ const TOOLS: ToolDefinition[] = [
511
511
  token: resolveToken(state, args.token),
512
512
  limit,
513
513
  });
514
- return { text: formatUserJson(profile), data: { ...profile } };
514
+ // Human-readable text like ghfind_org_profile (the structured data
515
+ // still rides along in `data` for callers that want the raw numbers).
516
+ return { text: formatUserText(profile), data: { ...profile } };
515
517
  },
516
518
  },
517
519
  {