github-search-cli 9.10.3 → 9.11.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 +18 -0
- package/README.md +11 -13
- package/completions/ghfind.bash +2 -2
- package/completions/ghfind.fish +3 -1
- package/completions/ghfind.zsh +3 -0
- package/dist/cli.js +193 -28
- package/docs/HOW-TO.md +8 -0
- package/package.json +1 -1
- package/release-notes/9.11.0.md +50 -0
- package/src/agent-skill.ts +7 -1
- package/src/cli.ts +73 -0
- package/src/clone.ts +129 -0
- package/src/doctor.ts +20 -0
- package/src/help.ts +1 -0
- package/src/tui.ts +51 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,24 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Publish
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## [9.11.0] — 2026-10-14 (released)
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`ghfind clone <owner/repo>` — clone a repository to disk.** Cloning was
|
|
14
|
+
previously only reachable as a command you copy: `--pipe clone` and
|
|
15
|
+
`--format clone-commands` print `git clone …` lines. This runs it. The ref is
|
|
16
|
+
validated with the same `parseRepoRef` the other repo subcommands use, the
|
|
17
|
+
repo lands in `<dir>/<name>` (default: the cwd), and HTTPS is the default
|
|
18
|
+
transport — `--ssh` switches to `git@github.com`, `--dir <path>` moves the
|
|
19
|
+
destination root. No GitHub API call is made, so a clone is never blocked by
|
|
20
|
+
the 60/hr unauthenticated budget, and git's own output and exit code are
|
|
21
|
+
passed through untouched. The TUI gets the same action under
|
|
22
|
+
`Esc` → Repo → Clone: git runs in the background with its output captured for
|
|
23
|
+
`GHFIND_LOG`, and the status bar carries git's own diagnosis, because the TUI
|
|
24
|
+
owns the screen and cannot render a progress bar or a credential prompt.
|
|
25
|
+
`ghfind --doctor` now reports whether git is on PATH.
|
|
26
|
+
|
|
9
27
|
## [9.10.3] — 2026-10-14 (released)
|
|
10
28
|
|
|
11
29
|
### Fixed
|
package/README.md
CHANGED
|
@@ -87,6 +87,7 @@ ghfind "query" --count # count only
|
|
|
87
87
|
ghfind "query" --format urls # one URL per line
|
|
88
88
|
ghfind "query" --pipe open # open in browser
|
|
89
89
|
ghfind "query" --pipe clone # clone commands
|
|
90
|
+
ghfind clone facebook/react # clone the repo into ./react (--dir, --ssh)
|
|
90
91
|
ghfind --trending --json # trending as JSON
|
|
91
92
|
ghfind trending rust --json # trending, filtered by language
|
|
92
93
|
ghfind --watch "query" # poll every 300s
|
|
@@ -192,6 +193,7 @@ ghfind --completion fish | source
|
|
|
192
193
|
<tr><td><b>Skill search</b></td><td><code>ghfind skill search <query></code> — find installed skills, or <code>--remote</code> for the skills.sh ecosystem (also <code>ghfind_skill_search</code> over MCP)</td></tr>
|
|
193
194
|
<tr><td><b>Read-only state</b></td><td><code>ghfind bookmarks|history|saved|topics</code> — print local state and popular topics without modifying anything</td></tr>
|
|
194
195
|
<tr><td><b>README & share</b></td><td><code>ghfind readme a/b</code> and <code>ghfind share a/b --as gh-cli</code> — the TUI's <code>r</code> / <code>y</code> actions from the shell</td></tr>
|
|
196
|
+
<tr><td><b>Clone</b></td><td>Menu → Clone, or <code>ghfind clone a/b</code> — clone the repo to disk (HTTPS, or <code>--ssh</code>; <code>--dir</code> picks the destination)</td></tr>
|
|
195
197
|
</table>
|
|
196
198
|
|
|
197
199
|
<details>
|
|
@@ -261,6 +263,7 @@ Actions shown depend on current mode (search vs trending) and selection:
|
|
|
261
263
|
| Compare | Add/remove repo to comparison set |
|
|
262
264
|
| Compare view | Show side-by-side comparison table |
|
|
263
265
|
| Open in browser | Open selected repo URL |
|
|
266
|
+
| Clone | Clone the repo to disk (into the cwd) |
|
|
264
267
|
| History | Recall past searches |
|
|
265
268
|
| Saved searches | Load a saved search |
|
|
266
269
|
| Save search | Save current query |
|
|
@@ -348,14 +351,14 @@ cd ghfind
|
|
|
348
351
|
# With Bun (recommended for TUI)
|
|
349
352
|
bun install
|
|
350
353
|
bun start # TUI
|
|
351
|
-
bun test #
|
|
354
|
+
bun test # 705 tests
|
|
352
355
|
bun run check # format check + tests — the green gate
|
|
353
356
|
bun run build # build dist/
|
|
354
357
|
|
|
355
358
|
# Or with npm
|
|
356
359
|
npm install
|
|
357
360
|
npm start # TUI (requires Node 20+)
|
|
358
|
-
npm test #
|
|
361
|
+
npm test # 705 tests
|
|
359
362
|
npm run build # build dist/
|
|
360
363
|
|
|
361
364
|
> Want to contribute? See [CONTRIBUTING.md](CONTRIBUTING.md) for commands,
|
|
@@ -389,6 +392,12 @@ for the architecture, measurements, and remaining work.
|
|
|
389
392
|
|
|
390
393
|
Recent releases (full history in [CHANGELOG.md](CHANGELOG.md)):
|
|
391
394
|
|
|
395
|
+
### v9.11.0
|
|
396
|
+
|
|
397
|
+
- **`ghfind clone <owner/repo>` clones to disk** — until now cloning was only a command you copy (`--pipe clone` printed `git clone …` lines); the new subcommand runs it, into `<dir>/<name>` (default: the cwd), over HTTPS or `--ssh` for the `git@github.com` form, and with no GitHub API call, so a spent unauthenticated budget can never block a clone
|
|
398
|
+
- **`Esc` → Repo → Clone in the TUI** — git runs in the background because the TUI owns the screen: full output goes to `GHFIND_LOG` and the status bar carries git's own diagnosis; `--pipe clone` keeps printing commands for scripts, and git's exit codes are passed through (128 stays 128)
|
|
399
|
+
- **`ghfind --doctor` reports whether git is on PATH** — a warning rather than a failure, since only `clone` needs it
|
|
400
|
+
|
|
392
401
|
### v9.10.3
|
|
393
402
|
|
|
394
403
|
- **The help overlay lists `Ctrl+E`** — the utility-keys section documented `t`, `Ctrl+X` and `Ctrl+C` but not the export shortcut the startup tip and the README already advertised
|
|
@@ -397,17 +406,6 @@ Recent releases (full history in [CHANGELOG.md](CHANGELOG.md)):
|
|
|
397
406
|
|
|
398
407
|
- **`Ctrl+E` opens the export chooser** — the startup tip advertised "Press Ctrl+E to export results as CSV or Markdown", but nothing bound the chord: it was ignored on the results screen and, while the query input had focus, its emacs binding read it as "move to line end"; it now opens the export overlay (JSON / CSV / Markdown / plain text), or reports that there is nothing to export yet
|
|
399
408
|
|
|
400
|
-
### v9.10.1
|
|
401
|
-
|
|
402
|
-
- **`--interval` is parsed again** — it was documented in `--help`, the README, the how-to guide and all three shell completions, but `parseArgs` had no case for it, so `ghfind --watch --interval 60` searched for the literal string `--interval 60` and polled at the default 300s anyway
|
|
403
|
-
- **A flag typo no longer opens a GitHub issue tab** — `--format jsonn` threw mid-run and routed into the crash-report flow (printing "ghfind encountered an unexpected error" and opening a prefilled issue page); `--pipe clonee` silently did nothing and exited 0; both now validate up front, list the valid values, and exit 1
|
|
404
|
-
- **An empty query fails fast instead of faking a network error** — `ghfind --count` with no query sent `q=" "`, got a 422 back, and looped on `⚠ Network error — check your connection. Retrying in 5s...`; the search/format/pipe/watch handlers now print a usage hint instead
|
|
405
|
-
- **Bare `ghfind --trending` opens the trending view** instead of falling through to the landing menu, as documented
|
|
406
|
-
- **`--since` validates `daily|weekly|monthly`** instead of silently accepting anything and falling back to daily — the same treatment `--sort` already had
|
|
407
|
-
- **`--watch --trending --since weekly` polls weekly** — the trending period used to be derived from the query string, so `--since` was ignored in watch mode
|
|
408
|
-
- **The MCP `ghfind_user_profile` tool returns formatted text**, matching `ghfind_org_profile` and the CLI's default `ghfind user` output; the raw JSON still rides along in `data`
|
|
409
|
-
- **Quit paths release the terminal** — the main TUI, the trending view and the package browser call `renderer.destroy()` before exiting, and the trending footer's `/` hint now matches the scroll-to-top key it actually binds
|
|
410
|
-
|
|
411
409
|
## License
|
|
412
410
|
|
|
413
411
|
MIT
|
package/completions/ghfind.bash
CHANGED
|
@@ -7,7 +7,7 @@ _ghfind() {
|
|
|
7
7
|
cur="${COMP_WORDS[COMP_CWORD]}"
|
|
8
8
|
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
|
9
9
|
|
|
10
|
-
opts="--json --csv --markdown --count --limit --sort --token --trending --language --since --pipe --format --registry --watch --interval --version --help --completion --remote --local --copy --raw --as pkg login init doctor org user mcp skill bookmarks history topics saved readme share trending deep-dive compare"
|
|
10
|
+
opts="--json --csv --markdown --count --limit --sort --token --trending --language --since --pipe --format --registry --watch --interval --version --help --completion --remote --local --copy --raw --as --dir --ssh pkg login init doctor org user mcp skill bookmarks history topics saved readme share clone trending deep-dive compare"
|
|
11
11
|
|
|
12
12
|
case "${prev}" in
|
|
13
13
|
--sort)
|
|
@@ -42,7 +42,7 @@ _ghfind() {
|
|
|
42
42
|
COMPREPLY=( $(compgen -W "rust python typescript javascript go c c++ c# java ruby php swift kotlin zig" -- "${cur}") )
|
|
43
43
|
return 0
|
|
44
44
|
;;
|
|
45
|
-
--limit|--interval)
|
|
45
|
+
--limit|--interval|--dir)
|
|
46
46
|
return 0
|
|
47
47
|
;;
|
|
48
48
|
*)
|
package/completions/ghfind.fish
CHANGED
|
@@ -25,5 +25,7 @@ complete -c ghfind -l remote -d "Skill search: query skills.sh instead of this m
|
|
|
25
25
|
complete -c ghfind -l local -d "Skill search: force the local scan (default)"
|
|
26
26
|
complete -c ghfind -l copy -d "Share: also copy the snippet to the clipboard"
|
|
27
27
|
complete -c ghfind -l raw -d "Readme: print the raw markdown with no header"
|
|
28
|
+
complete -c ghfind -l dir -d "Clone: directory to clone into (default: cwd)" -r
|
|
29
|
+
complete -c ghfind -l ssh -d "Clone: clone over SSH (git@github.com)"
|
|
28
30
|
|
|
29
|
-
complete -c ghfind -f -a "login init doctor org user bookmarks history topics saved readme share pkg mcp skill trending deep-dive compare"
|
|
31
|
+
complete -c ghfind -f -a "login init doctor org user bookmarks history topics saved readme share clone pkg mcp skill trending deep-dive compare"
|
package/completions/ghfind.zsh
CHANGED
|
@@ -28,6 +28,8 @@ _ghfind() {
|
|
|
28
28
|
'--local[Skill search: force the local scan (default)]'
|
|
29
29
|
'--copy[Share: also copy the snippet to the clipboard]'
|
|
30
30
|
'--raw[Readme: print the raw markdown with no header]'
|
|
31
|
+
'--dir[Clone: directory to clone into (default: cwd)]:path:'
|
|
32
|
+
'--ssh[Clone: clone over SSH (git@github.com)]'
|
|
31
33
|
|
|
32
34
|
'login[Import a GitHub token from the gh CLI or paste one]'
|
|
33
35
|
'init[Run the setup wizard]'
|
|
@@ -40,6 +42,7 @@ _ghfind() {
|
|
|
40
42
|
'saved[List saved searches]'
|
|
41
43
|
'readme[Print a repository README]:owner/repo:'
|
|
42
44
|
'share[Print a share snippet]:owner/repo:'
|
|
45
|
+
'clone[Clone a repo to disk]:owner/repo:'
|
|
43
46
|
'pkg[Search packages]:query:'
|
|
44
47
|
'trending[Trending repos, optionally by language]'
|
|
45
48
|
'deep-dive[Repo deep-dive: languages, contributors, README]:owner/repo:'
|
package/dist/cli.js
CHANGED
|
@@ -1304,7 +1304,7 @@ async function detectTerminalBackground(timeoutMs = 400) {
|
|
|
1304
1304
|
const setRawMode = stdin.setRawMode;
|
|
1305
1305
|
if (!stdout.isTTY || !stdin.isTTY || typeof setRawMode !== "function")
|
|
1306
1306
|
return null;
|
|
1307
|
-
return new Promise((
|
|
1307
|
+
return new Promise((resolve2) => {
|
|
1308
1308
|
const wasRaw = stdin.isRaw === true;
|
|
1309
1309
|
let settled = false;
|
|
1310
1310
|
let buffer = "";
|
|
@@ -1318,7 +1318,7 @@ async function detectTerminalBackground(timeoutMs = 400) {
|
|
|
1318
1318
|
} catch {
|
|
1319
1319
|
}
|
|
1320
1320
|
stdin.pause();
|
|
1321
|
-
|
|
1321
|
+
resolve2(result);
|
|
1322
1322
|
};
|
|
1323
1323
|
const onData = (d) => {
|
|
1324
1324
|
buffer += d.toString("latin1");
|
|
@@ -1732,10 +1732,10 @@ function persistToken(token, deps) {
|
|
|
1732
1732
|
}
|
|
1733
1733
|
function createAsk() {
|
|
1734
1734
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1735
|
-
return (query) => new Promise((
|
|
1735
|
+
return (query) => new Promise((resolve2) => {
|
|
1736
1736
|
rl.question(query, (answer) => {
|
|
1737
1737
|
rl.close();
|
|
1738
|
-
|
|
1738
|
+
resolve2(answer.trim());
|
|
1739
1739
|
});
|
|
1740
1740
|
});
|
|
1741
1741
|
}
|
|
@@ -1905,6 +1905,19 @@ function checkSystemBun(bundledOk) {
|
|
|
1905
1905
|
);
|
|
1906
1906
|
}
|
|
1907
1907
|
}
|
|
1908
|
+
function checkGit() {
|
|
1909
|
+
const res = run("git", ["--version"]);
|
|
1910
|
+
const version = res.stdout.trim();
|
|
1911
|
+
if (res.status === 0 && version) {
|
|
1912
|
+
add("PASS", "git", `${version} \u2014 used by \`ghfind clone\`.`);
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
add(
|
|
1916
|
+
"WARN",
|
|
1917
|
+
"git",
|
|
1918
|
+
"not found on PATH \u2014 `ghfind clone` will fail; every other command works. Install: https://git-scm.com/downloads"
|
|
1919
|
+
);
|
|
1920
|
+
}
|
|
1908
1921
|
function checkNodeVersion() {
|
|
1909
1922
|
const major = parseInt(process.version.replace(/^v/, "").split(".")[0], 10);
|
|
1910
1923
|
if (Number.isNaN(major) || major >= 20) {
|
|
@@ -2125,6 +2138,7 @@ async function runDoctor() {
|
|
|
2125
2138
|
const bundled = checkBundledBun();
|
|
2126
2139
|
checkSystemBun(bundled.ok);
|
|
2127
2140
|
checkNodeVersion();
|
|
2141
|
+
checkGit();
|
|
2128
2142
|
checkBuildOutput();
|
|
2129
2143
|
checkSource();
|
|
2130
2144
|
checkTuiBackend();
|
|
@@ -2154,7 +2168,7 @@ var cached = null;
|
|
|
2154
2168
|
function getVersion() {
|
|
2155
2169
|
if (cached) return cached;
|
|
2156
2170
|
if (true) {
|
|
2157
|
-
cached = "9.
|
|
2171
|
+
cached = "9.11.0";
|
|
2158
2172
|
return cached;
|
|
2159
2173
|
}
|
|
2160
2174
|
try {
|
|
@@ -3176,10 +3190,10 @@ function allCachedReleases() {
|
|
|
3176
3190
|
import { createInterface as createInterface2 } from "readline";
|
|
3177
3191
|
function ask(query) {
|
|
3178
3192
|
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
3179
|
-
return new Promise((
|
|
3193
|
+
return new Promise((resolve2) => {
|
|
3180
3194
|
rl.question(query, (answer) => {
|
|
3181
3195
|
rl.close();
|
|
3182
|
-
|
|
3196
|
+
resolve2(answer.trim());
|
|
3183
3197
|
});
|
|
3184
3198
|
});
|
|
3185
3199
|
}
|
|
@@ -3452,7 +3466,7 @@ function releaseSlot() {
|
|
|
3452
3466
|
}
|
|
3453
3467
|
async function withSlot(job) {
|
|
3454
3468
|
if (activeFetches >= MAX_CONCURRENT_IMAGE_FETCHES) {
|
|
3455
|
-
await new Promise((
|
|
3469
|
+
await new Promise((resolve2) => waiting.push(resolve2));
|
|
3456
3470
|
}
|
|
3457
3471
|
activeFetches++;
|
|
3458
3472
|
try {
|
|
@@ -3836,7 +3850,7 @@ function buildDeepDiveText(data) {
|
|
|
3836
3850
|
|
|
3837
3851
|
// src/cli.ts
|
|
3838
3852
|
import { readFileSync as readFileSync8 } from "fs";
|
|
3839
|
-
import { join as
|
|
3853
|
+
import { join as join9, dirname as dirname4 } from "path";
|
|
3840
3854
|
import { fileURLToPath as fileURLToPath4 } from "url";
|
|
3841
3855
|
|
|
3842
3856
|
// src/tui.ts
|
|
@@ -4090,6 +4104,7 @@ function buildHelpSections() {
|
|
|
4090
4104
|
{ keys: "Limit", action: "Cycle limit: 10 \u2192 25 \u2192 50 \u2192 100" },
|
|
4091
4105
|
{ keys: "Refresh", action: "Re-run current search" },
|
|
4092
4106
|
{ keys: "Graph", action: "Toggle commit activity chart" },
|
|
4107
|
+
{ keys: "Clone", action: "Clone the repo to disk" },
|
|
4093
4108
|
{ keys: "Readme", action: "Full markdown README viewer" },
|
|
4094
4109
|
{ keys: "Deep-dive", action: "Languages + contributors + excerpt" },
|
|
4095
4110
|
{ keys: "Bookmark", action: "Save / unsave selected repo" },
|
|
@@ -4959,9 +4974,9 @@ async function copyToClipboard(text) {
|
|
|
4959
4974
|
});
|
|
4960
4975
|
proc.stdin.write(text);
|
|
4961
4976
|
proc.stdin.end();
|
|
4962
|
-
return new Promise((
|
|
4963
|
-
proc.on("close", (code) =>
|
|
4964
|
-
proc.on("error", () =>
|
|
4977
|
+
return new Promise((resolve2) => {
|
|
4978
|
+
proc.on("close", (code) => resolve2(code === 0));
|
|
4979
|
+
proc.on("error", () => resolve2(false));
|
|
4965
4980
|
});
|
|
4966
4981
|
} catch {
|
|
4967
4982
|
return false;
|
|
@@ -5154,9 +5169,9 @@ async function performUpdate(location = detectInstallLocation(PKG_DIR2)) {
|
|
|
5154
5169
|
}
|
|
5155
5170
|
const argv = buildUpdateCommand(location);
|
|
5156
5171
|
try {
|
|
5157
|
-
const { spawn:
|
|
5172
|
+
const { spawn: spawn5 } = await import("child_process");
|
|
5158
5173
|
const useShell = process.platform === "win32";
|
|
5159
|
-
const proc =
|
|
5174
|
+
const proc = spawn5(
|
|
5160
5175
|
argv[0],
|
|
5161
5176
|
useShell ? argv.slice(1).map(quoteWin32) : argv.slice(1),
|
|
5162
5177
|
{ stdio: ["ignore", "ignore", "pipe"], shell: useShell }
|
|
@@ -5165,9 +5180,9 @@ async function performUpdate(location = detectInstallLocation(PKG_DIR2)) {
|
|
|
5165
5180
|
proc.stderr?.on("data", (chunk) => {
|
|
5166
5181
|
stderr += String(chunk);
|
|
5167
5182
|
});
|
|
5168
|
-
const code = await new Promise((
|
|
5169
|
-
proc.on("close", (c) =>
|
|
5170
|
-
proc.on("error", () =>
|
|
5183
|
+
const code = await new Promise((resolve2) => {
|
|
5184
|
+
proc.on("close", (c) => resolve2(c));
|
|
5185
|
+
proc.on("error", () => resolve2(-1));
|
|
5171
5186
|
});
|
|
5172
5187
|
if (code !== 0) {
|
|
5173
5188
|
debugLog(`Install failed (code ${code}): ${stderr.trim()}`);
|
|
@@ -5218,6 +5233,57 @@ function fetchReleaseNotes(version) {
|
|
|
5218
5233
|
return null;
|
|
5219
5234
|
}
|
|
5220
5235
|
|
|
5236
|
+
// src/clone.ts
|
|
5237
|
+
import { spawn as spawn4 } from "child_process";
|
|
5238
|
+
import { mkdirSync as mkdirSync4 } from "fs";
|
|
5239
|
+
import { join as join7, resolve } from "path";
|
|
5240
|
+
async function cloneRepo(ref, opts = {}) {
|
|
5241
|
+
const { owner, name } = parseRepoRef(ref);
|
|
5242
|
+
const url = opts.ssh ? `git@github.com:${owner}/${name}.git` : `https://github.com/${owner}/${name}.git`;
|
|
5243
|
+
const root = resolve(opts.dir ?? process.cwd());
|
|
5244
|
+
const target = join7(root, name);
|
|
5245
|
+
mkdirSync4(root, { recursive: true });
|
|
5246
|
+
const spawnFn = opts.spawn ?? spawn4;
|
|
5247
|
+
const interactive = opts.interactive === true;
|
|
5248
|
+
return new Promise((settle) => {
|
|
5249
|
+
let output = "";
|
|
5250
|
+
const child = spawnFn("git", ["clone", url, target], {
|
|
5251
|
+
cwd: root,
|
|
5252
|
+
env: interactive ? process.env : (
|
|
5253
|
+
// The TUI has nowhere to render a prompt, so let git fail loudly
|
|
5254
|
+
// instead of blocking on a username/password nobody can type.
|
|
5255
|
+
{ ...process.env, GIT_TERMINAL_PROMPT: "0" }
|
|
5256
|
+
),
|
|
5257
|
+
stdio: interactive ? "inherit" : ["ignore", "pipe", "pipe"]
|
|
5258
|
+
});
|
|
5259
|
+
child.stdout?.on("data", (chunk) => {
|
|
5260
|
+
output += String(chunk);
|
|
5261
|
+
});
|
|
5262
|
+
child.stderr?.on("data", (chunk) => {
|
|
5263
|
+
output += String(chunk);
|
|
5264
|
+
});
|
|
5265
|
+
child.on("error", (err) => {
|
|
5266
|
+
settle({
|
|
5267
|
+
ok: false,
|
|
5268
|
+
dir: target,
|
|
5269
|
+
code: null,
|
|
5270
|
+
output: output || err.message,
|
|
5271
|
+
error: err.code === "ENOENT" ? "git-missing" : void 0
|
|
5272
|
+
});
|
|
5273
|
+
});
|
|
5274
|
+
child.on("close", (code) => {
|
|
5275
|
+
settle({ ok: code === 0, dir: target, code, output });
|
|
5276
|
+
});
|
|
5277
|
+
});
|
|
5278
|
+
}
|
|
5279
|
+
var GIT_MISSING_MESSAGE = "git not found on PATH \u2014 install git and retry.";
|
|
5280
|
+
function gitErrorLine(output, maxLength = 88) {
|
|
5281
|
+
const lines = output.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
5282
|
+
const diagnosis = [...lines].reverse().find((l) => /^(fatal|remote|error):/i.test(l));
|
|
5283
|
+
const line = diagnosis ?? lines[lines.length - 1] ?? "";
|
|
5284
|
+
return line.length > maxLength ? `${line.slice(0, maxLength - 1)}\u2026` : line;
|
|
5285
|
+
}
|
|
5286
|
+
|
|
5221
5287
|
// src/landing.ts
|
|
5222
5288
|
import { BoxRenderable as BoxRenderable4, TextRenderable as TextRenderable4 } from "@opentui/core";
|
|
5223
5289
|
function landingKeysActive(mode, overlay) {
|
|
@@ -6439,6 +6505,12 @@ async function launchBrowser(theme_override, startMode) {
|
|
|
6439
6505
|
description: "Open selected repo in browser",
|
|
6440
6506
|
action: () => openUrlIfSelected()
|
|
6441
6507
|
},
|
|
6508
|
+
{
|
|
6509
|
+
type: "action",
|
|
6510
|
+
name: "Clone",
|
|
6511
|
+
description: "Clone the repo to disk (into the cwd)",
|
|
6512
|
+
action: () => cloneSelectedRepo()
|
|
6513
|
+
},
|
|
6442
6514
|
{
|
|
6443
6515
|
type: "action",
|
|
6444
6516
|
name: "Readme",
|
|
@@ -6503,7 +6575,7 @@ async function launchBrowser(theme_override, startMode) {
|
|
|
6503
6575
|
groups.push({
|
|
6504
6576
|
type: "category",
|
|
6505
6577
|
name: "Repo",
|
|
6506
|
-
description: "Open, readme, bookmark, share, compare",
|
|
6578
|
+
description: "Open, clone, readme, bookmark, share, compare",
|
|
6507
6579
|
icon: "\u{1F4CB}",
|
|
6508
6580
|
children: repoItems
|
|
6509
6581
|
});
|
|
@@ -6697,6 +6769,37 @@ async function launchBrowser(theme_override, startMode) {
|
|
|
6697
6769
|
if (repo) openUrl(repo.url);
|
|
6698
6770
|
else setStatus("No repo selected");
|
|
6699
6771
|
}
|
|
6772
|
+
function cloneSelectedRepo() {
|
|
6773
|
+
const opt = resultsSelect.getSelectedOption();
|
|
6774
|
+
const repo = opt?.value;
|
|
6775
|
+
if (!repo) {
|
|
6776
|
+
setStatus("No repo selected");
|
|
6777
|
+
return;
|
|
6778
|
+
}
|
|
6779
|
+
showOverlay("none");
|
|
6780
|
+
setStatus(`Cloning ${repo.fullName}\u2026`);
|
|
6781
|
+
renderer.requestRender();
|
|
6782
|
+
cloneRepo(repo.fullName).then((result) => {
|
|
6783
|
+
debugLog(
|
|
6784
|
+
`ghfind clone ${repo.fullName} \u2192 ${result.dir} (exit ${result.code})
|
|
6785
|
+
${result.output}`
|
|
6786
|
+
);
|
|
6787
|
+
if (result.error === "git-missing") {
|
|
6788
|
+
setStatus(`\u2717 ${GIT_MISSING_MESSAGE}`);
|
|
6789
|
+
} else if (result.ok) {
|
|
6790
|
+
setStatus(`\u2713 Cloned ${repo.fullName} \u2192 ${result.dir}`);
|
|
6791
|
+
} else {
|
|
6792
|
+
const detail = gitErrorLine(result.output);
|
|
6793
|
+
setStatus(
|
|
6794
|
+
`\u2717 Clone failed: ${detail || `git exited ${result.code}`} \u2014 see GHFIND_LOG`
|
|
6795
|
+
);
|
|
6796
|
+
}
|
|
6797
|
+
renderer.requestRender();
|
|
6798
|
+
}).catch((err) => {
|
|
6799
|
+
setStatus(`\u2717 ${err instanceof Error ? err.message : String(err)}`);
|
|
6800
|
+
renderer.requestRender();
|
|
6801
|
+
});
|
|
6802
|
+
}
|
|
6700
6803
|
function toggleBookmarkOnSelected() {
|
|
6701
6804
|
const opt = resultsSelect.getSelectedOption();
|
|
6702
6805
|
const repo = opt?.value;
|
|
@@ -8169,7 +8272,13 @@ reported in text output and via the error message.
|
|
|
8169
8272
|
ghfind deep-dive owner/repo --json
|
|
8170
8273
|
Text output (no flag) prints the same sections formatted for the terminal.
|
|
8171
8274
|
|
|
8172
|
-
##
|
|
8275
|
+
## Clone a repository
|
|
8276
|
+
|
|
8277
|
+
ghfind clone owner/repo # into ./repo, over HTTPS
|
|
8278
|
+
ghfind clone owner/repo --dir PATH --ssh
|
|
8279
|
+
This is the one command here that writes to disk: the clone lands in
|
|
8280
|
+
PATH/<repo-name>, or the cwd when --dir is omitted. Confirm the destination
|
|
8281
|
+
first; the repo name is taken from the ref, not from the query.
|
|
8173
8282
|
ghfind skill search QUERY # skills installed on this machine
|
|
8174
8283
|
ghfind skill search QUERY --remote # the public skills.sh ecosystem
|
|
8175
8284
|
Results carry path+description (local) or install command (registry, read-only --
|
|
@@ -8236,7 +8345,7 @@ function formatSkillCatalog() {
|
|
|
8236
8345
|
// src/skill-finder.ts
|
|
8237
8346
|
import { readdirSync, readFileSync as readFileSync7 } from "fs";
|
|
8238
8347
|
import { homedir as homedir4 } from "os";
|
|
8239
|
-
import { delimiter, join as
|
|
8348
|
+
import { delimiter, join as join8 } from "path";
|
|
8240
8349
|
var REGISTRY_SEARCH_ENDPOINT = "https://skills.sh/api/search";
|
|
8241
8350
|
var MIN_QUERY_LENGTH = 2;
|
|
8242
8351
|
var MAX_SKILL_RESULTS = 100;
|
|
@@ -8254,10 +8363,10 @@ function displayPath(path) {
|
|
|
8254
8363
|
function defaultSkillRoots(cwd = process.cwd()) {
|
|
8255
8364
|
const home = homedir4();
|
|
8256
8365
|
return [
|
|
8257
|
-
|
|
8258
|
-
|
|
8259
|
-
|
|
8260
|
-
|
|
8366
|
+
join8(home, ".codex", "skills"),
|
|
8367
|
+
join8(home, ".agents", "skills"),
|
|
8368
|
+
join8(cwd, ".codex", "skills"),
|
|
8369
|
+
join8(cwd, ".github", "skills")
|
|
8261
8370
|
];
|
|
8262
8371
|
}
|
|
8263
8372
|
function resolveSkillRoots(cwd = process.cwd()) {
|
|
@@ -8310,7 +8419,7 @@ function scanLocalSkills(roots = resolveSkillRoots()) {
|
|
|
8310
8419
|
const entries = listDir(dir);
|
|
8311
8420
|
if (!entries) continue;
|
|
8312
8421
|
if (!entries.some((e) => e.isFile() && e.name === "SKILL.md")) continue;
|
|
8313
|
-
const file =
|
|
8422
|
+
const file = join8(dir, "SKILL.md");
|
|
8314
8423
|
try {
|
|
8315
8424
|
const parsed = parseSkillFrontmatter(readFileSync7(file, "utf-8"));
|
|
8316
8425
|
if (!parsed || byName.has(parsed.name)) continue;
|
|
@@ -8345,7 +8454,7 @@ function* walkDirs(root) {
|
|
|
8345
8454
|
if (depth >= MAX_DEPTH) continue;
|
|
8346
8455
|
for (const entry of entries) {
|
|
8347
8456
|
if (!entry.isDirectory() || SKIP_DIRS.has(entry.name)) continue;
|
|
8348
|
-
queue.push([
|
|
8457
|
+
queue.push([join8(dir, entry.name), depth + 1]);
|
|
8349
8458
|
}
|
|
8350
8459
|
}
|
|
8351
8460
|
}
|
|
@@ -9122,7 +9231,7 @@ async function runMcpServer(options = {}) {
|
|
|
9122
9231
|
// src/cli.ts
|
|
9123
9232
|
process.env.GHFIND_CLI_RUN = "1";
|
|
9124
9233
|
var __dirname3 = dirname4(fileURLToPath4(import.meta.url));
|
|
9125
|
-
var PKG_DIR3 =
|
|
9234
|
+
var PKG_DIR3 = join9(__dirname3, "..");
|
|
9126
9235
|
var SORT_VALUES = [
|
|
9127
9236
|
"best-match",
|
|
9128
9237
|
"stars",
|
|
@@ -9166,6 +9275,7 @@ function parseArgs(args) {
|
|
|
9166
9275
|
local: false,
|
|
9167
9276
|
copy: false,
|
|
9168
9277
|
raw: false,
|
|
9278
|
+
cloneSsh: false,
|
|
9169
9279
|
limitExplicit: false
|
|
9170
9280
|
};
|
|
9171
9281
|
const queryParts = [];
|
|
@@ -9312,6 +9422,18 @@ function parseArgs(args) {
|
|
|
9312
9422
|
case "--raw":
|
|
9313
9423
|
flags.raw = true;
|
|
9314
9424
|
break;
|
|
9425
|
+
case "--dir": {
|
|
9426
|
+
const dir = args[++i];
|
|
9427
|
+
if (!dir || dir.startsWith("-")) {
|
|
9428
|
+
console.error("Missing value for --dir <path>.");
|
|
9429
|
+
process.exit(1);
|
|
9430
|
+
}
|
|
9431
|
+
flags.cloneDir = dir;
|
|
9432
|
+
break;
|
|
9433
|
+
}
|
|
9434
|
+
case "--ssh":
|
|
9435
|
+
flags.cloneSsh = true;
|
|
9436
|
+
break;
|
|
9315
9437
|
case "--as":
|
|
9316
9438
|
flags.sharedAs = args[++i];
|
|
9317
9439
|
break;
|
|
@@ -9350,6 +9472,16 @@ function parseArgs(args) {
|
|
|
9350
9472
|
}
|
|
9351
9473
|
break;
|
|
9352
9474
|
}
|
|
9475
|
+
case "clone": {
|
|
9476
|
+
const next = args[i + 1];
|
|
9477
|
+
if (next && !next.startsWith("-")) {
|
|
9478
|
+
flags.clone = next;
|
|
9479
|
+
i++;
|
|
9480
|
+
} else {
|
|
9481
|
+
flags.clone = "";
|
|
9482
|
+
}
|
|
9483
|
+
break;
|
|
9484
|
+
}
|
|
9353
9485
|
case "deep-dive": {
|
|
9354
9486
|
const next = args[i + 1];
|
|
9355
9487
|
if (next && !next.startsWith("-")) {
|
|
@@ -9454,6 +9586,7 @@ Usage:
|
|
|
9454
9586
|
ghfind topics Browse popular GitHub topics
|
|
9455
9587
|
ghfind saved Named searches saved in the TUI
|
|
9456
9588
|
ghfind readme <owner/repo> Print a repository README
|
|
9589
|
+
ghfind clone <owner/repo> Clone a repo to disk (HTTPS; --ssh = git@github.com)
|
|
9457
9590
|
ghfind share a/b --as gh-cli Share snippet (--copy to clipboard)
|
|
9458
9591
|
ghfind skill search <query> Find skills locally, or --remote
|
|
9459
9592
|
ghfind --watch <query> Watch mode (poll every Ns)
|
|
@@ -9485,6 +9618,8 @@ Options:
|
|
|
9485
9618
|
--as <fmt> share format: markdown|plain|gh-cli|short
|
|
9486
9619
|
--copy share: also copy the snippet to the clipboard
|
|
9487
9620
|
--raw readme: print the raw markdown with no header
|
|
9621
|
+
--dir <path> clone: directory to clone into (default: cwd)
|
|
9622
|
+
--ssh clone: clone over SSH (git@github.com)
|
|
9488
9623
|
`);
|
|
9489
9624
|
return;
|
|
9490
9625
|
}
|
|
@@ -9492,7 +9627,7 @@ Options:
|
|
|
9492
9627
|
const shell = flags.completion;
|
|
9493
9628
|
try {
|
|
9494
9629
|
const content = readFileSync8(
|
|
9495
|
-
|
|
9630
|
+
join9(PKG_DIR3, "completions", `ghfind.${shell}`),
|
|
9496
9631
|
"utf-8"
|
|
9497
9632
|
);
|
|
9498
9633
|
console.log(content);
|
|
@@ -9546,6 +9681,10 @@ Options:
|
|
|
9546
9681
|
await runShare(flags);
|
|
9547
9682
|
return;
|
|
9548
9683
|
}
|
|
9684
|
+
if (flags.clone !== void 0) {
|
|
9685
|
+
await runClone(flags);
|
|
9686
|
+
return;
|
|
9687
|
+
}
|
|
9549
9688
|
if (flags.pkg) {
|
|
9550
9689
|
const interactive = !flags.query && !flags.json && !flags.csv && !flags.markdown && !flags.count && !flags.format;
|
|
9551
9690
|
if (interactive) {
|
|
@@ -9919,6 +10058,32 @@ async function runShare(flags) {
|
|
|
9919
10058
|
process.exit(1);
|
|
9920
10059
|
}
|
|
9921
10060
|
}
|
|
10061
|
+
async function runClone(flags) {
|
|
10062
|
+
const ref = flags.clone ?? "";
|
|
10063
|
+
if (!ref) {
|
|
10064
|
+
console.error("Usage: ghfind clone <owner/repo> [--dir <path>] [--ssh]");
|
|
10065
|
+
process.exit(1);
|
|
10066
|
+
}
|
|
10067
|
+
let result;
|
|
10068
|
+
try {
|
|
10069
|
+
result = await cloneRepo(ref, {
|
|
10070
|
+
dir: flags.cloneDir,
|
|
10071
|
+
ssh: flags.cloneSsh,
|
|
10072
|
+
interactive: true
|
|
10073
|
+
});
|
|
10074
|
+
} catch (err) {
|
|
10075
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
10076
|
+
process.exit(1);
|
|
10077
|
+
}
|
|
10078
|
+
if (result.error === "git-missing") {
|
|
10079
|
+
console.error(GIT_MISSING_MESSAGE);
|
|
10080
|
+
process.exit(1);
|
|
10081
|
+
}
|
|
10082
|
+
if (result.ok) {
|
|
10083
|
+
console.error(`Cloned to ${result.dir}`);
|
|
10084
|
+
}
|
|
10085
|
+
process.exit(result.code ?? 1);
|
|
10086
|
+
}
|
|
9922
10087
|
async function runPackageSearch(flags) {
|
|
9923
10088
|
if (flags.registry !== "npm") {
|
|
9924
10089
|
console.error(`Registry "${flags.registry}" not yet supported (only npm).`);
|
package/docs/HOW-TO.md
CHANGED
|
@@ -233,8 +233,16 @@ ghfind --trending rust --count
|
|
|
233
233
|
|
|
234
234
|
# Pipe into another tool
|
|
235
235
|
ghfind "cli language:Rust" --json | jq -r '.[].url'
|
|
236
|
+
|
|
237
|
+
# Clone a repo you found (into ./react; --dir and --ssh are optional)
|
|
238
|
+
ghfind clone facebook/react
|
|
239
|
+
ghfind clone facebook/react --dir ~/code --ssh
|
|
236
240
|
```
|
|
237
241
|
|
|
242
|
+
The same action is in the TUI under `Esc` → **Repo** → **Clone**. It clones into
|
|
243
|
+
the directory ghfind was started in and reports the path in the status bar; git
|
|
244
|
+
runs in the background, so the interface stays responsive.
|
|
245
|
+
|
|
238
246
|
---
|
|
239
247
|
|
|
240
248
|
## Environment variables
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "github-search-cli",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.11.0",
|
|
4
4
|
"description": "Search GitHub from your terminal — repo search, trending repos, npm package search, bookmarks, deep-dive, compare, export. Keyboard + mouse TUI + pipeable CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "bun@1",
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# v9.11.0
|
|
2
|
+
|
|
3
|
+
Cloning stops being a command you copy.
|
|
4
|
+
|
|
5
|
+
## `ghfind clone` actually clones
|
|
6
|
+
|
|
7
|
+
The only cloning ghfind knew how to do was printing: `--pipe clone` and
|
|
8
|
+
`--format clone-commands` emit `git clone …` lines for you to paste somewhere
|
|
9
|
+
else. The new `clone` subcommand runs it.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
ghfind clone facebook/react # → ./react, over HTTPS
|
|
13
|
+
ghfind clone facebook/react --dir ~/code # → ~/code/react
|
|
14
|
+
ghfind clone facebook/react --ssh # git@github.com form
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Three of the decisions are worth knowing about:
|
|
18
|
+
|
|
19
|
+
- **No GitHub API call.** A clone needs only owner/name, so the ref is
|
|
20
|
+
validated for shape and git decides what exists and what you are allowed to
|
|
21
|
+
read. Your 60/hour unauthenticated budget can never be the reason a clone
|
|
22
|
+
fails.
|
|
23
|
+
- **The destination is `<dir>/<name>`, exactly what `git clone` would have
|
|
24
|
+
made.** Existing directories are left to git's own rule — an empty one is
|
|
25
|
+
reused, a non-empty one fails with git's message — rather than being
|
|
26
|
+
pre-checked or replaced.
|
|
27
|
+
- **git's answers are passed through, not rewritten.** The CLI shows git's own
|
|
28
|
+
progress and errors, and exits with git's own code (128 for a repository that
|
|
29
|
+
does not exist, so a script can tell that apart from a usage error's 1). The
|
|
30
|
+
single message ghfind writes itself is `git not found on PATH`, which is the
|
|
31
|
+
one failure we can name with certainty.
|
|
32
|
+
|
|
33
|
+
## `Esc` → Repo → Clone
|
|
34
|
+
|
|
35
|
+
The TUI gets the same action, deliberately with a different I/O strategy: the
|
|
36
|
+
TUI owns the screen, so it cannot paint git's progress bar or let git stop to
|
|
37
|
+
ask for a password. git runs in the background with its output captured, the
|
|
38
|
+
full log is written to `GHFIND_LOG` when you have set it, and the status bar
|
|
39
|
+
shows git's own diagnosis — `✗ Clone failed: fatal: repository '…' not found`
|
|
40
|
+
rather than a guess about why.
|
|
41
|
+
|
|
42
|
+
`--pipe clone` is unchanged. Anything already parsing its output keeps working,
|
|
43
|
+
which is why the new behaviour arrived as a subcommand instead of a change to
|
|
44
|
+
the flag.
|
|
45
|
+
|
|
46
|
+
## Also
|
|
47
|
+
|
|
48
|
+
- `ghfind --doctor` reports whether git is on PATH. It is a **warning**, not a
|
|
49
|
+
failure: only `clone` needs git, so the exit code stays 0 for anyone who
|
|
50
|
+
never clones.
|
package/src/agent-skill.ts
CHANGED
|
@@ -77,7 +77,13 @@ reported in text output and via the error message.
|
|
|
77
77
|
ghfind deep-dive owner/repo --json
|
|
78
78
|
Text output (no flag) prints the same sections formatted for the terminal.
|
|
79
79
|
|
|
80
|
-
##
|
|
80
|
+
## Clone a repository
|
|
81
|
+
|
|
82
|
+
ghfind clone owner/repo # into ./repo, over HTTPS
|
|
83
|
+
ghfind clone owner/repo --dir PATH --ssh
|
|
84
|
+
This is the one command here that writes to disk: the clone lands in
|
|
85
|
+
PATH/<repo-name>, or the cwd when --dir is omitted. Confirm the destination
|
|
86
|
+
first; the repo name is taken from the ref, not from the query.
|
|
81
87
|
ghfind skill search QUERY # skills installed on this machine
|
|
82
88
|
ghfind skill search QUERY --remote # the public skills.sh ecosystem
|
|
83
89
|
Results carry path+description (local) or install command (registry, read-only --
|
package/src/cli.ts
CHANGED
|
@@ -67,6 +67,7 @@ import { readFileSync } from "node:fs";
|
|
|
67
67
|
import { join, dirname } from "node:path";
|
|
68
68
|
import { fileURLToPath } from "node:url";
|
|
69
69
|
import { launchBrowser } from "./tui";
|
|
70
|
+
import { cloneRepo, GIT_MISSING_MESSAGE, type CloneResult } from "./clone";
|
|
70
71
|
import { getVersion } from "./version";
|
|
71
72
|
import { runMcpServer } from "./mcp-server";
|
|
72
73
|
import {
|
|
@@ -136,6 +137,9 @@ interface CLIFlags {
|
|
|
136
137
|
saved: boolean; // list saved searches (ghfind saved)
|
|
137
138
|
readme?: string; // print a repo README (ghfind readme <owner/repo>)
|
|
138
139
|
share?: string; // print a share snippet (ghfind share <owner/repo>)
|
|
140
|
+
clone?: string; // clone a repo to disk (ghfind clone <owner/repo>)
|
|
141
|
+
cloneDir?: string; // --dir <path> for clone (the repo gets a <name> subdir)
|
|
142
|
+
cloneSsh: boolean; // --ssh for clone (git@github.com form instead of HTTPS)
|
|
139
143
|
skillSearch?: string; // skill search query (ghfind skill search <query>)
|
|
140
144
|
sharedAs?: string; // --as <format> for share
|
|
141
145
|
remote: boolean; // skill search: hit skills.sh instead of this machine
|
|
@@ -196,6 +200,7 @@ function parseArgs(args: string[]): CLIFlags {
|
|
|
196
200
|
local: false,
|
|
197
201
|
copy: false,
|
|
198
202
|
raw: false,
|
|
203
|
+
cloneSsh: false,
|
|
199
204
|
limitExplicit: false,
|
|
200
205
|
};
|
|
201
206
|
|
|
@@ -350,6 +355,18 @@ function parseArgs(args: string[]): CLIFlags {
|
|
|
350
355
|
case "--raw":
|
|
351
356
|
flags.raw = true;
|
|
352
357
|
break;
|
|
358
|
+
case "--dir": {
|
|
359
|
+
const dir = args[++i];
|
|
360
|
+
if (!dir || dir.startsWith("-")) {
|
|
361
|
+
console.error("Missing value for --dir <path>.");
|
|
362
|
+
process.exit(1);
|
|
363
|
+
}
|
|
364
|
+
flags.cloneDir = dir;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
case "--ssh":
|
|
368
|
+
flags.cloneSsh = true;
|
|
369
|
+
break;
|
|
353
370
|
case "--as":
|
|
354
371
|
flags.sharedAs = args[++i];
|
|
355
372
|
break;
|
|
@@ -390,6 +407,17 @@ function parseArgs(args: string[]): CLIFlags {
|
|
|
390
407
|
}
|
|
391
408
|
break;
|
|
392
409
|
}
|
|
410
|
+
case "clone": {
|
|
411
|
+
// Single repo ref; a following flag means none was given.
|
|
412
|
+
const next = args[i + 1];
|
|
413
|
+
if (next && !next.startsWith("-")) {
|
|
414
|
+
flags.clone = next;
|
|
415
|
+
i++;
|
|
416
|
+
} else {
|
|
417
|
+
flags.clone = "";
|
|
418
|
+
}
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
393
421
|
case "deep-dive": {
|
|
394
422
|
// Single repo ref (owner/name); a following flag means none.
|
|
395
423
|
const next = args[i + 1];
|
|
@@ -508,6 +536,7 @@ Usage:
|
|
|
508
536
|
ghfind topics Browse popular GitHub topics
|
|
509
537
|
ghfind saved Named searches saved in the TUI
|
|
510
538
|
ghfind readme <owner/repo> Print a repository README
|
|
539
|
+
ghfind clone <owner/repo> Clone a repo to disk (HTTPS; --ssh = git@github.com)
|
|
511
540
|
ghfind share a/b --as gh-cli Share snippet (--copy to clipboard)
|
|
512
541
|
ghfind skill search <query> Find skills locally, or --remote
|
|
513
542
|
ghfind --watch <query> Watch mode (poll every Ns)
|
|
@@ -539,6 +568,8 @@ Options:
|
|
|
539
568
|
--as <fmt> share format: markdown|plain|gh-cli|short
|
|
540
569
|
--copy share: also copy the snippet to the clipboard
|
|
541
570
|
--raw readme: print the raw markdown with no header
|
|
571
|
+
--dir <path> clone: directory to clone into (default: cwd)
|
|
572
|
+
--ssh clone: clone over SSH (git@github.com)
|
|
542
573
|
`);
|
|
543
574
|
return;
|
|
544
575
|
}
|
|
@@ -614,6 +645,11 @@ Options:
|
|
|
614
645
|
await runShare(flags);
|
|
615
646
|
return;
|
|
616
647
|
}
|
|
648
|
+
// Clone a repo to disk (parity with the TUI's Repo → Clone action)
|
|
649
|
+
if (flags.clone !== undefined) {
|
|
650
|
+
await runClone(flags);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
617
653
|
// Package search (prototype, ticket 002-008)
|
|
618
654
|
if (flags.pkg) {
|
|
619
655
|
const interactive =
|
|
@@ -1090,6 +1126,43 @@ async function runShare(flags: CLIFlags): Promise<void> {
|
|
|
1090
1126
|
}
|
|
1091
1127
|
}
|
|
1092
1128
|
|
|
1129
|
+
/**
|
|
1130
|
+
* `ghfind clone <owner/repo>` — clone the repo to disk.
|
|
1131
|
+
*
|
|
1132
|
+
* Runs interactively: git inherits our stdio, so its own progress bar renders
|
|
1133
|
+
* and a credential prompt can still be answered. No API call — the ref is
|
|
1134
|
+
* validated syntactically and git reports whether the repo exists or is
|
|
1135
|
+
* readable. git's exit code is propagated (scripts can tell a usage error from
|
|
1136
|
+
* a git failure), and git's own output is left on the terminal untouched.
|
|
1137
|
+
*/
|
|
1138
|
+
async function runClone(flags: CLIFlags): Promise<void> {
|
|
1139
|
+
const ref = flags.clone ?? "";
|
|
1140
|
+
if (!ref) {
|
|
1141
|
+
console.error("Usage: ghfind clone <owner/repo> [--dir <path>] [--ssh]");
|
|
1142
|
+
process.exit(1);
|
|
1143
|
+
}
|
|
1144
|
+
let result: CloneResult;
|
|
1145
|
+
try {
|
|
1146
|
+
result = await cloneRepo(ref, {
|
|
1147
|
+
dir: flags.cloneDir,
|
|
1148
|
+
ssh: flags.cloneSsh,
|
|
1149
|
+
interactive: true,
|
|
1150
|
+
});
|
|
1151
|
+
} catch (err) {
|
|
1152
|
+
// A malformed ref — parseRepoRef's message names the expected shape.
|
|
1153
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1154
|
+
process.exit(1);
|
|
1155
|
+
}
|
|
1156
|
+
if (result.error === "git-missing") {
|
|
1157
|
+
console.error(GIT_MISSING_MESSAGE);
|
|
1158
|
+
process.exit(1);
|
|
1159
|
+
}
|
|
1160
|
+
if (result.ok) {
|
|
1161
|
+
console.error(`Cloned to ${result.dir}`);
|
|
1162
|
+
}
|
|
1163
|
+
process.exit(result.code ?? 1);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1093
1166
|
/** `ghfind pkg "<query>"` — npm package search. Pass-through query to the registry. */
|
|
1094
1167
|
async function runPackageSearch(flags: CLIFlags): Promise<void> {
|
|
1095
1168
|
if (flags.registry !== "npm") {
|
package/src/clone.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* clone.ts — clone a repository to disk (`ghfind clone`, and the TUI's Clone
|
|
3
|
+
* action both drive this one function).
|
|
4
|
+
*
|
|
5
|
+
* Deliberately does NOT consult the GitHub API. A clone needs only owner/name,
|
|
6
|
+
* and gating it behind the unauthenticated 60/hr budget would refuse to clone a
|
|
7
|
+
* repository git itself can fetch fine. `parseRepoRef` validates the shape of
|
|
8
|
+
* the ref (and tolerates `@owner/name.git`); git stays the source of truth for
|
|
9
|
+
* whether the repo exists and whether we may read it.
|
|
10
|
+
*/
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { mkdirSync } from "node:fs";
|
|
13
|
+
import { join, resolve } from "node:path";
|
|
14
|
+
import { parseRepoRef } from "./deepdive";
|
|
15
|
+
|
|
16
|
+
/** Set when the failure is one we can name with certainty. */
|
|
17
|
+
export type CloneFailure = "git-missing";
|
|
18
|
+
|
|
19
|
+
export interface CloneResult {
|
|
20
|
+
ok: boolean;
|
|
21
|
+
/** Absolute path of the clone target — `<dir>/<name>`, like plain `git clone`. */
|
|
22
|
+
dir: string;
|
|
23
|
+
/** git's exit code, or null when git never ran. */
|
|
24
|
+
code: number | null;
|
|
25
|
+
/** git's stdout+stderr. Empty in `interactive` mode (the terminal showed it). */
|
|
26
|
+
output: string;
|
|
27
|
+
error?: CloneFailure;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CloneOptions {
|
|
31
|
+
/** Directory to clone INTO — the repo gets a `<name>` subdirectory. Default: cwd. */
|
|
32
|
+
dir?: string;
|
|
33
|
+
/** Use the `git@github.com:owner/name.git` SSH form instead of HTTPS. */
|
|
34
|
+
ssh?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* true — the caller owns a terminal (CLI): git inherits stdio, so its progress
|
|
37
|
+
* bar renders and a credential prompt can be answered; the exit code is
|
|
38
|
+
* returned verbatim. false (default) — capture git's output and forbid
|
|
39
|
+
* prompts, which is the only safe mode while the TUI owns the screen.
|
|
40
|
+
*/
|
|
41
|
+
interactive?: boolean;
|
|
42
|
+
/** Injectable for tests. Defaults to node:child_process.spawn. */
|
|
43
|
+
spawn?: typeof spawn;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Clone `ref` ("owner/name") to `<dir>/<name>`.
|
|
48
|
+
*
|
|
49
|
+
* Throws (from `parseRepoRef`) for a malformed ref — the caller gets a
|
|
50
|
+
* user-facing usage error, not a result object. Existing target directories are
|
|
51
|
+
* NOT pre-checked: git's own rule applies, so an empty dir is reused and a
|
|
52
|
+
* non-empty one fails with git's "destination path already exists".
|
|
53
|
+
*/
|
|
54
|
+
export async function cloneRepo(
|
|
55
|
+
ref: string,
|
|
56
|
+
opts: CloneOptions = {},
|
|
57
|
+
): Promise<CloneResult> {
|
|
58
|
+
const { owner, name } = parseRepoRef(ref);
|
|
59
|
+
const url = opts.ssh
|
|
60
|
+
? `git@github.com:${owner}/${name}.git`
|
|
61
|
+
: `https://github.com/${owner}/${name}.git`;
|
|
62
|
+
|
|
63
|
+
const root = resolve(opts.dir ?? process.cwd());
|
|
64
|
+
const target = join(root, name);
|
|
65
|
+
// git creates `target` itself but not missing parents, so create the root.
|
|
66
|
+
mkdirSync(root, { recursive: true });
|
|
67
|
+
|
|
68
|
+
const spawnFn = opts.spawn ?? spawn;
|
|
69
|
+
const interactive = opts.interactive === true;
|
|
70
|
+
|
|
71
|
+
return new Promise<CloneResult>((settle) => {
|
|
72
|
+
let output = "";
|
|
73
|
+
const child = spawnFn("git", ["clone", url, target], {
|
|
74
|
+
cwd: root,
|
|
75
|
+
env: interactive
|
|
76
|
+
? process.env
|
|
77
|
+
: // The TUI has nowhere to render a prompt, so let git fail loudly
|
|
78
|
+
// instead of blocking on a username/password nobody can type.
|
|
79
|
+
{ ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
80
|
+
stdio: interactive ? "inherit" : ["ignore", "pipe", "pipe"],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
child.stdout?.on("data", (chunk) => {
|
|
84
|
+
output += String(chunk);
|
|
85
|
+
});
|
|
86
|
+
child.stderr?.on("data", (chunk) => {
|
|
87
|
+
output += String(chunk);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
child.on("error", (err: NodeJS.ErrnoException) => {
|
|
91
|
+
settle({
|
|
92
|
+
ok: false,
|
|
93
|
+
dir: target,
|
|
94
|
+
code: null,
|
|
95
|
+
output: output || err.message,
|
|
96
|
+
error: err.code === "ENOENT" ? "git-missing" : undefined,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
child.on("close", (code) => {
|
|
101
|
+
settle({ ok: code === 0, dir: target, code, output });
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The message shown when git is not installed — the one failure we diagnose ourselves. */
|
|
107
|
+
export const GIT_MISSING_MESSAGE =
|
|
108
|
+
"git not found on PATH — install git and retry.";
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The most useful single line of git's output, for a one-line status message.
|
|
112
|
+
*
|
|
113
|
+
* git's own `fatal:` / `remote:` line is the diagnosis; `Cloning into 'x'...`
|
|
114
|
+
* is progress noise, so a diagnosis line wins, then the last line. Nothing is
|
|
115
|
+
* rewritten — the status bar shows git's own words, and the full log goes to
|
|
116
|
+
* GHFIND_LOG. Lives here rather than in the TUI so the text that reaches the
|
|
117
|
+
* user is covered by tests.
|
|
118
|
+
*/
|
|
119
|
+
export function gitErrorLine(output: string, maxLength = 88): string {
|
|
120
|
+
const lines = output
|
|
121
|
+
.split(/\r?\n/)
|
|
122
|
+
.map((l) => l.trim())
|
|
123
|
+
.filter(Boolean);
|
|
124
|
+
const diagnosis = [...lines]
|
|
125
|
+
.reverse()
|
|
126
|
+
.find((l) => /^(fatal|remote|error):/i.test(l));
|
|
127
|
+
const line = diagnosis ?? lines[lines.length - 1] ?? "";
|
|
128
|
+
return line.length > maxLength ? `${line.slice(0, maxLength - 1)}…` : line;
|
|
129
|
+
}
|
package/src/doctor.ts
CHANGED
|
@@ -140,6 +140,25 @@ function checkSystemBun(bundledOk: boolean): void {
|
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* git is only needed by `ghfind clone`, so a missing git is a WARN, not a FAIL:
|
|
145
|
+
* every other surface works without it, and the exit code should stay 0 for
|
|
146
|
+
* people who never clone.
|
|
147
|
+
*/
|
|
148
|
+
function checkGit(): void {
|
|
149
|
+
const res = run("git", ["--version"]);
|
|
150
|
+
const version = res.stdout.trim();
|
|
151
|
+
if (res.status === 0 && version) {
|
|
152
|
+
add("PASS", "git", `${version} — used by \`ghfind clone\`.`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
add(
|
|
156
|
+
"WARN",
|
|
157
|
+
"git",
|
|
158
|
+
"not found on PATH — `ghfind clone` will fail; every other command works. Install: https://git-scm.com/downloads",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
143
162
|
function checkNodeVersion(): void {
|
|
144
163
|
const major = parseInt(process.version.replace(/^v/, "").split(".")[0], 10);
|
|
145
164
|
if (Number.isNaN(major) || major >= 20) {
|
|
@@ -414,6 +433,7 @@ export async function runDoctor(): Promise<void> {
|
|
|
414
433
|
const bundled = checkBundledBun();
|
|
415
434
|
checkSystemBun(bundled.ok);
|
|
416
435
|
checkNodeVersion();
|
|
436
|
+
checkGit();
|
|
417
437
|
checkBuildOutput();
|
|
418
438
|
checkSource();
|
|
419
439
|
checkTuiBackend();
|
package/src/help.ts
CHANGED
|
@@ -86,6 +86,7 @@ export function buildHelpSections(): HelpSection[] {
|
|
|
86
86
|
{ keys: "Limit", action: "Cycle limit: 10 → 25 → 50 → 100" },
|
|
87
87
|
{ keys: "Refresh", action: "Re-run current search" },
|
|
88
88
|
{ keys: "Graph", action: "Toggle commit activity chart" },
|
|
89
|
+
{ keys: "Clone", action: "Clone the repo to disk" },
|
|
89
90
|
{ keys: "Readme", action: "Full markdown README viewer" },
|
|
90
91
|
{ keys: "Deep-dive", action: "Languages + contributors + excerpt" },
|
|
91
92
|
{ keys: "Bookmark", action: "Save / unsave selected repo" },
|
package/src/tui.ts
CHANGED
|
@@ -133,6 +133,7 @@ import {
|
|
|
133
133
|
markPostUpgradeSeen,
|
|
134
134
|
} from "./update-check";
|
|
135
135
|
import { debugLog } from "./storage";
|
|
136
|
+
import { cloneRepo, gitErrorLine, GIT_MISSING_MESSAGE } from "./clone";
|
|
136
137
|
import {
|
|
137
138
|
landingConsumesKey,
|
|
138
139
|
landingKeysActive,
|
|
@@ -1569,6 +1570,12 @@ export async function launchBrowser(
|
|
|
1569
1570
|
description: "Open selected repo in browser",
|
|
1570
1571
|
action: () => openUrlIfSelected(),
|
|
1571
1572
|
},
|
|
1573
|
+
{
|
|
1574
|
+
type: "action",
|
|
1575
|
+
name: "Clone",
|
|
1576
|
+
description: "Clone the repo to disk (into the cwd)",
|
|
1577
|
+
action: () => cloneSelectedRepo(),
|
|
1578
|
+
},
|
|
1572
1579
|
{
|
|
1573
1580
|
type: "action",
|
|
1574
1581
|
name: "Readme",
|
|
@@ -1633,7 +1640,7 @@ export async function launchBrowser(
|
|
|
1633
1640
|
groups.push({
|
|
1634
1641
|
type: "category",
|
|
1635
1642
|
name: "Repo",
|
|
1636
|
-
description: "Open, readme, bookmark, share, compare",
|
|
1643
|
+
description: "Open, clone, readme, bookmark, share, compare",
|
|
1637
1644
|
icon: "\uD83D\uDCCB",
|
|
1638
1645
|
children: repoItems,
|
|
1639
1646
|
});
|
|
@@ -1841,6 +1848,49 @@ export async function launchBrowser(
|
|
|
1841
1848
|
else setStatus("No repo selected");
|
|
1842
1849
|
}
|
|
1843
1850
|
|
|
1851
|
+
/**
|
|
1852
|
+
* Clone the selected repo to disk, without blocking the render loop.
|
|
1853
|
+
*
|
|
1854
|
+
* Follows the `performUpdate` precedent: git runs in the background with its
|
|
1855
|
+
* output captured, because the TUI owns the screen and has nowhere to draw a
|
|
1856
|
+
* progress bar or a credential prompt (`cloneRepo` sets GIT_TERMINAL_PROMPT=0
|
|
1857
|
+
* for exactly that reason). The full output is written to GHFIND_LOG when it
|
|
1858
|
+
* is set, and the status bar carries git's own diagnosis.
|
|
1859
|
+
*/
|
|
1860
|
+
function cloneSelectedRepo() {
|
|
1861
|
+
const opt = resultsSelect.getSelectedOption();
|
|
1862
|
+
const repo = opt?.value as Repo | undefined;
|
|
1863
|
+
if (!repo) {
|
|
1864
|
+
setStatus("No repo selected");
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
showOverlay("none");
|
|
1868
|
+
setStatus(`Cloning ${repo.fullName}…`);
|
|
1869
|
+
renderer.requestRender();
|
|
1870
|
+
cloneRepo(repo.fullName)
|
|
1871
|
+
.then((result) => {
|
|
1872
|
+
debugLog(
|
|
1873
|
+
`ghfind clone ${repo.fullName} → ${result.dir} (exit ${result.code})\n${result.output}`,
|
|
1874
|
+
);
|
|
1875
|
+
if (result.error === "git-missing") {
|
|
1876
|
+
setStatus(`✗ ${GIT_MISSING_MESSAGE}`);
|
|
1877
|
+
} else if (result.ok) {
|
|
1878
|
+
setStatus(`✓ Cloned ${repo.fullName} → ${result.dir}`);
|
|
1879
|
+
} else {
|
|
1880
|
+
const detail = gitErrorLine(result.output);
|
|
1881
|
+
setStatus(
|
|
1882
|
+
`✗ Clone failed: ${detail || `git exited ${result.code}`} — see GHFIND_LOG`,
|
|
1883
|
+
);
|
|
1884
|
+
}
|
|
1885
|
+
renderer.requestRender();
|
|
1886
|
+
})
|
|
1887
|
+
.catch((err) => {
|
|
1888
|
+
// A malformed ref never reaches git — parseRepoRef's message names it.
|
|
1889
|
+
setStatus(`✗ ${err instanceof Error ? err.message : String(err)}`);
|
|
1890
|
+
renderer.requestRender();
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1844
1894
|
function toggleBookmarkOnSelected() {
|
|
1845
1895
|
const opt = resultsSelect.getSelectedOption();
|
|
1846
1896
|
const repo = opt?.value as Repo | undefined;
|