github-search-cli 9.8.3 → 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.3",
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,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) {
package/src/errors.ts CHANGED
@@ -34,15 +34,21 @@ export class AuthError extends SearchCliError {
34
34
  }
35
35
 
36
36
  /**
37
- * HTTP 403 — authenticated but not allowed (SAML enforcement, resource
38
- * restrictions, secondary limits). Not the same as the rate-limit 403,
39
- * which is detected by an exhausted x-ratelimit-remaining header first.
37
+ * HTTP 403 that is NOT a primary rate limit (i.e. `x-ratelimit-remaining`
38
+ * was greater than zero). Three unrelated causes land here and the status
39
+ * code cannot tell them apart:
40
+ * - the token's org enforces SAML SSO and has not authorized it
41
+ * - a secondary/abuse rate limit (concurrent or rapid-fire requests)
42
+ * - a valid token blocked by org or resource restrictions
43
+ * The old message asserted SSO unconditionally, which sent people to fix a
44
+ * credential problem that did not exist. Check GHFIND_LOG for the body.
40
45
  */
41
46
  export class ForbiddenError extends SearchCliError {
42
- constructor() {
47
+ constructor(public readonly detail: string = "GitHub API returned 403") {
43
48
  const msg =
44
- "⚠ Forbidden (403). If your org enforces SSO, authorize the token; [t] to change it";
45
- super("GitHub API returned 403 (token lacks access)", msg);
49
+ `⚠ Forbidden (403) — ${detail}. Common causes: org SSO not authorizing the token, ` +
50
+ `or a secondary rate limit. Press [t] to change the token`;
51
+ super(msg, msg);
46
52
  this.name = "ForbiddenError";
47
53
  }
48
54
  }
@@ -61,17 +67,35 @@ export class RateLimitError extends SearchCliError {
61
67
  }
62
68
 
63
69
  export class BadQueryError extends SearchCliError {
64
- constructor(public readonly detail: string) {
65
- super(`Invalid query: ${detail}`, `⚠ Invalid query: ${detail}`);
70
+ /** A concrete, corrected query the user can retry with. */
71
+ constructor(
72
+ public readonly detail: string,
73
+ public readonly hint?: string,
74
+ ) {
75
+ super(
76
+ `Invalid query: ${detail}`,
77
+ hint
78
+ ? `⚠ Invalid query: ${detail}\n Try: ${hint}`
79
+ : `⚠ Invalid query: ${detail}`,
80
+ );
66
81
  this.name = "BadQueryError";
67
82
  }
68
83
  }
69
84
 
70
85
  export class NoResultsError extends SearchCliError {
71
- constructor(public readonly query: string) {
86
+ /** Concrete loosened queries to retry, most promising first. */
87
+ constructor(
88
+ public readonly query: string,
89
+ public readonly relaxations: string[] = [],
90
+ ) {
91
+ const tips = relaxations.length
92
+ ? relaxations.map((r) => `Try: ${r}`).join("\n ")
93
+ : "Tips: check spelling, try fewer qualifiers, or [b]rowse trending";
72
94
  super(
73
- `No results for "${query}"`,
74
- `🔍 No results for "${query}". Tips: check spelling, try fewer qualifiers, or [b]rowse trending`,
95
+ relaxations.length > 0
96
+ ? `No results for "${query}" — try loosening: ${relaxations.join(" / ")}`
97
+ : `No results for "${query}"`,
98
+ `🔍 No results for "${query}".\n ${tips}`,
75
99
  );
76
100
  this.name = "NoResultsError";
77
101
  }
package/src/mcp-server.ts CHANGED
@@ -29,6 +29,8 @@ import {
29
29
  createGitHubSearch,
30
30
  createTrendingSearch,
31
31
  resolveTrendingLanguage,
32
+ trendingQuery,
33
+ validateQuery,
32
34
  } from "./search";
33
35
  import {
34
36
  createPackageSearch,
@@ -55,7 +57,7 @@ import {
55
57
  type SkillSource,
56
58
  } from "./skill-finder";
57
59
  import { getVersion } from "./version";
58
- import { RateLimitError } from "./errors";
60
+ import { RateLimitError, BadQueryError } from "./errors";
59
61
 
60
62
  // ─── Protocol constants ────────────────────────────────────────────────
61
63
 
@@ -215,7 +217,7 @@ function asMcpToolError(err: unknown): McpToolError {
215
217
  return new McpToolError(err instanceof Error ? err.message : String(err));
216
218
  }
217
219
 
218
- const TRENDING_QUERY = { keywords: [], qualifiers: [], raw: "trending" };
220
+ const TRENDING_QUERY = trendingQuery();
219
221
 
220
222
  // ─── Tool registry ─────────────────────────────────────────────────────
221
223
 
@@ -271,6 +273,20 @@ const TOOLS: ToolDefinition[] = [
271
273
 
272
274
  const provider = createGitHubSearch(undefined, token ? [token] : []);
273
275
  const parsed = parseQuery(query);
276
+ // Reject a malformed qualifier here rather than spending an API call:
277
+ // for an agent, GitHub's opaque 422 reads as breakage, whereas the
278
+ // specific message names the bad token and the fix.
279
+ try {
280
+ validateQuery(parsed);
281
+ } catch (err) {
282
+ if (err instanceof BadQueryError) {
283
+ return {
284
+ text: `Invalid query: ${err.detail}${err.hint ? ` Try: ${err.hint}` : ""}`,
285
+ data: { totalCount: 0, repos: [], invalidQuery: true },
286
+ };
287
+ }
288
+ throw err;
289
+ }
274
290
  const options: SearchOptions = {
275
291
  limit,
276
292
  sort,
@@ -14,6 +14,7 @@ import {
14
14
  createGitHubSearch,
15
15
  SearchModule,
16
16
  TrendingAdapter,
17
+ trendingQuery,
17
18
  } from "./search";
18
19
  import { restoreSession, saveSession } from "./session";
19
20
  import { appendHistory } from "./history";
@@ -155,16 +156,13 @@ export class SearchStateManager {
155
156
  this.state.trendingTab = tab;
156
157
  this.state.currentMode = "trending";
157
158
  try {
158
- const response = await this.trendingSearch.search(
159
- { keywords: [], qualifiers: [], raw: "trending" },
160
- {
161
- limit: 25,
162
- sort: "stars",
163
- json: false,
164
- verbose: false,
165
- trendingSince: tabSince(tab),
166
- },
167
- );
159
+ const response = await this.trendingSearch.search(trendingQuery(), {
160
+ limit: 25,
161
+ sort: "stars",
162
+ json: false,
163
+ verbose: false,
164
+ trendingSince: tabSince(tab),
165
+ });
168
166
  this.state.currentRepos = response.repos;
169
167
  this.state.totalCount = response.totalCount;
170
168
  appendHistory({