github-search-cli 9.8.3 → 9.10.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/docs/HOW-TO.md ADDED
@@ -0,0 +1,255 @@
1
+ # How to use ghfind
2
+
3
+ A task-oriented guide. For the flag and keybinding _reference_, see
4
+ [`README.md`](../README.md) and `ghfind --help`; this page is about **getting a
5
+ good result out of a query**, and what to do when one goes wrong.
6
+
7
+ - [Set up a token first](#set-up-a-token-first)
8
+ - [Write a query](#write-a-query)
9
+ - [Qualifier reference](#qualifier-reference)
10
+ - [Read the results](#read-the-results)
11
+ - [When a search returns nothing](#when-a-search-returns-nothing)
12
+ - [When something goes wrong](#when-something-goes-wrong)
13
+ - [Recipes](#recipes)
14
+ - [Environment variables](#environment-variables)
15
+
16
+ ---
17
+
18
+ ## Set up a token first
19
+
20
+ Without a token GitHub allows **60 requests/hour** from your IP. With one you
21
+ get **5,000/hour**. A TUI session that pages through results will exhaust 60
22
+ fast, so a token is the difference between a usable session and a session that
23
+ stops halfway through.
24
+
25
+ ```bash
26
+ ghfind login # interactive: paste a token, saved to config
27
+ ```
28
+
29
+ Or set it for one shell session:
30
+
31
+ ```bash
32
+ export GITHUB_TOKEN=ghp_yourtoken
33
+ ```
34
+
35
+ Create a token at **GitHub → Settings → Developer settings → Personal access
36
+ tokens**. For searching public repositories no scopes are required; a
37
+ fine-grained token with no extra permissions is fine. Confirm it took effect:
38
+
39
+ ```bash
40
+ ghfind --doctor
41
+ ```
42
+
43
+ A token is optional — everything below works without one, just with a much
44
+ smaller budget.
45
+
46
+ ---
47
+
48
+ ## Write a query
49
+
50
+ A query is free text plus optional `qualifier:value` filters:
51
+
52
+ ```bash
53
+ ghfind "terminal http client language:Rust"
54
+ ```
55
+
56
+ Three things to know:
57
+
58
+ **1. Typos in qualifiers are corrected, not rejected.** `langauge:Rust`,
59
+ `star:1000` and `push:>2024-01-01` all work — ghfix maps them to `language:`,
60
+ `stars:` and `pushed:`. Only qualifiers ghfind knows are corrected, so a
61
+ qualifier it has never heard of (`is:`, `has:`, `props.*`) is passed through
62
+ untouched rather than mangled into something else.
63
+
64
+ **2. Counts accept `k` and `m`.** `stars:10k` means 10,000 or more. Without a
65
+ suffix, a bare number means _exactly_ that number — `stars:100` matches repos
66
+ with exactly 100 stars, which is rarely what you want, so prefer `stars:>=100`
67
+ or `stars:10k`.
68
+
69
+ ```bash
70
+ ghfind "cli stars:10k" # 10,000 or more
71
+ ghfind "cli stars:>=10000" # identical
72
+ ghfind "cli stars:1k..10k" # between 1,000 and 10,000
73
+ ```
74
+
75
+ **3. Quote multi-word phrases.** Unquoted, every word is a separate term:
76
+
77
+ ```bash
78
+ ghfind '"machine learning" language:Python' # the phrase
79
+ ghfind 'machine learning language:Python' # both words, anywhere
80
+ ```
81
+
82
+ Prefix any qualifier with `-` to exclude it:
83
+
84
+ ```bash
85
+ ghfind "cli topic:cli -language:JavaScript"
86
+ ```
87
+
88
+ ---
89
+
90
+ ## Qualifier reference
91
+
92
+ | Qualifier | Example | Notes |
93
+ | ------------- | -------------------------- | ----------------------------------------- |
94
+ | `language:` | `language:Rust` | |
95
+ | `stars:` | `stars:10k`, `stars:>=500` | count, `k`/`m` sugar, `a..b` range |
96
+ | `forks:` | `forks:>100` | count |
97
+ | `topics:` | `topics:>3` | number of topics a repo has |
98
+ | `followers:` | `followers:>=10000` | count |
99
+ | `size:` | `size:<1000` | kilobytes |
100
+ | `fork:` | `fork:true`, `fork:only` | `fork` is a boolean, `forks` is a count |
101
+ | `archived:` | `archived:false` | boolean |
102
+ | `topic:` | `topic:machine-learning` | one topic (repeat for more) |
103
+ | `user:` | `user:torvalds` | repos owned by a user |
104
+ | `org:` | `org:rust-lang` | repos owned by an organization |
105
+ | `repo:` | `repo:facebook/react` | one exact repository |
106
+ | `in:` | `in:name,description` | `name`, `description`, `topics`, `readme` |
107
+ | `pushed:` | `pushed:>2024-01-01` | `YYYY-MM-DD` |
108
+ | `created:` | `created:>2020-01-01` | `YYYY-MM-DD` |
109
+ | `updated:` | `updated:>2024-06-01` | `YYYY-MM-DD` |
110
+ | `license:` | `license:apache-2.0` | SPDX-ish keyword |
111
+ | `visibility:` | `visibility:public` | `public`, `private`, `internal` |
112
+
113
+ An invalid value is rejected **before** the request is sent, with the fix in the
114
+ message:
115
+
116
+ ```console
117
+ $ ghfind "cli stars:abc"
118
+ ⚠ Invalid query: "stars:abc" is not a number — use stars:100, stars:>=100, or stars:1k..10k
119
+ Try: cli
120
+ ```
121
+
122
+ ---
123
+
124
+ ## Read the results
125
+
126
+ Results are shown in the TUI. In the CLI, `--json` / `--csv` / `--markdown`
127
+ give you the same data in a form you can pipe:
128
+
129
+ ```bash
130
+ ghfind "cli language:Rust" --json | jq -r '.[].fullName'
131
+ ghfind "cli language:Rust" --json | jq 'length' # how many came back
132
+ ghfind "cli language:Rust" --markdown # for pasting into an issue
133
+ ```
134
+
135
+ Sort changes the order, not the query:
136
+
137
+ ```bash
138
+ ghfind "cli" --sort stars # most-starred
139
+ ghfind "cli" --sort updated # most recently touched
140
+ ghfind "cli" --sort forks
141
+ ghfind "cli" --sort best-match # GitHub's own relevance order
142
+ ```
143
+
144
+ In the TUI, `Esc` opens the leader menu (or click the <kbd>☰ menu</kbd> button in
145
+ the bottom-right corner), where **Sort** cycles the same strategies without
146
+ leaving the results.
147
+
148
+ ---
149
+
150
+ ## When a search returns nothing
151
+
152
+ ghfind ranks the qualifiers by how likely each is to be what killed the
153
+ result, and suggests a loosened query rather than a generic "try again":
154
+
155
+ ```console
156
+ 🔍 No results for "rust stars:>=5000000".
157
+ Try: rust stars:>=500000
158
+ ```
159
+
160
+ Each suggestion rewrites one qualifier instead of dropping it, so the retry is
161
+ still a recognisable version of your question. The order reflects how sharply
162
+ each filter narrows things: a star or size threshold empties a result set
163
+ outright, so those are loosened first and a `language:` filter — which matches
164
+ plenty on its own — is touched last.
165
+
166
+ If a suggestion still returns nothing, the qualifier you most care about is
167
+ probably `repo:`, `user:` or `org:` — those restrict to a single account.
168
+
169
+ ---
170
+
171
+ ## When something goes wrong
172
+
173
+ | Message | Meaning | Fix |
174
+ | ------------------------------------ | -------------------------------------------- | ---------------------------------------------------------------------------------------------- |
175
+ | `API rate limit exceeded (60/hr)` | No token, or the hourly budget is spent | `ghfind login`, or wait for the reset |
176
+ | `API rate limit exceeded (5,000/hr)` | Authenticated budget spent | Wait, or `c` in the TUI to swap tokens |
177
+ | `Bad GitHub token (401)` | Token rejected — usually revoked or mistyped | `t` in the TUI, or `ghfind login` |
178
+ | `Forbidden (403)` | Authenticated but not allowed | Read the reason in the message; often SSO not authorizing the token, or a secondary rate limit |
179
+ | `Network error` | Genuine connectivity or a 5xx | Retry with `r` |
180
+
181
+ While one of these is showing in the TUI status bar, these keys work:
182
+
183
+ | Key | Action |
184
+ | --- | ----------------------------------------------------- |
185
+ | `r` | Retry |
186
+ | `t` | Fix the token inline (saves it and re-runs the query) |
187
+ | `u` | Drop the token and retry unauthenticated |
188
+ | `c` | Swap in a different token (rate limit) |
189
+
190
+ **To see the actual reason behind an error**, set `GHFIND_LOG` — the TUI owns
191
+ the screen and cannot print diagnostics into it, so it writes them to a file:
192
+
193
+ ```bash
194
+ GHFIND_LOG=/tmp/ghfind.log ghfind
195
+ # then quit with q and read the log
196
+ ```
197
+
198
+ A `403` includes the response body, which names the real cause; token values
199
+ are redacted from logged URLs.
200
+
201
+ ---
202
+
203
+ ## Recipes
204
+
205
+ ```bash
206
+ # Popular, actively maintained Rust CLI tools
207
+ ghfind "cli language:Rust stars:10k -archived:true"
208
+
209
+ # Something big and recently touched by a specific org
210
+ ghfind "org:rust-lang pushed:>2025-01-01"
211
+
212
+ # Exactly this repository
213
+ ghfind "repo:facebook/react"
214
+
215
+ # Topic + size ceiling (small, focused libraries)
216
+ ghfind "topic:parser size:<500 stars:>=100"
217
+
218
+ # Find the name of a project you half-remember
219
+ ghfind "json schema validator in:name"
220
+
221
+ # Just how many repos match (no output formatting)
222
+ ghfind "topic:cli language:Rust" --count
223
+
224
+ # Top 5 by stars, as CSV
225
+ ghfind "topic:cli" --sort stars --limit 5 --csv
226
+
227
+ # Watch a query for new results
228
+ ghfind "topic:ai language:Rust" --watch --interval 300
229
+
230
+ # Trending instead of searching
231
+ ghfind --trending --since weekly
232
+ ghfind --trending rust --count
233
+
234
+ # Pipe into another tool
235
+ ghfind "cli language:Rust" --json | jq -r '.[].url'
236
+ ```
237
+
238
+ ---
239
+
240
+ ## Environment variables
241
+
242
+ | Variable | Effect |
243
+ | ----------------- | ------------------------------------------------------- |
244
+ | `GITHUB_TOKEN` | Raises the rate limit from 60/hr to 5,000/hr |
245
+ | `GHFIND_CONFIG` | Config file path, overriding the XDG lookup |
246
+ | `GHFIND_LOG` | Write TUI diagnostics to this file |
247
+ | `NO_COLOR` | Disables colored `--doctor` output |
248
+ | `XDG_CONFIG_HOME` | Relocates `~/.config` (config lives at `<dir>/ghfind/`) |
249
+ | `XDG_STATE_HOME` | Relocates history/bookmarks/session state |
250
+
251
+ `GITHUB_TOKEN` takes precedence over a token saved in the config file.
252
+
253
+ See [`README.md`](../README.md) for the full flag, subcommand and keybinding
254
+ reference, [`CONTRIBUTING.md`](../CONTRIBUTING.md) for development commands, and
255
+ [`CHANGELOG.md`](../CHANGELOG.md) for what changed in each release.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "github-search-cli",
3
- "version": "9.8.3",
4
- "description": "Search GitHub from your terminal — repo search, trending repos, npm package search, bookmarks, deep-dive, compare, export. Keyboard-driven TUI + pipeable CLI.",
3
+ "version": "9.10.0",
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",
7
7
  "volta": {
@@ -21,6 +21,7 @@
21
21
  "src/",
22
22
  "scripts/",
23
23
  "completions/",
24
+ "docs/",
24
25
  "demo.svg",
25
26
  "LICENSE",
26
27
  "CHANGELOG.md",
@@ -0,0 +1,137 @@
1
+ # v9.10.0
2
+
3
+ ghfind's TUI has always been keyboard-only — and, quietly, half-broken for the
4
+ mouse. OpenTUI was already emitting SGR mouse tracking and hit-testing every
5
+ event into a target renderable. Nothing in ghfind consumed any of it. The
6
+ events were parsed, dispatched, and thrown away. This release makes the TUI
7
+ clickable, and fixes the two keyboard rough edges that were hiding behind it.
8
+
9
+ ## The TUI is now fully clickable
10
+
11
+ Click a result and the detail pane updates. Click it again and your browser
12
+ opens. Scroll to move through results. Click outside an overlay to close it.
13
+
14
+ Every list, card, tab and button responds: the results list, the landing mode
15
+ cards, trending tabs, the update dialog's buttons, and every overlay list
16
+ (history, bookmarks, saved searches, topics, export, share, and the command
17
+ menu itself).
18
+
19
+ The important detail is that clicks do not reimplement anything. A click calls
20
+ `setSelectedIndex`, which emits the same `selectionChanged` event `↑`/`↓`
21
+ already emitted, so the detail pane, the detail graph and the packages view all
22
+ work identically without a parallel code path. A double-click calls the same
23
+ activation path as `Enter`. There is no mouse branch to keep in sync — the
24
+ keyboard and the mouse converge on one implementation, which is also why they
25
+ cannot drift.
26
+
27
+ ## The wheel scrolled the wrong pane
28
+
29
+ Worth calling out, because it predates this release and was a real bug rather
30
+ than a missing feature.
31
+
32
+ When a scroll event hit nothing, OpenTUI falls back to delivering it to
33
+ whatever renderable currently holds focus. In ghfind that was almost always the
34
+ results list. So opening the README viewer and scrolling would move the
35
+ _result selection_ instead of the README — the one thing you were trying to do.
36
+
37
+ Every scrollable region now claims the wheel explicitly, and the list's wheel
38
+ handler steps the selection through `moveUp`/`moveDown`, so wheel navigation
39
+ and `↑`/`↓` navigation cannot diverge either.
40
+
41
+ ## Text that swallowed clicks
42
+
43
+ `TextRenderable` defaults to OpenTUI's `selectable: true`, and a left-press on
44
+ a selectable renderable is consumed as a text-selection drag _before_ the app's
45
+ own handlers run. The detail pane, comparison table, tab bar and other read-only
46
+ text are all `TextRenderable`s, so clicks landing on them did nothing at all.
47
+
48
+ They now opt out of text selection. The query input deliberately keeps it, so
49
+ clicking into the input still places your cursor.
50
+
51
+ ## A hover highlight that painted the list magenta
52
+
53
+ Small one, but it would have been immediately obvious to anyone using it.
54
+ OpenTUI's colour parser accepts `"transparent"`, CSS colour names, and 3/4/6/8
55
+ digit hex — nothing else. An `rgba(255,255,255,0.06)` hover tint failed that
56
+ check and fell through to its magenta fallback, so hovering a list would have
57
+ flashed it magenta. Now written as `#ffffff0f`.
58
+
59
+ Found because the test harness prints that warning. Worth noting the class of
60
+ bug: OpenTUI degrades an invalid colour to magenta rather than throwing, so this
61
+ fails silently and visibly at the same time.
62
+
63
+ ## `Space` opened the menu only if you had clicked something first
64
+
65
+ The subtlest problem in this release, and it was not caused by mouse support —
66
+ it was exposed by it.
67
+
68
+ The query input is focused by default. The command menu was bound to `Space`,
69
+ gated on the input _not_ being focused, so that spaces could still be typed into
70
+ a query. That gate meant `Space` opened the menu only when focus had moved
71
+ somewhere else. The one way focus moved was clicking a result row: OpenTUI's
72
+ auto-focus-on-press walks up from the pressed renderable to the nearest
73
+ focusable ancestor, finds the results list, and focuses it — blurring the input
74
+ as a side effect.
75
+
76
+ So on a fresh launch, `Space` typed a space. Click any row, and `Space` opened
77
+ the menu. Same key, same screen, different behaviour depending on invisible
78
+ state.
79
+
80
+ A regression test now pins this down from both directions: with `keepFocus` a row
81
+ click does not move focus, and without it one does.
82
+
83
+ The resolution is that **`Esc` opens the command menu**, and `Space` always
84
+ types. `Esc` already closed every overlay, so this adds a meaning rather than
85
+ replacing one. For anyone who prefers not to reach for `Esc`, there is now a
86
+ <kbd>☰ menu</kbd> button in the bottom-right corner — clickable, it floats above
87
+ the layout, and it hides while an overlay is open so it never sits on top of one.
88
+
89
+ ## Added
90
+
91
+ - Full mouse support: click to select, double-click to open, wheel to scroll,
92
+ click-outside to dismiss, hover feedback with a pointer cursor.
93
+ - <kbd>☰ menu</kbd> button in the bottom-right corner to open the command menu.
94
+ - A "Mouse" section in the in-app help, documenting every binding.
95
+ - README keybinding tables gained a mouse table; the command-menu references
96
+ across the README and `docs/HOW-TO.md` now say `Esc` instead of `Space`.
97
+
98
+ ## Fixed
99
+
100
+ - The scroll wheel scrolled the focused renderable rather than the pane under
101
+ the pointer, so long-pane scrolling moved the result selection instead.
102
+ - Non-interactive text consumed left-presses as text-selection drags, so clicks
103
+ over the detail pane and comparison table did nothing.
104
+ - The hover tint used an `rgba()` string that OpenTUI's colour parser rejects,
105
+ rendering the list magenta.
106
+ - `Space` could not open the command menu on a fresh launch, because it was
107
+ gated on focus that only moved after a mouse click.
108
+
109
+ ## Verification
110
+
111
+ The suite grew by 33 tests, to 657.
112
+
113
+ - **15 unit tests** on the click→row geometry: scroll offsets, multi-line rows
114
+ (overlay lists render a description line, so each option is two lines tall),
115
+ `itemSpacing`, clicks past the last row, unmeasured renderables, and empty
116
+ lists. OpenTUI keeps `scrollOffset` and `linesPerItem` private, so this
117
+ arithmetic is read through a single narrow cast — the tests are what keep that
118
+ cast honest.
119
+ - **12 integration tests** driving the real handlers with OpenTUI's mock mouse
120
+ against a live renderer: click-selects, double-click-opens, the double-click
121
+ window expiring, click-outside dismissal, row clicks _not_ leaking to the
122
+ enclosing panel, focus behaviour with and without `keepFocus`, and wheel
123
+ stepping including both ends of the list.
124
+ - **4 button tests** asserting the corner button actually lands in the
125
+ bottom-right, that hidden buttons neither render nor take clicks, and that its
126
+ click does not bubble.
127
+
128
+ The first two geometry tests failed on the run that wrote them, and caught a
129
+ real off-by-one: the empty area below a short list returned "no row" instead of
130
+ resolving to the last row, which would have dismissed an overlay instead of
131
+ selecting its final entry. That is the exact class of bug that would have been
132
+ very annoying and very hard to report.
133
+
134
+ `test`, `format:check`, `lint` (Biome) and `typecheck` (tsc) are all green, and
135
+ `bun run build` succeeds. The TUI was also driven end-to-end in a pty to confirm
136
+ the menu opens from both `Esc` and the corner button, since the mock mouse does
137
+ not exercise a real terminal's mouse reporting.
@@ -0,0 +1,193 @@
1
+ # v9.9.0
2
+
3
+ ghfind used to hand your query straight to GitHub: whatever you typed was
4
+ string-concatenated into an API URL. A misspelled qualifier or a malformed
5
+ value became an opaque `422` from GitHub, or — worse — a **"network error"**
6
+ naming a connectivity problem you did not have. This release makes ghfind
7
+ _understand_ the query before spending a request on it, and adds a
8
+ task-oriented guide for using it.
9
+
10
+ ## A typo in a qualifier no longer ruins the search
11
+
12
+ `matchQualifier` accepted any word before a colon as a qualifier key, so
13
+ `langauge:Rust` was not treated as the typo it was — it was forwarded verbatim
14
+ and GitHub rejected it with an error that named nothing useful. Now:
15
+
16
+ ```console
17
+ $ ghfind "cli langauge:Rust stars:10k"
18
+ # runs as: cli language:Rust stars:>=10000
19
+ ```
20
+
21
+ Misspellings are corrected two ways: a table of common ones (`langauge`,
22
+ `star`, `licence`, `push`), plus a bounded edit-distance pass that catches the
23
+ rest (`starss` → `stars`).
24
+
25
+ **The correction is deliberately narrow.** It only touches the qualifiers
26
+ ghfind knows. `is:public`, `has:funding-file` and `props.environment:prod` are
27
+ valid GitHub syntax that ghfind has no business rewriting — and two real bugs
28
+ came from trying:
29
+
30
+ - `is` sits one edit from the known qualifier `in`, so an unguarded fuzzy pass
31
+ turned `is:public` into `in:public`, which validation then rejected. Fuzzy
32
+ correction is now skipped for keys under four characters.
33
+ - Ambiguous input (`sizet`) is left alone rather than rewritten arbitrarily —
34
+ with two candidates equally close there is not enough signal to guess.
35
+
36
+ `props.*` had the opposite problem: custom-property keys contain a dot, which
37
+ the parser's key charset rejected, so `props.environment:production` silently
38
+ degraded into a literal keyword. The charset now allows one dot.
39
+
40
+ ## A bad value fails before the request, naming the fix
41
+
42
+ Every qualifier now has a value rule — numeric counts for `stars`/`forks`/
43
+ `size`/`topics`/`followers`, booleans for `archived`, `true|false|only` for
44
+ `fork`, ISO8601 dates for `created`/`pushed`/`updated`, the `visibility:`
45
+ enum, and the `in:` searchable fields:
46
+
47
+ ```console
48
+ $ ghfind "cli stars:abc"
49
+ ⚠ Invalid query: "stars:abc" is not a number — use stars:100, stars:>=100, or stars:1k..10k
50
+ Try: cli
51
+ ```
52
+
53
+ Dates are checked against the calendar, not just the shape — `2024-02-31` and
54
+ `2023-02-29` are rejected, which `Date.parse` would have quietly rolled over.
55
+ Documented comma-lists like `in:name,description` and the `fork:only` form are
56
+ accepted, because rejecting a valid form is worse than passing a bad one
57
+ through.
58
+
59
+ ## One gate, enforced everywhere
60
+
61
+ `validateQuery` existed but was called by **the TUI only**. The CLI, the MCP
62
+ server and `--watch` all skipped it, so the single rule it enforced
63
+ (`visibility:` + `private:`) applied in one of four places. All four now go
64
+ through it, and for agents a malformed query returns a readable message with
65
+ `invalidQuery: true` rather than a tool failure that reads as breakage.
66
+
67
+ `--watch` validates before its poll loop starts: a malformed query is not
68
+ transient, so retrying it five times just printed the same error five times.
69
+
70
+ ## Counts: `10k` works, and `100` still means exactly 100
71
+
72
+ `stars:10k` → `stars:>=10000`, `size:1m` → `size:>=1000000`,
73
+ `stars:1k..10k` → `stars:1000..10000`.
74
+
75
+ A bare number is left alone, and that is the subtle part: GitHub reads
76
+ `stars:100` as _exactly_ 100 stars. Rewriting it to `>=100` — which a first
77
+ pass here did — silently changes the question being asked. Verified live:
78
+
79
+ stars:100 language:Rust -> 118 repos
80
+ stars:>=10000 language:Rust -> 256 repos
81
+
82
+ ## Zero results now come with a way out
83
+
84
+ Previously: _"check spelling, try fewer qualifiers, or browse trending."_
85
+ Now ghfind ranks the qualifiers by how sharply each one narrows the corpus and
86
+ offers a concrete retry:
87
+
88
+ ```console
89
+ 🔍 No results for "rust stars:>=5000000".
90
+ Try: rust stars:>=500000
91
+ ```
92
+
93
+ A star or size threshold empties a result set outright, so those are loosened
94
+ first; a plain `language:` filter matches plenty on its own, so it is touched
95
+ last. Each suggestion rewrites one qualifier rather than dropping it, keeping
96
+ the retry a recognisable version of the original question. A negated filter is
97
+ never suggested for removal — it can only widen a search, so it cannot be what
98
+ emptied it.
99
+
100
+ ## A 403 no longer claims SSO is the cause
101
+
102
+ Found while testing this: the TUI showed `Forbidden (403). If your org
103
+ enforces SSO…` and nothing else. Three unrelated conditions produce a 403 with
104
+ rate-limit budget remaining — SSO not authorizing the token, a secondary/abuse
105
+ rate limit, and org resource restrictions — and the message asserted the first
106
+ one unconditionally. For someone who was actually being secondary-rate-limited,
107
+ that sent them to fix a credential problem that did not exist.
108
+
109
+ The message now reports GitHub's own explanation and names both plausible
110
+ causes. To see which one it is, `GHFIND_LOG=<path>` writes diagnostics to a
111
+ file — the TUI owns the screen and cannot print them into it, and its logger
112
+ used to be a silent no-op, which is why this was undiagnosable in the first
113
+ place:
114
+
115
+ ```
116
+ [debug] [github] request url: https://api.github.com/search/repositories?q=cli
117
+ [error] [github] non-rate-limit 403: remaining=42 retry-after=60
118
+ x-ratelimit-reset=1790859999 authed=no url=…
119
+ [error] [github] 403 body: {"message":"Resource not accessible by integration"}
120
+ ```
121
+
122
+ Unset by default, so it costs nothing in normal use; token values are redacted
123
+ from logged URLs.
124
+
125
+ ## The post-upgrade panel never went away
126
+
127
+ The panel that says `Updated ghfind 9.8.3 → 9.9.0` is triggered by a single
128
+ condition: `lastInstalledVersion !== currentVersion`. Nothing in the codebase
129
+ ever wrote `lastInstalledVersion` back after showing the panel, so the
130
+ condition stayed true forever:
131
+
132
+ launch 1 after upgrade: panel shown
133
+ launch 2: panel shown again
134
+ launch 3: panel shown again
135
+ …
136
+
137
+ Worse, dismissing it did not help. **later** writes `snoozeUntil` and **never**
138
+ writes `suppressed` — neither touches `lastInstalledVersion`, and the
139
+ post-upgrade panel's guard only checks `suppressed`. So a user who explicitly
140
+ told ghfind to stop notifying them still got the panel on every launch.
141
+
142
+ The panel now acknowledges itself once shown. Two guards keep that honest: a
143
+ recorded version _newer_ than the running one is left alone (so a rolled-back
144
+ install, or an update that landed mid-session, is still reported), and the
145
+ write is best-effort so a read-only state dir cannot block the panel.
146
+
147
+ Verified across a simulated six-launch sequence:
148
+
149
+ panel per launch: [true, false, false, false, false, false]
150
+
151
+ ## A prerelease was being advertised as an upgrade
152
+
153
+ `isNewerVersion` prefers `Bun.semver` and falls back to hand-rolled comparison
154
+ for Node and the compiled binaries. The fallback dropped the pre-release suffix
155
+ but compared nothing else about it, so `9.9.0` → `9.9.0-beta.1` reported
156
+ `true` — offering a **downgrade** as an update. Build metadata had a related
157
+ bug: `9.9.0+build.5` parsed `Number("0+build")` as `NaN`, silently becoming
158
+ `0`.
159
+
160
+ Comparison now follows npm ordering. A prerelease counts as an upgrade only when
161
+ its numeric core is genuinely ahead; within the same core, only a
162
+ prerelease → release move counts; build metadata is stripped before parsing.
163
+ The Bun path was already correct and is untouched.
164
+
165
+ ## `forks` was silently the wrong qualifier
166
+
167
+ `KNOWN_QUALIFIERS` listed `fork` but not `forks`. So `forks:100` fell through
168
+ to fuzzy matching, got rewritten to `fork:100`, and skipped numeric validation
169
+ entirely — `forks:abc` validated clean and reached GitHub. `forks`, `topics`
170
+ and `followers` are now known, and `fork` (a boolean) stays distinct from
171
+ `forks` (a count).
172
+
173
+ ## A how-to guide
174
+
175
+ `docs/HOW-TO.md` covers the task-shaped material the README does not: getting a
176
+ token in place first (60/hr → 5,000/hr is the difference between a TUI session
177
+ that works and one that dies mid-scroll), how to write a query, a qualifier
178
+ table, what to read in the results, what to do about an error, and copy-paste
179
+ recipes. Its claims are pinned by tests so the page cannot drift from the code.
180
+
181
+ ## Verification
182
+
183
+ The suite grew by 110 tests: 82 for query parsing and validation, 2 pinning the
184
+ 403 diagnostics, 11 keeping the how-to page honest, and 15 on self-update. The
185
+ new tests cover each qualifier value rule, `k`/`m` normalization and the
186
+ exact-match guarantee, misspelling correction and unknown-qualifier
187
+ passthrough, keyword-order and `raw` preservation, the relaxation ranking, the
188
+ 403 diagnostics — including that `remaining: 0` still routes to
189
+ `RateLimitError` rather than `ForbiddenError` — and the update panel firing
190
+ exactly once across repeated launches.
191
+
192
+ `format:check`, `typecheck` (tsc) and `lint` (Biome) all green. Every example
193
+ in the how-to was executed against the live API rather than written from memory.
package/src/cli.ts CHANGED
@@ -6,6 +6,8 @@ import {
6
6
  rankRepos,
7
7
  createGitHubSearch,
8
8
  createTrendingSearch,
9
+ trendingQuery,
10
+ validateQuery,
9
11
  } from "./search";
10
12
  import {
11
13
  createPackageSearch,
@@ -645,6 +647,9 @@ interface SearchContext {
645
647
 
646
648
  function buildSearchContext(flags: CLIFlags): SearchContext {
647
649
  const parsed = applyFlagFilters(parseQuery(flags.query), {});
650
+ // Validate before the context is built so every handler (search, pipe,
651
+ // format, watch) inherits the same gate — previously only the TUI checked.
652
+ validateQuery(parsed);
648
653
  return {
649
654
  query: flags.query,
650
655
  parsed,
@@ -1154,17 +1159,14 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
1154
1159
  console.error(err instanceof Error ? err.message : String(err));
1155
1160
  process.exit(1);
1156
1161
  }
1157
- const response = await trendingSearch.search(
1158
- { keywords: [], qualifiers: [], raw: "trending" },
1159
- {
1160
- limit: 25,
1161
- sort: "stars",
1162
- json: false,
1163
- verbose: false,
1164
- trendingSince: since,
1165
- ...(language ? { trendingLanguage: language } : {}),
1166
- },
1167
- );
1162
+ const response = await trendingSearch.search(trendingQuery(), {
1163
+ limit: 25,
1164
+ sort: "stars",
1165
+ json: false,
1166
+ verbose: false,
1167
+ trendingSince: since,
1168
+ ...(language ? { trendingLanguage: language } : {}),
1169
+ });
1168
1170
  if (outputFormat) {
1169
1171
  console.log(formatOutput(response.repos, outputFormat));
1170
1172
  } else if (flags.count) {