github-search-cli 9.8.2 → 9.9.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,254 @@
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, `Space` opens the leader menu, where **Sort** cycles the same
145
+ strategies without leaving the results.
146
+
147
+ ---
148
+
149
+ ## When a search returns nothing
150
+
151
+ ghfind ranks the qualifiers by how likely each is to be what killed the
152
+ result, and suggests a loosened query rather than a generic "try again":
153
+
154
+ ```console
155
+ 🔍 No results for "rust stars:>=5000000".
156
+ Try: rust stars:>=500000
157
+ ```
158
+
159
+ Each suggestion rewrites one qualifier instead of dropping it, so the retry is
160
+ still a recognisable version of your question. The order reflects how sharply
161
+ each filter narrows things: a star or size threshold empties a result set
162
+ outright, so those are loosened first and a `language:` filter — which matches
163
+ plenty on its own — is touched last.
164
+
165
+ If a suggestion still returns nothing, the qualifier you most care about is
166
+ probably `repo:`, `user:` or `org:` — those restrict to a single account.
167
+
168
+ ---
169
+
170
+ ## When something goes wrong
171
+
172
+ | Message | Meaning | Fix |
173
+ | ------------------------------------ | -------------------------------------------- | ---------------------------------------------------------------------------------------------- |
174
+ | `API rate limit exceeded (60/hr)` | No token, or the hourly budget is spent | `ghfind login`, or wait for the reset |
175
+ | `API rate limit exceeded (5,000/hr)` | Authenticated budget spent | Wait, or `c` in the TUI to swap tokens |
176
+ | `Bad GitHub token (401)` | Token rejected — usually revoked or mistyped | `t` in the TUI, or `ghfind login` |
177
+ | `Forbidden (403)` | Authenticated but not allowed | Read the reason in the message; often SSO not authorizing the token, or a secondary rate limit |
178
+ | `Network error` | Genuine connectivity or a 5xx | Retry with `r` |
179
+
180
+ While one of these is showing in the TUI status bar, these keys work:
181
+
182
+ | Key | Action |
183
+ | --- | ----------------------------------------------------- |
184
+ | `r` | Retry |
185
+ | `t` | Fix the token inline (saves it and re-runs the query) |
186
+ | `u` | Drop the token and retry unauthenticated |
187
+ | `c` | Swap in a different token (rate limit) |
188
+
189
+ **To see the actual reason behind an error**, set `GHFIND_LOG` — the TUI owns
190
+ the screen and cannot print diagnostics into it, so it writes them to a file:
191
+
192
+ ```bash
193
+ GHFIND_LOG=/tmp/ghfind.log ghfind
194
+ # then quit with q and read the log
195
+ ```
196
+
197
+ A `403` includes the response body, which names the real cause; token values
198
+ are redacted from logged URLs.
199
+
200
+ ---
201
+
202
+ ## Recipes
203
+
204
+ ```bash
205
+ # Popular, actively maintained Rust CLI tools
206
+ ghfind "cli language:Rust stars:10k -archived:true"
207
+
208
+ # Something big and recently touched by a specific org
209
+ ghfind "org:rust-lang pushed:>2025-01-01"
210
+
211
+ # Exactly this repository
212
+ ghfind "repo:facebook/react"
213
+
214
+ # Topic + size ceiling (small, focused libraries)
215
+ ghfind "topic:parser size:<500 stars:>=100"
216
+
217
+ # Find the name of a project you half-remember
218
+ ghfind "json schema validator in:name"
219
+
220
+ # Just how many repos match (no output formatting)
221
+ ghfind "topic:cli language:Rust" --count
222
+
223
+ # Top 5 by stars, as CSV
224
+ ghfind "topic:cli" --sort stars --limit 5 --csv
225
+
226
+ # Watch a query for new results
227
+ ghfind "topic:ai language:Rust" --watch --interval 300
228
+
229
+ # Trending instead of searching
230
+ ghfind --trending --since weekly
231
+ ghfind --trending rust --count
232
+
233
+ # Pipe into another tool
234
+ ghfind "cli language:Rust" --json | jq -r '.[].url'
235
+ ```
236
+
237
+ ---
238
+
239
+ ## Environment variables
240
+
241
+ | Variable | Effect |
242
+ | ----------------- | ------------------------------------------------------- |
243
+ | `GITHUB_TOKEN` | Raises the rate limit from 60/hr to 5,000/hr |
244
+ | `GHFIND_CONFIG` | Config file path, overriding the XDG lookup |
245
+ | `GHFIND_LOG` | Write TUI diagnostics to this file |
246
+ | `NO_COLOR` | Disables colored `--doctor` output |
247
+ | `XDG_CONFIG_HOME` | Relocates `~/.config` (config lives at `<dir>/ghfind/`) |
248
+ | `XDG_STATE_HOME` | Relocates history/bookmarks/session state |
249
+
250
+ `GITHUB_TOKEN` takes precedence over a token saved in the config file.
251
+
252
+ See [`README.md`](../README.md) for the full flag, subcommand and keybinding
253
+ reference, [`CONTRIBUTING.md`](../CONTRIBUTING.md) for development commands, and
254
+ [`CHANGELOG.md`](../CHANGELOG.md) for what changed in each release.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-search-cli",
3
- "version": "9.8.2",
3
+ "version": "9.9.0",
4
4
  "description": "Search GitHub from your terminal — repo search, trending repos, npm package search, bookmarks, deep-dive, compare, export. Keyboard-driven TUI + pipeable CLI.",
5
5
  "type": "module",
6
6
  "packageManager": "bun@1",
@@ -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,98 @@
1
+ # v9.8.3
2
+
3
+ Three things went wrong often enough to be worth a release: GitHub told you
4
+ "Network error" for problems that had nothing to do with your network, you had
5
+ to leave the TUI to fix a bad token, and a session file could hijack startup.
6
+ The README image viewer also gained the last two cases from the 9.8.1 notice.
7
+
8
+ ## Errors now say what actually happened
9
+
10
+ A rejected token and an authenticated-but-forbidden request were both reported
11
+ as `Network error`, which named a connectivity problem you did not have. Now:
12
+
13
+ - `401` — the token was rejected: **"Bad GitHub token (401)"**
14
+ - `403` that is not the rate-limit case — e.g. SAML/SSO enforcement:
15
+ **"Forbidden (403)"**, naming the SSO case
16
+ - `403` with `x-ratelimit-remaining: 0` — a rate limit, as before
17
+ - `5xx` and genuine connectivity failures — still a network error
18
+
19
+ A rate-limited search also used to _swallow_ the error and render as a silent,
20
+ successful "no repositories found". The real message now reaches the status
21
+ bar. For agents, the `ghfind_search_repos` MCP tool converts the error into the
22
+ structured flag it already had — `rateLimited: true` plus backoff advice —
23
+ because a tool failure reads as breakage while the flag reads as retry-later.
24
+
25
+ ## Recover from errors without leaving the TUI
26
+
27
+ While the matching error is in the status bar:
28
+
29
+ | Key | Action |
30
+ | --- | --------------------------------------------------------------- |
31
+ | `t` | Inline token prompt — saves to config, re-runs the failed query |
32
+ | `u` | Clear the token and retry unauthenticated (401) |
33
+ | `c` | Swap in a different token (rate limit) |
34
+ | `r` | Retry the search or trending load |
35
+
36
+ `Esc` cancels the prompt. The keys are scoped to that one error — any new
37
+ search retires them, so `t` and `c` go back to cycling the theme and opening
38
+ compare. Documented in the help overlay and the README.
39
+
40
+ ## A corrupted session no longer hijacks startup
41
+
42
+ A `session.json` whose query field held an MCP handshake blob (a client piped
43
+ its `initialize` request into the search box) made every launch skip the landing
44
+ menu and auto-run that garbage as a search. Two layers now:
45
+
46
+ - Startup always shows the landing menu. A saved session restores preferences
47
+ (sort, limit, trending tab) and pre-fills the query when you pick Repo
48
+ Search, but never runs a search on its own.
49
+ - Save and restore both go through `sanitizeSession()`. JSON-shaped or
50
+ oversized queries are dropped; a non-restorable mode, bad sort, `NaN` limit
51
+ or empty tab fall back to defaults. Reading from disk runs the same check as
52
+ writing, so an existing corrupt file cannot do it again.
53
+
54
+ ## Images wrapped in links render now
55
+
56
+ The common badge-and-screenshot shape — `[![alt](shot.png)](https://example.com)`
57
+ — rendered nothing at all. The markdown token walk treated any block containing
58
+ a link token as prose and discarded it, so `sst/opencode` lost the only image in
59
+ its README and `withastro/astro` lost 23 of its 25. The walk now recurses into
60
+ link tokens: the image renders, and its cells carry an OSC 8 hyperlink to the
61
+ original target, so clicking the image opens the same URL the prose version
62
+ would have.
63
+
64
+ ## A README full of small images no longer floods the screen
65
+
66
+ `openclaw/openclaw` has 661 `<img>` tags in its README, 648 of them 48×48
67
+ sponsor avatars. Each rendered at 48 columns × 24 rows — a full screen per
68
+ avatar — so opening the README downloaded 649 images (1.6 MB) and produced
69
+ 15,552 rows of output, in 28 seconds.
70
+
71
+ Images the README declares smaller than 100 px on any edge are now filtered
72
+ while parsing, before a single request is made. Images that declare no size
73
+ (marked `![]()` syntax) are measured from the image header after download —
74
+ `Bun.Image.metadata()` reads only the header, no pixel decode — and rejected
75
+ before rasterising. Measured on the same README:
76
+
77
+ image urls fetched: 649 -> 1
78
+ wall time: 28,318 ms -> ~550 ms
79
+
80
+ The trade-off is deliberate: a README that understates an image's declared size
81
+ hides that image (two of openclaw's avatars declare `width="48"` but are
82
+ 420×420). The README is treated as the authority on its own layout; the
83
+ alternative — trusting the image over the README — is exactly what made these
84
+ pages unusable.
85
+
86
+ ## Verification
87
+
88
+ 516 tests (up from 482), plus `format:check`, `typecheck` and `lint` all green.
89
+ New tests pin the HTTP status → error mapping end to end, the session
90
+ sanitizers and the TUI startup contract, the link-recursion extraction
91
+ (including prose-links staying prose and nested links taking the innermost
92
+ href), the declared-size reader (percentages like `width="100%"` are ignored,
93
+ `px` suffixes accepted), the header-only pixel gate, and end-to-end view
94
+ behaviour: a linked-image block builds an image node instead of prose,
95
+ declared-small images make no request, and undeclared images still reach the
96
+ loader.
97
+
98
+ `README.md` Known-limitations section rewritten to match; test counts synced.
@@ -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) {