github-search-cli 9.8.0 → 9.8.1

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/dist/cli.js CHANGED
@@ -1538,7 +1538,7 @@ var cached = null;
1538
1538
  function getVersion() {
1539
1539
  if (cached) return cached;
1540
1540
  if (true) {
1541
- cached = "9.8.0";
1541
+ cached = "9.8.1";
1542
1542
  return cached;
1543
1543
  }
1544
1544
  try {
@@ -2309,6 +2309,12 @@ function removeBookmark(fullName) {
2309
2309
  const bookmarks = loadRaw().filter((b) => b.repo.fullName !== fullName);
2310
2310
  saveRaw(bookmarks);
2311
2311
  }
2312
+ function searchBookmarks(query) {
2313
+ const q = query.toLowerCase();
2314
+ return getBookmarks().filter(
2315
+ (b) => b.repo.fullName.toLowerCase().includes(q) || (b.repo.description ?? "").toLowerCase().includes(q) || b.tags.some((t3) => t3.toLowerCase().includes(q))
2316
+ );
2317
+ }
2312
2318
  function getLastSeen(fullName) {
2313
2319
  return loadRaw().find((b) => b.repo.fullName === fullName)?.lastSeenAt ?? 0;
2314
2320
  }
@@ -2946,8 +2952,8 @@ function buildDeepDiveText(data) {
2946
2952
  }
2947
2953
 
2948
2954
  // src/cli.ts
2949
- import { readFileSync as readFileSync6 } from "fs";
2950
- import { join as join6, dirname as dirname4 } from "path";
2955
+ import { readFileSync as readFileSync7 } from "fs";
2956
+ import { join as join7, dirname as dirname4 } from "path";
2951
2957
  import { fileURLToPath as fileURLToPath4 } from "url";
2952
2958
 
2953
2959
  // src/tui.ts
@@ -3577,7 +3583,25 @@ var RAW_HOSTS = /* @__PURE__ */ new Set([
3577
3583
  ]);
3578
3584
  var RASTER_EXT = /\.(png|jpe?g|webp|gif|bmp|avif|heic|tiff?)(?:[?#]|$)/i;
3579
3585
  var CACHE_LIMIT = 24;
3586
+ var MAX_CONCURRENT_IMAGE_FETCHES = 4;
3580
3587
  var cache = /* @__PURE__ */ new Map();
3588
+ var activeFetches = 0;
3589
+ var waiting = [];
3590
+ function releaseSlot() {
3591
+ activeFetches--;
3592
+ waiting.shift()?.();
3593
+ }
3594
+ async function withSlot(job) {
3595
+ if (activeFetches >= MAX_CONCURRENT_IMAGE_FETCHES) {
3596
+ await new Promise((resolve) => waiting.push(resolve));
3597
+ }
3598
+ activeFetches++;
3599
+ try {
3600
+ return await job();
3601
+ } finally {
3602
+ releaseSlot();
3603
+ }
3604
+ }
3581
3605
  function decodeDataUri(uri) {
3582
3606
  const match = /^data:([^;,]+)?(;base64)?,(.*)$/is.exec(uri);
3583
3607
  if (!match) return null;
@@ -3660,7 +3684,7 @@ function isGitHubHost(hostname) {
3660
3684
  async function fetchImageBytes(url, opts = {}) {
3661
3685
  const cached2 = cache.get(url);
3662
3686
  if (cached2) return cached2;
3663
- const pending = loadImageBytes(url, opts);
3687
+ const pending = withSlot(() => loadImageBytes(url, opts));
3664
3688
  cache.set(url, pending);
3665
3689
  while (cache.size > CACHE_LIMIT) {
3666
3690
  const oldest = cache.keys().next().value;
@@ -3793,6 +3817,7 @@ function extractImageRefs(token) {
3793
3817
 
3794
3818
  // src/markdown-view.ts
3795
3819
  var MAX_IMAGE_ROWS = 24;
3820
+ var RASTER_CACHE_LIMIT = 32;
3796
3821
  function createMarkdownStyle(colors2) {
3797
3822
  const surface = colors2.surface ?? colors2.bg;
3798
3823
  return SyntaxStyle.fromStyles({
@@ -3887,6 +3912,11 @@ function createMarkdownView(opts) {
3887
3912
  });
3888
3913
  })();
3889
3914
  rasterCache.set(key, pending);
3915
+ while (rasterCache.size > RASTER_CACHE_LIMIT) {
3916
+ const oldest = rasterCache.keys().next().value;
3917
+ if (oldest === void 0) break;
3918
+ rasterCache.delete(oldest);
3919
+ }
3890
3920
  return pending;
3891
3921
  };
3892
3922
  const imageNode = (image) => {
@@ -3902,9 +3932,6 @@ function createMarkdownView(opts) {
3902
3932
  container.add(placeholder);
3903
3933
  void rasterize(url).then(
3904
3934
  (cells) => {
3905
- console.error(
3906
- `[dbg] image resolved: cells=${cells ? `${cells.cols}x${cells.rows}` : "null"} destroyed=${container.isDestroyed} parent=${!!container.parent} enabled=${imagesEnabled}`
3907
- );
3908
3935
  if (container.isDestroyed || !container.parent) return;
3909
3936
  container.remove(placeholder);
3910
3937
  placeholder.destroy();
@@ -3923,7 +3950,7 @@ function createMarkdownView(opts) {
3923
3950
  renderable.requestRender();
3924
3951
  },
3925
3952
  (err) => {
3926
- console.error("[dbg] image promise rejected:", err);
3953
+ void err;
3927
3954
  }
3928
3955
  );
3929
3956
  return container;
@@ -3978,6 +4005,8 @@ function createMarkdownView(opts) {
3978
4005
  renderable.fg = text();
3979
4006
  renderable.bg = bg();
3980
4007
  renderable.content = markdown;
4008
+ renderable.streaming = false;
4009
+ renderable.requestRender();
3981
4010
  },
3982
4011
  setImagesEnabled(enabled) {
3983
4012
  if (imagesEnabled === enabled) return;
@@ -3988,6 +4017,29 @@ function createMarkdownView(opts) {
3988
4017
  };
3989
4018
  }
3990
4019
 
4020
+ // src/readme.ts
4021
+ var CANDIDATES = [
4022
+ ["main", "README.md"],
4023
+ ["master", "README.md"],
4024
+ ["main", "README.rst"]
4025
+ ];
4026
+ async function fetchReadme(owner, repo, opts = {}) {
4027
+ const fetchImpl = opts.fetchImpl ?? fetch;
4028
+ const headers = { "User-Agent": "ghfind/1.0" };
4029
+ if (opts.token) headers.Authorization = `Bearer ${opts.token}`;
4030
+ const attempts = CANDIDATES.map(async ([branch, file]) => {
4031
+ const url = readmeBaseUrl(owner, repo, branch, file);
4032
+ const res = await fetchImpl(url, { headers });
4033
+ if (!res.ok) throw new Error(`no readme at ${url}`);
4034
+ return { text: await res.text(), sourceUrl: url };
4035
+ });
4036
+ try {
4037
+ return await Promise.any(attempts);
4038
+ } catch {
4039
+ return null;
4040
+ }
4041
+ }
4042
+
3991
4043
  // src/explore.ts
3992
4044
  async function fetchTopics() {
3993
4045
  const res = await fetch(
@@ -5889,27 +5941,13 @@ async function launchBrowser(theme_override) {
5889
5941
  readmeScroll.scrollTop = 0;
5890
5942
  showOverlay("readme");
5891
5943
  try {
5892
- const headers = { "User-Agent": "ghfind/1.0" };
5893
- if (githubToken) headers.Authorization = `Bearer ${githubToken}`;
5894
- let text = "";
5895
- let sourceUrl = "";
5896
- for (const [branch, file] of [
5897
- ["main", "README.md"],
5898
- ["master", "README.md"],
5899
- ["main", "README.rst"]
5900
- ]) {
5901
- const url = readmeBaseUrl(repo.owner, repo.name, branch, file);
5902
- const r = await fetch(url, { headers });
5903
- if (r.ok) {
5904
- text = await r.text();
5905
- sourceUrl = url;
5906
- break;
5907
- }
5908
- }
5909
- if (!text) {
5910
- readmeView.setContent(`_(no README found for ${repo.fullName})_`);
5944
+ const readme = await fetchReadme(repo.owner, repo.name, {
5945
+ token: githubToken
5946
+ });
5947
+ if (readme) {
5948
+ readmeView.setContent(readme.text, readme.sourceUrl);
5911
5949
  } else {
5912
- readmeView.setContent(text, sourceUrl);
5950
+ readmeView.setContent(`_(no README found for ${repo.fullName})_`);
5913
5951
  }
5914
5952
  } catch {
5915
5953
  readmeView.setContent("_Failed to load README._");
@@ -6931,6 +6969,12 @@ reported in text output and via the error message.
6931
6969
  ghfind deep-dive owner/repo --json
6932
6970
  Text output (no flag) prints the same sections formatted for the terminal.
6933
6971
 
6972
+ ## Finding skills
6973
+ ghfind skill search QUERY # skills installed on this machine
6974
+ ghfind skill search QUERY --remote # the public skills.sh ecosystem
6975
+ Results carry path+description (local) or install command (registry, read-only --
6976
+ nothing is installed). The MCP server exposes the same search as
6977
+ ghfind_skill_search.
6934
6978
  ## Rate limits
6935
6979
 
6936
6980
  60 req/hr unauthenticated, 5,000 with GITHUB_TOKEN (env var or \`ghfind login\`).
@@ -6942,6 +6986,14 @@ Bookmarks live in ghfind's local state, not GitHub. An agent can read the
6942
6986
  release feed with:
6943
6987
  ghfind --releases --json
6944
6988
  Marking releases seen and editing bookmarks is a TUI action; skip it.
6989
+ ## Local state (read-only)
6990
+ ghfind bookmarks [QUERY] --json # saved repos, newest first
6991
+ ghfind history [QUERY] --json # past searches
6992
+ ghfind saved --json # named searches
6993
+ ghfind topics --json # popular GitHub topics
6994
+ ghfind readme owner/repo # print a repo README
6995
+ ghfind share owner/repo --as gh-cli|markdown|plain|short
6996
+ These never write local state; skip them when nothing needs reading.
6945
6997
 
6946
6998
  ## Token efficiency
6947
6999
 
@@ -6981,6 +7033,233 @@ function formatSkillCatalog() {
6981
7033
  ].join("\n");
6982
7034
  }
6983
7035
 
7036
+ // src/skill-finder.ts
7037
+ import { readdirSync, readFileSync as readFileSync6 } from "fs";
7038
+ import { homedir as homedir3 } from "os";
7039
+ import { delimiter, join as join6 } from "path";
7040
+ var REGISTRY_SEARCH_ENDPOINT = "https://skills.sh/api/search";
7041
+ var MIN_QUERY_LENGTH = 2;
7042
+ var MAX_SKILL_RESULTS = 100;
7043
+ function displayPath(path) {
7044
+ const home = homedir3();
7045
+ const normalized = path.replace(/\\/g, "/");
7046
+ const homeNorm = home.replace(/\\/g, "/").replace(/\/$/, "");
7047
+ if (normalized === homeNorm) return "~";
7048
+ if (normalized.startsWith(`${homeNorm}/`)) {
7049
+ return `~${normalized.slice(homeNorm.length)}`;
7050
+ }
7051
+ return normalized;
7052
+ }
7053
+ function defaultSkillRoots(cwd = process.cwd()) {
7054
+ const home = homedir3();
7055
+ return [
7056
+ join6(home, ".codex", "skills"),
7057
+ join6(home, ".agents", "skills"),
7058
+ join6(cwd, ".codex", "skills"),
7059
+ join6(cwd, ".github", "skills")
7060
+ ];
7061
+ }
7062
+ function resolveSkillRoots(cwd = process.cwd()) {
7063
+ const override = process.env.GHFIND_SKILL_ROOTS;
7064
+ const paths = override?.trim() ? override.split(delimiter).filter((p) => p.trim()).map((p) => p.trim()) : defaultSkillRoots(cwd);
7065
+ return paths.map((path) => ({ path, label: displayPath(path) }));
7066
+ }
7067
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git"]);
7068
+ var MAX_DEPTH = 4;
7069
+ function unquote(value) {
7070
+ const trimmed = value.trim();
7071
+ if (trimmed.length >= 2 && (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'"))) {
7072
+ return trimmed.slice(1, -1).trim();
7073
+ }
7074
+ return trimmed;
7075
+ }
7076
+ function parseSkillFrontmatter(text) {
7077
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
7078
+ if (!match) return null;
7079
+ const lines = match[1].split(/\r?\n/);
7080
+ const fields = {};
7081
+ for (let i = 0; i < lines.length; i++) {
7082
+ const field = /^([A-Za-z_][\w-]*):[ \t]*(.*)$/.exec(lines[i]);
7083
+ if (!field) continue;
7084
+ const key = field[1].toLowerCase();
7085
+ const inline = field[2].trim();
7086
+ if (inline === ">" || inline === "|" || /^[>|][-+]?$/.test(inline)) {
7087
+ const literal = inline.startsWith("|");
7088
+ const parts = [];
7089
+ while (i + 1 < lines.length && /^[ \t]+\S/.test(lines[i + 1])) {
7090
+ parts.push(lines[++i].trim());
7091
+ }
7092
+ fields[key] = literal ? parts.join("\n") : parts.join(" ");
7093
+ } else {
7094
+ fields[key] = unquote(inline);
7095
+ }
7096
+ }
7097
+ const name = fields.name ?? "";
7098
+ if (!name) return null;
7099
+ return { name, description: fields.description ?? "" };
7100
+ }
7101
+ function scanLocalSkills(roots = resolveSkillRoots()) {
7102
+ const byName = /* @__PURE__ */ new Map();
7103
+ for (const root of roots) {
7104
+ for (const dir of walkDirs(root.path)) {
7105
+ const entries = listDir(dir);
7106
+ if (!entries) continue;
7107
+ if (!entries.some((e) => e.isFile() && e.name === "SKILL.md")) continue;
7108
+ const file = join6(dir, "SKILL.md");
7109
+ try {
7110
+ const parsed = parseSkillFrontmatter(readFileSync6(file, "utf-8"));
7111
+ if (!parsed || byName.has(parsed.name)) continue;
7112
+ byName.set(parsed.name, {
7113
+ name: parsed.name,
7114
+ description: parsed.description.replace(/\s+/g, " ").trim(),
7115
+ path: file,
7116
+ origin: root.label
7117
+ });
7118
+ } catch {
7119
+ }
7120
+ }
7121
+ }
7122
+ return [...byName.values()];
7123
+ }
7124
+ function listDir(dir) {
7125
+ try {
7126
+ return readdirSync(dir, { withFileTypes: true });
7127
+ } catch {
7128
+ return null;
7129
+ }
7130
+ }
7131
+ function* walkDirs(root) {
7132
+ const queue = [[root, 0]];
7133
+ while (queue.length > 0) {
7134
+ const [dir, depth] = queue.shift();
7135
+ const entries = listDir(dir);
7136
+ if (!entries) continue;
7137
+ yield dir;
7138
+ if (depth >= MAX_DEPTH) continue;
7139
+ for (const entry of entries) {
7140
+ if (!entry.isDirectory() || SKIP_DIRS.has(entry.name)) continue;
7141
+ queue.push([join6(dir, entry.name), depth + 1]);
7142
+ }
7143
+ }
7144
+ }
7145
+ function localMatchTier(hit, needle) {
7146
+ const name = hit.name.toLowerCase();
7147
+ if (name === needle) return 0;
7148
+ if (name.startsWith(needle)) return 1;
7149
+ if (name.includes(needle)) return 2;
7150
+ if (hit.description.toLowerCase().includes(needle)) return 3;
7151
+ return null;
7152
+ }
7153
+ function rankLocalSkills(hits, query, limit = MAX_SKILL_RESULTS) {
7154
+ const needle = query.trim().toLowerCase();
7155
+ return hits.map((hit) => ({ hit, tier: localMatchTier(hit, needle) })).filter(
7156
+ (entry) => entry.tier !== null
7157
+ ).sort((a, b) => a.tier - b.tier || a.hit.name.localeCompare(b.hit.name)).slice(0, clampLimit(limit)).map((entry) => entry.hit);
7158
+ }
7159
+ function clampLimit(limit) {
7160
+ const n = Math.trunc(Number(limit));
7161
+ if (!Number.isFinite(n)) return MAX_SKILL_RESULTS;
7162
+ return Math.min(Math.max(n, 1), MAX_SKILL_RESULTS);
7163
+ }
7164
+ function assertSearchableQuery(query) {
7165
+ const trimmed = query.trim();
7166
+ if (trimmed.length < MIN_QUERY_LENGTH) {
7167
+ throw new Error(
7168
+ `Query must be at least ${MIN_QUERY_LENGTH} characters (got "${trimmed}").`
7169
+ );
7170
+ }
7171
+ return trimmed;
7172
+ }
7173
+ function searchLocalSkills(query, opts = {}) {
7174
+ const trimmed = assertSearchableQuery(query);
7175
+ const skills = rankLocalSkills(
7176
+ scanLocalSkills(opts.roots ?? resolveSkillRoots()),
7177
+ trimmed,
7178
+ opts.limit ?? MAX_SKILL_RESULTS
7179
+ );
7180
+ return { query: trimmed, source: "local", count: skills.length, skills };
7181
+ }
7182
+ function normalizeRegistrySkill(raw) {
7183
+ if (typeof raw !== "object" || raw === null) return null;
7184
+ const obj = raw;
7185
+ const source = typeof obj.source === "string" ? obj.source : "";
7186
+ const skillId = typeof obj.skillId === "string" ? obj.skillId : "";
7187
+ if (!source || !skillId) return null;
7188
+ const name = typeof obj.name === "string" && obj.name.trim() ? obj.name : skillId;
7189
+ const installs = typeof obj.installs === "number" ? obj.installs : 0;
7190
+ const id = typeof obj.id === "string" && obj.id ? obj.id : `${source}/${skillId}`;
7191
+ return {
7192
+ id,
7193
+ source,
7194
+ skillId,
7195
+ name,
7196
+ installs,
7197
+ installCommand: `npx skills add ${source}@${skillId}`,
7198
+ url: `https://skills.sh/${source}/${skillId}`
7199
+ };
7200
+ }
7201
+ async function searchRegistrySkills(query, opts = {}) {
7202
+ const trimmed = assertSearchableQuery(query);
7203
+ const limit = clampLimit(opts.limit ?? MAX_SKILL_RESULTS);
7204
+ const fetchImpl = opts.fetchImpl ?? fetch;
7205
+ const url = `${REGISTRY_SEARCH_ENDPOINT}?q=${encodeURIComponent(trimmed)}&limit=${limit}`;
7206
+ let res;
7207
+ try {
7208
+ res = await fetchImpl(url, {
7209
+ headers: { Accept: "application/json", "User-Agent": "ghfind/1.0" }
7210
+ });
7211
+ } catch (err) {
7212
+ throw new Error(
7213
+ `Could not reach skills.sh: ${err instanceof Error ? err.message : String(err)}`
7214
+ );
7215
+ }
7216
+ if (!res.ok) {
7217
+ throw new Error(
7218
+ `skills.sh returned HTTP ${res.status} for "${trimmed}". Retry, or search locally with --local.`
7219
+ );
7220
+ }
7221
+ let payload;
7222
+ try {
7223
+ payload = await res.json();
7224
+ } catch {
7225
+ throw new Error("skills.sh returned a response that was not JSON.");
7226
+ }
7227
+ const items = payload?.skills ?? [];
7228
+ const skills = items.map(normalizeRegistrySkill).filter((s) => s !== null).slice(0, limit);
7229
+ return { query: trimmed, source: "registry", count: skills.length, skills };
7230
+ }
7231
+ async function searchSkills(query, opts = {}) {
7232
+ if ((opts.source ?? "local") === "registry") {
7233
+ return searchRegistrySkills(query, opts);
7234
+ }
7235
+ return searchLocalSkills(query, opts);
7236
+ }
7237
+ function formatSkillSearchJson(result) {
7238
+ return JSON.stringify(result, null, 2);
7239
+ }
7240
+ function shortDescription(description, width = 100) {
7241
+ if (description.length <= width) return description;
7242
+ return `${description.slice(0, width - 1).trimEnd()}\u2026`;
7243
+ }
7244
+ function formatSkillSearchText(result) {
7245
+ if (result.count === 0) {
7246
+ return result.source === "local" ? `No installed skills match "${result.query}". Try --remote to search the skills.sh ecosystem.` : `No skills.sh entries match "${result.query}".`;
7247
+ }
7248
+ if (result.source === "local") {
7249
+ return result.skills.map(
7250
+ (s) => `${s.name} (${s.origin})
7251
+ ${shortDescription(s.description)}`
7252
+ ).join("\n");
7253
+ }
7254
+ return result.skills.map(
7255
+ (s) => `${s.name} ${s.installs.toLocaleString()} installs [${s.source}]
7256
+ ${s.installCommand}`
7257
+ ).join("\n");
7258
+ }
7259
+ function formatSkillSearchNames(result) {
7260
+ return result.skills.map((s) => s.name).join("\n");
7261
+ }
7262
+
6984
7263
  // src/mcp-server.ts
6985
7264
  var SUPPORTED_PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
6986
7265
  var LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0];
@@ -7043,6 +7322,7 @@ function asMcpToolError(err) {
7043
7322
  }
7044
7323
  var TRENDING_QUERY = { keywords: [], qualifiers: [], raw: "trending" };
7045
7324
  var REPO_SORTS = ["best-match", "stars", "updated", "forks"];
7325
+ var SKILL_SOURCES = ["local", "registry"];
7046
7326
  var TRENDING_SINCE = ["daily", "weekly", "monthly"];
7047
7327
  var PKG_SORTS = ["best-match", "score", "downloads", "name"];
7048
7328
  var TOOLS = [
@@ -7387,6 +7667,52 @@ Not found: ${missing.join(", ")}` : "");
7387
7667
  data: { name: "ghfind-cli" }
7388
7668
  };
7389
7669
  }
7670
+ },
7671
+ {
7672
+ name: "ghfind_skill_search",
7673
+ title: "Search agent skills",
7674
+ description: "Search installed agent skills (SKILL.md files under the user's skill directories) or the public skills.sh ecosystem by keyword, and return matching skills with their descriptions. Read-only: it never installs anything - registry hits include the `npx skills add` command for the user to run deliberately. Use when the user asks to find a skill for a task, or asks what skills are available.",
7675
+ inputSchema: {
7676
+ type: "object",
7677
+ properties: {
7678
+ query: {
7679
+ type: "string",
7680
+ description: 'Keyword to match against skill names and descriptions, e.g. "changelog" or "testing" (2+ characters).'
7681
+ },
7682
+ source: {
7683
+ type: "string",
7684
+ description: "Where to search: 'local' for skills installed on this machine (default), 'registry' for the public skills.sh ecosystem.",
7685
+ enum: [...SKILL_SOURCES]
7686
+ },
7687
+ limit: {
7688
+ type: "number",
7689
+ description: "Max results, 1-100 (default 20).",
7690
+ minimum: 1,
7691
+ maximum: 100
7692
+ }
7693
+ },
7694
+ required: ["query"]
7695
+ },
7696
+ async run(args) {
7697
+ const source = coerceEnum(
7698
+ args.source,
7699
+ SKILL_SOURCES,
7700
+ "local"
7701
+ );
7702
+ const limit = coerceInt(args.limit, 20, 1, 100);
7703
+ const result = await searchSkills(
7704
+ String(args.query ?? ""),
7705
+ { source, limit }
7706
+ );
7707
+ const where = result.source === "local" ? "installed skills on this machine" : "the public skills.sh ecosystem";
7708
+ const header = `${result.count} match(es) for "${result.query}" in ${where}:`;
7709
+ return {
7710
+ text: `${header}
7711
+
7712
+ ${formatSkillSearchText(result)}`,
7713
+ data: { ...result }
7714
+ };
7715
+ }
7390
7716
  }
7391
7717
  ];
7392
7718
  function toolSummaries() {
@@ -7465,7 +7791,7 @@ async function processServerLine(line, state, config) {
7465
7791
  title: "ghfind \u2014 GitHub search for agents",
7466
7792
  version: getVersion()
7467
7793
  },
7468
- instructions: "ghfind gives agents GitHub search: repositories, trending, npm packages, org/user profiles, repo comparison and deep-dives. Call ghfind_skill for the full CLI guide. Unauthenticated requests are rate-limited to 60/hr; pass a token or set GITHUB_TOKEN for 5,000/hr."
7794
+ instructions: "ghfind gives agents GitHub search: repositories, trending, npm packages, org/user profiles, repo comparison and deep-dives. ghfind_skill_search finds agent skills, either installed on this machine or in the public skills.sh ecosystem. Call ghfind_skill for the full CLI guide. Unauthenticated requests are rate-limited to 60/hr; pass a token or set GITHUB_TOKEN for 5,000/hr."
7469
7795
  })
7470
7796
  );
7471
7797
  return;
@@ -7569,7 +7895,7 @@ async function runMcpServer(options = {}) {
7569
7895
  // src/cli.ts
7570
7896
  process.env.GHFIND_CLI_RUN = "1";
7571
7897
  var __dirname3 = dirname4(fileURLToPath4(import.meta.url));
7572
- var PKG_DIR3 = join6(__dirname3, "..");
7898
+ var PKG_DIR3 = join7(__dirname3, "..");
7573
7899
  function parseArgs(args) {
7574
7900
  const flags = {
7575
7901
  query: "",
@@ -7593,7 +7919,16 @@ function parseArgs(args) {
7593
7919
  registry: "npm",
7594
7920
  compare: [],
7595
7921
  doctor: false,
7596
- mcp: false
7922
+ mcp: false,
7923
+ bookmarks: false,
7924
+ history: false,
7925
+ topics: false,
7926
+ saved: false,
7927
+ remote: false,
7928
+ local: false,
7929
+ copy: false,
7930
+ raw: false,
7931
+ limitExplicit: false
7597
7932
  };
7598
7933
  const queryParts = [];
7599
7934
  for (let i = 0; i < args.length; i++) {
@@ -7650,6 +7985,7 @@ function parseArgs(args) {
7650
7985
  break;
7651
7986
  case "--limit": {
7652
7987
  const parsed = parseInt(args[++i], 10);
7988
+ flags.limitExplicit = true;
7653
7989
  if (Number.isNaN(parsed) || parsed < 1 || parsed > 100) {
7654
7990
  console.error(`Invalid limit: must be 1-100. Using default 50.`);
7655
7991
  flags.limit = 50;
@@ -7682,9 +8018,56 @@ function parseArgs(args) {
7682
8018
  case "--completion":
7683
8019
  flags.completion = args[++i];
7684
8020
  break;
8021
+ case "--remote":
8022
+ flags.remote = true;
8023
+ break;
8024
+ case "--local":
8025
+ flags.local = true;
8026
+ break;
8027
+ case "--copy":
8028
+ flags.copy = true;
8029
+ break;
8030
+ case "--raw":
8031
+ flags.raw = true;
8032
+ break;
8033
+ case "--as":
8034
+ flags.sharedAs = args[++i];
8035
+ break;
7685
8036
  case "mcp":
7686
8037
  flags.mcp = true;
7687
8038
  break;
8039
+ case "bookmarks":
8040
+ flags.bookmarks = true;
8041
+ break;
8042
+ case "history":
8043
+ flags.history = true;
8044
+ break;
8045
+ case "topics":
8046
+ flags.topics = true;
8047
+ break;
8048
+ case "saved":
8049
+ flags.saved = true;
8050
+ break;
8051
+ case "readme": {
8052
+ const next = args[i + 1];
8053
+ if (next && !next.startsWith("-")) {
8054
+ flags.readme = next;
8055
+ i++;
8056
+ } else {
8057
+ flags.readme = "";
8058
+ }
8059
+ break;
8060
+ }
8061
+ case "share": {
8062
+ const next = args[i + 1];
8063
+ if (next && !next.startsWith("-")) {
8064
+ flags.share = next;
8065
+ i++;
8066
+ } else {
8067
+ flags.share = "";
8068
+ }
8069
+ break;
8070
+ }
7688
8071
  case "deep-dive": {
7689
8072
  const next = args[i + 1];
7690
8073
  if (next && !next.startsWith("-")) {
@@ -7695,6 +8078,17 @@ function parseArgs(args) {
7695
8078
  }
7696
8079
  case "skill": {
7697
8080
  const next = args[i + 1];
8081
+ if (next === "search") {
8082
+ i++;
8083
+ const query = args[i + 1];
8084
+ if (query && !query.startsWith("-")) {
8085
+ flags.skillSearch = query;
8086
+ i++;
8087
+ } else {
8088
+ flags.skillSearch = "";
8089
+ }
8090
+ break;
8091
+ }
7698
8092
  if (next && !next.startsWith("-")) {
7699
8093
  flags.skill = next;
7700
8094
  i++;
@@ -7773,6 +8167,13 @@ Usage:
7773
8167
  ghfind pkg <query> Search npm packages, text list
7774
8168
  ghfind mcp Run the MCP server for AI agents (stdio)
7775
8169
  ghfind skill [name] Print the agent skill guide (or catalog)
8170
+ ghfind bookmarks [query] Saved repos, newest first
8171
+ ghfind history [query] Past searches, newest first
8172
+ ghfind topics Browse popular GitHub topics
8173
+ ghfind saved Named searches saved in the TUI
8174
+ ghfind readme <owner/repo> Print a repository README
8175
+ ghfind share a/b --as gh-cli Share snippet (--copy to clipboard)
8176
+ ghfind skill search <query> Find skills locally, or --remote
7776
8177
  ghfind --watch <query> Watch mode (poll every Ns)
7777
8178
  ghfind init Run setup wizard
7778
8179
  ghfind login Import the gh CLI token or paste one
@@ -7797,14 +8198,19 @@ Options:
7797
8198
  --releases New-release feed for bookmarks
7798
8199
  --interval <s> Watch interval in seconds (default: 300)
7799
8200
  --completion <shell> Generate completions
8201
+ --remote skill search: query skills.sh instead of this machine
8202
+ --local skill search: force the local scan (default)
8203
+ --as <fmt> share format: markdown|plain|gh-cli|short
8204
+ --copy share: also copy the snippet to the clipboard
8205
+ --raw readme: print the raw markdown with no header
7800
8206
  `);
7801
8207
  return;
7802
8208
  }
7803
8209
  if (flags.completion) {
7804
8210
  const shell = flags.completion;
7805
8211
  try {
7806
- const content = readFileSync6(
7807
- join6(PKG_DIR3, "completions", `ghfind.${shell}`),
8212
+ const content = readFileSync7(
8213
+ join7(PKG_DIR3, "completions", `ghfind.${shell}`),
7808
8214
  "utf-8"
7809
8215
  );
7810
8216
  console.log(content);
@@ -7826,10 +8232,38 @@ Options:
7826
8232
  await runMcpServer({ token: flags.token });
7827
8233
  return;
7828
8234
  }
8235
+ if (flags.skillSearch !== void 0) {
8236
+ await runSkillSearch(flags);
8237
+ return;
8238
+ }
7829
8239
  if (flags.skill !== void 0) {
7830
8240
  printAgentSkill(flags.skill);
7831
8241
  return;
7832
8242
  }
8243
+ if (flags.bookmarks) {
8244
+ runBookmarks(flags);
8245
+ return;
8246
+ }
8247
+ if (flags.history) {
8248
+ runHistory(flags);
8249
+ return;
8250
+ }
8251
+ if (flags.saved) {
8252
+ runSavedSearches(flags);
8253
+ return;
8254
+ }
8255
+ if (flags.topics) {
8256
+ await runTopics(flags);
8257
+ return;
8258
+ }
8259
+ if (flags.readme !== void 0) {
8260
+ await runReadme(flags);
8261
+ return;
8262
+ }
8263
+ if (flags.share !== void 0) {
8264
+ await runShare(flags);
8265
+ return;
8266
+ }
7833
8267
  if (flags.pkg) {
7834
8268
  const interactive = !flags.query && !flags.json && !flags.csv && !flags.markdown && !flags.count && !flags.format;
7835
8269
  if (interactive) {
@@ -7963,6 +8397,233 @@ async function runDeepDive(flags, outputFormat) {
7963
8397
  process.exit(1);
7964
8398
  }
7965
8399
  }
8400
+ async function runSkillSearch(flags) {
8401
+ const query = flags.skillSearch ?? "";
8402
+ if (!query.trim()) {
8403
+ console.error(
8404
+ "Usage: ghfind skill search <query> [--remote] [--limit <n>] [--json]"
8405
+ );
8406
+ process.exit(1);
8407
+ }
8408
+ if (flags.remote && flags.local) {
8409
+ console.error(
8410
+ "Contradictory flags: pass either --remote or --local, not both."
8411
+ );
8412
+ process.exit(1);
8413
+ }
8414
+ const source = flags.remote ? "registry" : "local";
8415
+ try {
8416
+ const result = await searchSkills(query, {
8417
+ source,
8418
+ limit: flags.limit
8419
+ });
8420
+ if (flags.json) {
8421
+ console.log(formatSkillSearchJson(result));
8422
+ } else if (flags.format === "names") {
8423
+ console.log(formatSkillSearchNames(result));
8424
+ } else {
8425
+ console.log(formatSkillSearchText(result));
8426
+ }
8427
+ } catch (err) {
8428
+ console.error(err instanceof Error ? err.message : String(err));
8429
+ process.exit(1);
8430
+ }
8431
+ }
8432
+ function runBookmarks(flags) {
8433
+ const query = flags.query.trim();
8434
+ const bookmarks = query ? searchBookmarks(query) : getBookmarks();
8435
+ if (flags.json) {
8436
+ console.log(JSON.stringify(bookmarks, null, 2));
8437
+ return;
8438
+ }
8439
+ if (bookmarks.length === 0) {
8440
+ console.log(
8441
+ query ? `No bookmarks match "${query}".` : "No bookmarks yet. Press Space \u2192 Bookmark in the TUI to save one."
8442
+ );
8443
+ return;
8444
+ }
8445
+ if (flags.csv || flags.markdown) {
8446
+ const header = ["full_name", "stars", "language", "tags", "saved_at"];
8447
+ const rows = bookmarks.map((b) => [
8448
+ b.repo.fullName,
8449
+ String(b.repo.stars),
8450
+ b.repo.language ?? "",
8451
+ b.tags.join(" "),
8452
+ new Date(b.savedAt).toISOString().slice(0, 10)
8453
+ ]);
8454
+ console.log(
8455
+ flags.csv ? rowsToCsv(header, rows) : rowsToMarkdown(header, rows)
8456
+ );
8457
+ return;
8458
+ }
8459
+ for (const b of bookmarks) {
8460
+ const tags = b.tags.length > 0 ? ` [${b.tags.join(", ")}]` : "";
8461
+ console.log(
8462
+ `${b.repo.fullName} \u2014 ${b.repo.stars.toLocaleString()} ${b.repo.language ?? "n/a"}${tags}`
8463
+ );
8464
+ }
8465
+ }
8466
+ function runHistory(flags) {
8467
+ const limit = flags.limitExplicit ? flags.limit : 20;
8468
+ const query = flags.query.trim().toLowerCase();
8469
+ const entries = readHistory().filter((e) => !query || e.query.toLowerCase().includes(query)).slice(0, limit);
8470
+ if (flags.json) {
8471
+ console.log(JSON.stringify(entries, null, 2));
8472
+ return;
8473
+ }
8474
+ if (entries.length === 0) {
8475
+ console.log(
8476
+ query ? `No history matches "${query}".` : "No search history yet."
8477
+ );
8478
+ return;
8479
+ }
8480
+ if (flags.csv || flags.markdown) {
8481
+ const header = ["query", "mode", "results", "when"];
8482
+ const rows = entries.map((e) => [
8483
+ e.query,
8484
+ e.mode,
8485
+ String(e.resultCount),
8486
+ new Date(e.timestamp).toISOString()
8487
+ ]);
8488
+ console.log(
8489
+ flags.csv ? rowsToCsv(header, rows) : rowsToMarkdown(header, rows)
8490
+ );
8491
+ return;
8492
+ }
8493
+ for (const e of entries) {
8494
+ const when = new Date(e.timestamp).toISOString().slice(0, 10);
8495
+ console.log(
8496
+ `${e.query} \u2014 ${e.mode} \u2014 ${e.resultCount} results \u2014 ${when}`
8497
+ );
8498
+ }
8499
+ }
8500
+ function runSavedSearches(flags) {
8501
+ const saved = getSavedSearches();
8502
+ if (flags.json) {
8503
+ console.log(JSON.stringify(saved, null, 2));
8504
+ return;
8505
+ }
8506
+ if (saved.length === 0) {
8507
+ console.log("No saved searches yet. Save one from the TUI command menu.");
8508
+ return;
8509
+ }
8510
+ if (flags.csv || flags.markdown) {
8511
+ const header = ["name", "query", "mode", "limit"];
8512
+ const rows = saved.map((s) => [s.name, s.query, s.mode, String(s.limit)]);
8513
+ console.log(
8514
+ flags.csv ? rowsToCsv(header, rows) : rowsToMarkdown(header, rows)
8515
+ );
8516
+ return;
8517
+ }
8518
+ for (const s of saved) {
8519
+ console.log(`${s.name} \u2014 ${s.query} \u2014 ${s.mode} \u2014 limit ${s.limit}`);
8520
+ }
8521
+ }
8522
+ function rowsToCsv(header, rows) {
8523
+ const quote = (value) => /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
8524
+ return [header, ...rows].map((row) => row.map(quote).join(",")).join("\n");
8525
+ }
8526
+ function rowsToMarkdown(header, rows) {
8527
+ return [
8528
+ `| ${header.join(" | ")} |`,
8529
+ `|${header.map(() => "---").join("|")}|`,
8530
+ ...rows.map((row) => `| ${row.join(" | ")} |`)
8531
+ ].join("\n");
8532
+ }
8533
+ function truncate2(text, width) {
8534
+ if (text.length <= width) return text;
8535
+ return `${text.slice(0, width - 1).trimEnd()}\u2026`;
8536
+ }
8537
+ function plainText(html) {
8538
+ return html.replace(/<\/p>|<br\s*\/?>/gi, " ").replace(/<[^>]*>/g, "").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim();
8539
+ }
8540
+ async function runTopics(flags) {
8541
+ try {
8542
+ const topics = (await fetchTopics()).slice(0, flags.limit);
8543
+ if (flags.json) {
8544
+ console.log(JSON.stringify(topics, null, 2));
8545
+ return;
8546
+ }
8547
+ if (topics.length === 0) {
8548
+ console.log("No topics returned.");
8549
+ return;
8550
+ }
8551
+ for (const t3 of topics) {
8552
+ const description = truncate2(plainText(t3.description), 140);
8553
+ console.log(description ? `${t3.name} ${description}` : t3.name);
8554
+ }
8555
+ } catch (err) {
8556
+ console.error(err instanceof Error ? err.message : String(err));
8557
+ process.exit(1);
8558
+ }
8559
+ }
8560
+ async function runReadme(flags) {
8561
+ const ref = flags.readme ?? "";
8562
+ if (!ref) {
8563
+ console.error(
8564
+ "Usage: ghfind readme <owner/repo> [--json] [--raw] [--token <t>]"
8565
+ );
8566
+ process.exit(1);
8567
+ }
8568
+ try {
8569
+ const { owner, name } = repoFromRef(ref);
8570
+ const readme = await fetchReadme(owner, name, { token: flags.token });
8571
+ if (!readme) {
8572
+ console.error(`No README found for ${owner}/${name}.`);
8573
+ process.exit(1);
8574
+ }
8575
+ if (flags.json) {
8576
+ console.log(
8577
+ JSON.stringify(
8578
+ { owner, name, sourceUrl: readme.sourceUrl, text: readme.text },
8579
+ null,
8580
+ 2
8581
+ )
8582
+ );
8583
+ return;
8584
+ }
8585
+ if (!flags.raw) {
8586
+ console.log(`# ${owner}/${name} README (${readme.sourceUrl})`);
8587
+ console.log("");
8588
+ }
8589
+ console.log(readme.text);
8590
+ } catch (err) {
8591
+ console.error(err instanceof Error ? err.message : String(err));
8592
+ process.exit(1);
8593
+ }
8594
+ }
8595
+ async function runShare(flags) {
8596
+ const ref = flags.share ?? "";
8597
+ if (!ref) {
8598
+ console.error(
8599
+ "Usage: ghfind share <owner/repo> [--as markdown|plain|gh-cli|short] [--copy]"
8600
+ );
8601
+ process.exit(1);
8602
+ }
8603
+ const SHARE_FORMATS = ["markdown", "plain", "gh-cli", "short"];
8604
+ const as = flags.sharedAs ?? "markdown";
8605
+ if (!SHARE_FORMATS.includes(as)) {
8606
+ console.error(
8607
+ `Invalid --as value: "${flags.sharedAs}" (use markdown|plain|gh-cli|short).`
8608
+ );
8609
+ process.exit(1);
8610
+ }
8611
+ try {
8612
+ repoFromRef(ref);
8613
+ const repo = await resolveRepoFromRef(ref, flags.token);
8614
+ const text = formatShare(repo, as);
8615
+ console.log(text);
8616
+ if (flags.copy) {
8617
+ const copied = await copyToClipboard(text);
8618
+ console.error(
8619
+ copied ? "Copied to clipboard." : "Could not copy to the clipboard on this platform."
8620
+ );
8621
+ }
8622
+ } catch (err) {
8623
+ console.error(err instanceof Error ? err.message : String(err));
8624
+ process.exit(1);
8625
+ }
8626
+ }
7966
8627
  async function runPackageSearch(flags) {
7967
8628
  if (flags.registry !== "npm") {
7968
8629
  console.error(`Registry "${flags.registry}" not yet supported (only npm).`);