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/CHANGELOG.md +72 -0
- package/README.md +32 -10
- package/dist/cli.js +543 -67
- package/docs/HOW-TO.md +254 -0
- package/package.json +2 -1
- package/release-notes/9.9.0.md +193 -0
- package/src/cli.ts +13 -11
- package/src/errors.ts +35 -11
- package/src/mcp-server.ts +18 -2
- package/src/search-state.ts +8 -10
- package/src/search.ts +683 -11
- package/src/trending.ts +2 -6
- package/src/tui.ts +55 -17
- package/src/types.ts +11 -1
- package/src/update-check.ts +72 -6
- package/src/watch.ts +16 -11
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.
|
|
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
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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 =
|
|
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,
|
package/src/search-state.ts
CHANGED
|
@@ -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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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({
|