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 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 &lt;query&gt;</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 &amp; 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 # 682 tests
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 # 682 tests
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
@@ -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
  *)
@@ -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"
@@ -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((resolve) => {
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
- resolve(result);
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((resolve) => {
1735
+ return (query) => new Promise((resolve2) => {
1736
1736
  rl.question(query, (answer) => {
1737
1737
  rl.close();
1738
- resolve(answer.trim());
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.10.3";
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((resolve) => {
3193
+ return new Promise((resolve2) => {
3180
3194
  rl.question(query, (answer) => {
3181
3195
  rl.close();
3182
- resolve(answer.trim());
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((resolve) => waiting.push(resolve));
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 join8, dirname as dirname4 } from "path";
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((resolve) => {
4963
- proc.on("close", (code) => resolve(code === 0));
4964
- proc.on("error", () => resolve(false));
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: spawn4 } = await import("child_process");
5172
+ const { spawn: spawn5 } = await import("child_process");
5158
5173
  const useShell = process.platform === "win32";
5159
- const proc = spawn4(
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((resolve) => {
5169
- proc.on("close", (c) => resolve(c));
5170
- proc.on("error", () => resolve(-1));
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
- ## Finding skills
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 join7 } from "path";
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
- join7(home, ".codex", "skills"),
8258
- join7(home, ".agents", "skills"),
8259
- join7(cwd, ".codex", "skills"),
8260
- join7(cwd, ".github", "skills")
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 = join7(dir, "SKILL.md");
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([join7(dir, entry.name), depth + 1]);
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 = join8(__dirname3, "..");
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
- join8(PKG_DIR3, "completions", `ghfind.${shell}`),
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.10.3",
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.
@@ -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
- ## Finding skills
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;