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.
@@ -35,6 +35,10 @@ import {
35
35
  /** Tallest inline image, in terminal cells. */
36
36
  export const MAX_IMAGE_ROWS = 24;
37
37
 
38
+ /** Cap on memoised rasters — a resize session re-keys on width, so this
39
+ * would otherwise grow without bound. */
40
+ const RASTER_CACHE_LIMIT = 32;
41
+
38
42
  export interface MarkdownViewOptions {
39
43
  renderer: CliRenderer;
40
44
  /** Theme palette (see src/themes.ts). */
@@ -182,6 +186,13 @@ export function createMarkdownView(opts: MarkdownViewOptions): MarkdownView {
182
186
  });
183
187
  })();
184
188
  rasterCache.set(key, pending);
189
+ // Map iteration order is insertion order, so the first key is the oldest.
190
+ // Unbounded growth would re-rasterize every image on every resize.
191
+ while (rasterCache.size > RASTER_CACHE_LIMIT) {
192
+ const oldest = rasterCache.keys().next().value;
193
+ if (oldest === undefined) break;
194
+ rasterCache.delete(oldest);
195
+ }
185
196
  return pending;
186
197
  };
187
198
 
@@ -200,9 +211,6 @@ export function createMarkdownView(opts: MarkdownViewOptions): MarkdownView {
200
211
 
201
212
  void rasterize(url).then(
202
213
  (cells) => {
203
- console.error(
204
- `[dbg] image resolved: cells=${cells ? `${cells.cols}x${cells.rows}` : "null"} destroyed=${container.isDestroyed} parent=${!!container.parent} enabled=${imagesEnabled}`,
205
- );
206
214
  // The document may have been replaced (or the overlay closed) while
207
215
  // the image was downloading.
208
216
  if (container.isDestroyed || !container.parent) return;
@@ -223,7 +231,8 @@ export function createMarkdownView(opts: MarkdownViewOptions): MarkdownView {
223
231
  renderable.requestRender();
224
232
  },
225
233
  (err) => {
226
- console.error("[dbg] image promise rejected:", err);
234
+ // Swallow: a failed image should not crash the view.
235
+ void err;
227
236
  },
228
237
  );
229
238
  return container;
@@ -288,6 +297,13 @@ export function createMarkdownView(opts: MarkdownViewOptions): MarkdownView {
288
297
  renderable.fg = text();
289
298
  renderable.bg = bg();
290
299
  renderable.content = markdown;
300
+ // Finalize streaming so the last tokens are no longer marked unstable.
301
+ // Without this, OpenTUI keeps the trailing 2 blocks perpetually
302
+ // unstable in streaming mode, which recreates image render nodes (and
303
+ // their pending fetch promises) on every render cycle — the promise
304
+ // resolves after the loop idles and never settles.
305
+ renderable.streaming = false;
306
+ renderable.requestRender();
291
307
  },
292
308
  setImagesEnabled(enabled: boolean) {
293
309
  if (imagesEnabled === enabled) return;
package/src/mcp-server.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  * ghfind_deep_dive deepdive.ts
18
18
  * ghfind_bookmarked_releases releases.ts + bookmarks.ts (read-only)
19
19
  * ghfind_skill agent-skill.ts
20
+ * ghfind_skill_search skill-finder.ts (local scan | skills.sh HTTP)
20
21
  *
21
22
  * Only the tools capability is declared (no prompts/resources yet), so those
22
23
  * methods fall through to "Method not found".
@@ -47,6 +48,12 @@ import { getBookmarks } from "./bookmarks";
47
48
  import { appendHistory } from "./history";
48
49
  import { loadConfig } from "./config";
49
50
  import { ensureBuiltinSkill, getSkill } from "./agent-skill";
51
+ import {
52
+ formatSkillSearchText,
53
+ searchSkills,
54
+ type SkillSearchResult,
55
+ type SkillSource,
56
+ } from "./skill-finder";
50
57
  import { getVersion } from "./version";
51
58
 
52
59
  // ─── Protocol constants ────────────────────────────────────────────────
@@ -212,6 +219,8 @@ const TRENDING_QUERY = { keywords: [], qualifiers: [], raw: "trending" };
212
219
  // ─── Tool registry ─────────────────────────────────────────────────────
213
220
 
214
221
  const REPO_SORTS = ["best-match", "stars", "updated", "forks"] as const;
222
+ /** Skill search sources: this machine, or the public skills.sh ecosystem. */
223
+ const SKILL_SOURCES = ["local", "registry"] as const;
215
224
  const TRENDING_SINCE = ["daily", "weekly", "monthly"] as const;
216
225
  const PKG_SORTS = ["best-match", "score", "downloads", "name"] as const;
217
226
 
@@ -599,6 +608,56 @@ const TOOLS: ToolDefinition[] = [
599
608
  };
600
609
  },
601
610
  },
611
+ {
612
+ name: "ghfind_skill_search",
613
+ title: "Search agent skills",
614
+ description:
615
+ "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.",
616
+ inputSchema: {
617
+ type: "object",
618
+ properties: {
619
+ query: {
620
+ type: "string",
621
+ description:
622
+ 'Keyword to match against skill names and descriptions, e.g. "changelog" or "testing" (2+ characters).',
623
+ },
624
+ source: {
625
+ type: "string",
626
+ description:
627
+ "Where to search: 'local' for skills installed on this machine (default), 'registry' for the public skills.sh ecosystem.",
628
+ enum: [...SKILL_SOURCES],
629
+ },
630
+ limit: {
631
+ type: "number",
632
+ description: "Max results, 1-100 (default 20).",
633
+ minimum: 1,
634
+ maximum: 100,
635
+ },
636
+ },
637
+ required: ["query"],
638
+ },
639
+ async run(args) {
640
+ const source = coerceEnum<SkillSource>(
641
+ args.source,
642
+ SKILL_SOURCES,
643
+ "local",
644
+ );
645
+ const limit = coerceInt(args.limit, 20, 1, 100);
646
+ const result: SkillSearchResult = await searchSkills(
647
+ String(args.query ?? ""),
648
+ { source, limit },
649
+ );
650
+ const where =
651
+ result.source === "local"
652
+ ? "installed skills on this machine"
653
+ : "the public skills.sh ecosystem";
654
+ const header = `${result.count} match(es) for "${result.query}" in ${where}:`;
655
+ return {
656
+ text: `${header}\n\n${formatSkillSearchText(result)}`,
657
+ data: { ...result },
658
+ };
659
+ },
660
+ },
602
661
  ];
603
662
 
604
663
  /** Tools/list payload (name, title, description, inputSchema only). */
@@ -737,6 +796,7 @@ export async function processServerLine(
737
796
  },
738
797
  instructions:
739
798
  "ghfind gives agents GitHub search: repositories, trending, npm packages, org/user profiles, repo comparison and deep-dives. " +
799
+ "ghfind_skill_search finds agent skills, either installed on this machine or in the public skills.sh ecosystem. " +
740
800
  "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.",
741
801
  }),
742
802
  );
package/src/readme.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * README retrieval.
3
+ *
4
+ * A repository's README can live at one of three URLs, and the first guess
5
+ * ("main") 404s often enough that awaiting the candidates one at a time
6
+ * triples the time before any text appears. All three are therefore fired at
7
+ * once and the first *usable* response wins.
8
+ */
9
+ import { readmeBaseUrl } from "./image-loader";
10
+
11
+ export interface Readme {
12
+ /** The raw README markdown (or reStructuredText). */
13
+ text: string;
14
+ /**
15
+ * Where the README came from. Its own URL doubles as the base for resolving
16
+ * relative image paths, so it must match the branch that actually served.
17
+ */
18
+ sourceUrl: string;
19
+ }
20
+
21
+ /**
22
+ * Candidate README locations, most likely first.
23
+ */
24
+ const CANDIDATES: ReadonlyArray<readonly [string, string]> = [
25
+ ["main", "README.md"],
26
+ ["master", "README.md"],
27
+ ["main", "README.rst"],
28
+ ];
29
+
30
+ export async function fetchReadme(
31
+ owner: string,
32
+ repo: string,
33
+ opts: { token?: string; fetchImpl?: typeof fetch } = {},
34
+ ): Promise<Readme | null> {
35
+ const fetchImpl = opts.fetchImpl ?? fetch;
36
+ const headers: Record<string, string> = { "User-Agent": "ghfind/1.0" };
37
+ if (opts.token) headers.Authorization = `Bearer ${opts.token}`;
38
+
39
+ const attempts = CANDIDATES.map(async ([branch, file]) => {
40
+ const url = readmeBaseUrl(owner, repo, branch, file);
41
+ const res = await fetchImpl(url, { headers });
42
+ // Reject rather than resolve with null: Promise.any settles on the first
43
+ // *fulfilled* promise, so a 404 that resolved would beat the real README.
44
+ if (!res.ok) throw new Error(`no readme at ${url}`);
45
+ return { text: await res.text(), sourceUrl: url } satisfies Readme;
46
+ });
47
+
48
+ try {
49
+ return await Promise.any(attempts);
50
+ } catch {
51
+ // AggregateError: every candidate failed.
52
+ return null;
53
+ }
54
+ }
@@ -0,0 +1,445 @@
1
+ /**
2
+ * skill-finder.ts — keyword search over agent skills, from two sources.
3
+ *
4
+ * Agents start a session not knowing which skills this machine has, and the
5
+ * public skills ecosystem is searched through a web page. This module gives
6
+ * both a single seam:
7
+ *
8
+ * source where results come from transport
9
+ * ─────────── ──────────────────────────────────────────────── ─────────────
10
+ * local SKILL.md files installed under the four roots filesystem
11
+ * registry skills.sh public ecosystem HTTPS (public)
12
+ *
13
+ * Everything here is read-only: nothing is downloaded, installed, or written.
14
+ * Registry results carry the `npx skills add` command for a human or agent to
15
+ * run deliberately.
16
+ */
17
+ import { readdirSync, readFileSync } from "node:fs";
18
+ import type { Dirent } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { delimiter, join } from "node:path";
21
+
22
+ // ─── Types ────────────────────────────────────────────────────────────────
23
+
24
+ /** Where an installed skill was found, and what it says about itself. */
25
+ export interface LocalSkillHit {
26
+ /** The skill's frontmatter `name`. Also the `ghfind skill <name>` selector. */
27
+ name: string;
28
+ /** One-line summary from frontmatter (newlines collapsed). */
29
+ description: string;
30
+ /** Absolute path to the SKILL.md that won de-duplication. */
31
+ path: string;
32
+ /** Human-readable root the skill came from (home abbreviated to `~`). */
33
+ origin: string;
34
+ }
35
+
36
+ /** One skill from the public skills.sh ecosystem. */
37
+ export interface RegistrySkillHit {
38
+ /** `owner/repo/skillId` — the unique ecosystem id. */
39
+ id: string;
40
+ /** Source repository, `owner/repo`. */
41
+ source: string;
42
+ /** Skill id within that repository. */
43
+ skillId: string;
44
+ /** Display name (the ecosystem sometimes supplies a title-case name). */
45
+ name: string;
46
+ /** Install count, the ecosystem's popularity signal. */
47
+ installs: number;
48
+ /** Command a human or agent can run to install it. */
49
+ installCommand: string;
50
+ /** Page for the skill on skills.sh. */
51
+ url: string;
52
+ }
53
+
54
+ export interface LocalSkillSearchResult {
55
+ query: string;
56
+ source: "local";
57
+ count: number;
58
+ skills: LocalSkillHit[];
59
+ }
60
+
61
+ export interface RegistrySkillSearchResult {
62
+ query: string;
63
+ source: "registry";
64
+ count: number;
65
+ skills: RegistrySkillHit[];
66
+ }
67
+
68
+ /** A skill search outcome, discriminated by `source`. */
69
+ export type SkillSearchResult =
70
+ LocalSkillSearchResult | RegistrySkillSearchResult;
71
+
72
+ export type SkillSource = "local" | "registry";
73
+
74
+ /** The skills.sh search endpoint (public, no auth). */
75
+ export const REGISTRY_SEARCH_ENDPOINT = "https://skills.sh/api/search";
76
+
77
+ /** Shortest accepted query. skills.sh rejects anything shorter with HTTP 400. */
78
+ export const MIN_QUERY_LENGTH = 2;
79
+
80
+ /** Result cap, applied to both sources. */
81
+ export const MAX_SKILL_RESULTS = 100;
82
+
83
+ // ─── Local roots ──────────────────────────────────────────────────────────
84
+
85
+ /** One scan root: where it is, and the label results carry. */
86
+ interface SkillRoot {
87
+ path: string;
88
+ label: string;
89
+ }
90
+
91
+ /** Abbreviate a path for display: home becomes `~`, separators become `/`. */
92
+ function displayPath(path: string): string {
93
+ const home = homedir();
94
+ const normalized = path.replace(/\\/g, "/");
95
+ const homeNorm = home.replace(/\\/g, "/").replace(/\/$/, "");
96
+ if (normalized === homeNorm) return "~";
97
+ if (normalized.startsWith(`${homeNorm}/`)) {
98
+ return `~${normalized.slice(homeNorm.length)}`;
99
+ }
100
+ return normalized;
101
+ }
102
+
103
+ /**
104
+ * The four roots scanned for installed skills, highest priority first.
105
+ *
106
+ * User-level roots outrank project-level ones, and `~/.codex/skills` outranks
107
+ * `~/.agents/skills`: the two overlap heavily on real machines, and the
108
+ * Codex-managed copy is the one its own tooling reads.
109
+ */
110
+ export function defaultSkillRoots(cwd: string = process.cwd()): string[] {
111
+ const home = homedir();
112
+ return [
113
+ join(home, ".codex", "skills"),
114
+ join(home, ".agents", "skills"),
115
+ join(cwd, ".codex", "skills"),
116
+ join(cwd, ".github", "skills"),
117
+ ];
118
+ }
119
+
120
+ /**
121
+ * Scan roots in priority order. `GHFIND_SKILL_ROOTS` overrides the defaults —
122
+ * the seam tests and the MCP tool use it to point at a fixture directory
123
+ * instead of the real machine. It is split on the platform delimiter only
124
+ * (`;` on Windows, `:` elsewhere), so a Windows drive letter survives.
125
+ */
126
+ export function resolveSkillRoots(cwd: string = process.cwd()): SkillRoot[] {
127
+ const override = process.env.GHFIND_SKILL_ROOTS;
128
+ const paths = override?.trim()
129
+ ? override
130
+ .split(delimiter)
131
+ .filter((p) => p.trim())
132
+ .map((p) => p.trim())
133
+ : defaultSkillRoots(cwd);
134
+ return paths.map((path) => ({ path, label: displayPath(path) }));
135
+ }
136
+
137
+ // ─── Frontmatter ──────────────────────────────────────────────────────────
138
+
139
+ /** Directories never worth walking into. Hidden dirs ARE walked (`.system`). */
140
+ const SKIP_DIRS = new Set(["node_modules", ".git"]);
141
+
142
+ /** Cap on directory depth, so a symlink loop cannot hang a scan. */
143
+ const MAX_DEPTH = 4;
144
+
145
+ /** Strip one layer of matching quotes from a frontmatter scalar. */
146
+ function unquote(value: string): string {
147
+ const trimmed = value.trim();
148
+ if (
149
+ trimmed.length >= 2 &&
150
+ ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
151
+ (trimmed.startsWith("'") && trimmed.endsWith("'")))
152
+ ) {
153
+ return trimmed.slice(1, -1).trim();
154
+ }
155
+ return trimmed;
156
+ }
157
+
158
+ /**
159
+ * Read `name` and `description` out of a SKILL.md frontmatter block.
160
+ *
161
+ * Handles the three shapes real skills use: plain scalars, quoted scalars
162
+ * (`name: "imagegen"`), and folded/literal blocks (`description: >` followed
163
+ * by indented lines). Returns null when there is no frontmatter block.
164
+ */
165
+ export function parseSkillFrontmatter(
166
+ text: string,
167
+ ): { name: string; description: string } | null {
168
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
169
+ if (!match) return null;
170
+ const lines = match[1].split(/\r?\n/);
171
+ const fields: Record<string, string> = {};
172
+ for (let i = 0; i < lines.length; i++) {
173
+ const field = /^([A-Za-z_][\w-]*):[ \t]*(.*)$/.exec(lines[i]);
174
+ if (!field) continue;
175
+ const key = field[1].toLowerCase();
176
+ const inline = field[2].trim();
177
+ if (inline === ">" || inline === "|" || /^[>|][-+]?$/.test(inline)) {
178
+ // Block scalar: gather the following more-indented lines.
179
+ const literal = inline.startsWith("|");
180
+ const parts: string[] = [];
181
+ while (i + 1 < lines.length && /^[ \t]+\S/.test(lines[i + 1])) {
182
+ parts.push(lines[++i].trim());
183
+ }
184
+ fields[key] = literal ? parts.join("\n") : parts.join(" ");
185
+ } else {
186
+ fields[key] = unquote(inline);
187
+ }
188
+ }
189
+ const name = fields.name ?? "";
190
+ if (!name) return null;
191
+ return { name, description: fields.description ?? "" };
192
+ }
193
+
194
+ // ─── Local scan ───────────────────────────────────────────────────────────
195
+
196
+ /**
197
+ * Walk the roots and collect every readable SKILL.md, de-duplicated by name.
198
+ *
199
+ * The first root to claim a name wins; within a root the shallowest path wins
200
+ * (breadth-first order). Unreadable directories and malformed files are skipped
201
+ * rather than failing the whole scan — a scan is best-effort by nature.
202
+ */
203
+ export function scanLocalSkills(
204
+ roots: SkillRoot[] = resolveSkillRoots(),
205
+ ): LocalSkillHit[] {
206
+ const byName = new Map<string, LocalSkillHit>();
207
+ for (const root of roots) {
208
+ for (const dir of walkDirs(root.path)) {
209
+ const entries = listDir(dir);
210
+ if (!entries) continue;
211
+ if (!entries.some((e) => e.isFile() && e.name === "SKILL.md")) continue;
212
+ const file = join(dir, "SKILL.md");
213
+ try {
214
+ const parsed = parseSkillFrontmatter(readFileSync(file, "utf-8"));
215
+ if (!parsed || byName.has(parsed.name)) continue;
216
+ byName.set(parsed.name, {
217
+ name: parsed.name,
218
+ description: parsed.description.replace(/\s+/g, " ").trim(),
219
+ path: file,
220
+ origin: root.label,
221
+ });
222
+ } catch {
223
+ // unreadable file — skip it, the rest of the scan still stands
224
+ }
225
+ }
226
+ }
227
+ return [...byName.values()];
228
+ }
229
+
230
+ /** Directory entries, or null when the directory is missing or unreadable. */
231
+ function listDir(dir: string): Dirent[] | null {
232
+ try {
233
+ return readdirSync(dir, { withFileTypes: true }) as Dirent[];
234
+ } catch {
235
+ return null;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Directories under `root`, breadth-first and depth-capped so a symlink loop
241
+ * cannot hang a scan. `node_modules` and `.git` are pruned; other hidden
242
+ * directories (e.g. `.system`) are walked.
243
+ */
244
+ function* walkDirs(root: string): Generator<string> {
245
+ const queue: Array<[string, number]> = [[root, 0]];
246
+ while (queue.length > 0) {
247
+ const [dir, depth] = queue.shift() as [string, number];
248
+ const entries = listDir(dir);
249
+ if (!entries) continue;
250
+ yield dir;
251
+ if (depth >= MAX_DEPTH) continue;
252
+ for (const entry of entries) {
253
+ if (!entry.isDirectory() || SKIP_DIRS.has(entry.name)) continue;
254
+ queue.push([join(dir, entry.name), depth + 1]);
255
+ }
256
+ }
257
+ }
258
+
259
+ // ─── Ranking ──────────────────────────────────────────────────────────────
260
+
261
+ /** Match tiers, best first: exact name, name prefix, name, description. */
262
+ function localMatchTier(hit: LocalSkillHit, needle: string): number | null {
263
+ const name = hit.name.toLowerCase();
264
+ if (name === needle) return 0;
265
+ if (name.startsWith(needle)) return 1;
266
+ if (name.includes(needle)) return 2;
267
+ if (hit.description.toLowerCase().includes(needle)) return 3;
268
+ return null;
269
+ }
270
+
271
+ /** Filter + rank local skills for a query. */
272
+ export function rankLocalSkills(
273
+ hits: LocalSkillHit[],
274
+ query: string,
275
+ limit: number = MAX_SKILL_RESULTS,
276
+ ): LocalSkillHit[] {
277
+ const needle = query.trim().toLowerCase();
278
+ return hits
279
+ .map((hit) => ({ hit, tier: localMatchTier(hit, needle) }))
280
+ .filter(
281
+ (entry): entry is { hit: LocalSkillHit; tier: number } =>
282
+ entry.tier !== null,
283
+ )
284
+ .sort((a, b) => a.tier - b.tier || a.hit.name.localeCompare(b.hit.name))
285
+ .slice(0, clampLimit(limit))
286
+ .map((entry) => entry.hit);
287
+ }
288
+
289
+ /** Clamp a result limit into 1..MAX_SKILL_RESULTS. */
290
+ export function clampLimit(limit: number): number {
291
+ const n = Math.trunc(Number(limit));
292
+ if (!Number.isFinite(n)) return MAX_SKILL_RESULTS;
293
+ return Math.min(Math.max(n, 1), MAX_SKILL_RESULTS);
294
+ }
295
+
296
+ /** Throw for queries both sources reject, before any work is done. */
297
+ export function assertSearchableQuery(query: string): string {
298
+ const trimmed = query.trim();
299
+ if (trimmed.length < MIN_QUERY_LENGTH) {
300
+ throw new Error(
301
+ `Query must be at least ${MIN_QUERY_LENGTH} characters (got "${trimmed}").`,
302
+ );
303
+ }
304
+ return trimmed;
305
+ }
306
+
307
+ // ─── Search entry points ──────────────────────────────────────────────────
308
+
309
+ /** Search skills installed on this machine. */
310
+ export function searchLocalSkills(
311
+ query: string,
312
+ opts: { limit?: number; roots?: SkillRoot[] } = {},
313
+ ): LocalSkillSearchResult {
314
+ const trimmed = assertSearchableQuery(query);
315
+ const skills = rankLocalSkills(
316
+ scanLocalSkills(opts.roots ?? resolveSkillRoots()),
317
+ trimmed,
318
+ opts.limit ?? MAX_SKILL_RESULTS,
319
+ );
320
+ return { query: trimmed, source: "local", count: skills.length, skills };
321
+ }
322
+
323
+ /** Normalize one skills.sh search result. */
324
+ function normalizeRegistrySkill(raw: unknown): RegistrySkillHit | null {
325
+ if (typeof raw !== "object" || raw === null) return null;
326
+ const obj = raw as Record<string, unknown>;
327
+ const source = typeof obj.source === "string" ? obj.source : "";
328
+ const skillId = typeof obj.skillId === "string" ? obj.skillId : "";
329
+ if (!source || !skillId) return null;
330
+ const name =
331
+ typeof obj.name === "string" && obj.name.trim() ? obj.name : skillId;
332
+ const installs = typeof obj.installs === "number" ? obj.installs : 0;
333
+ const id =
334
+ typeof obj.id === "string" && obj.id ? obj.id : `${source}/${skillId}`;
335
+ return {
336
+ id,
337
+ source,
338
+ skillId,
339
+ name,
340
+ installs,
341
+ installCommand: `npx skills add ${source}@${skillId}`,
342
+ url: `https://skills.sh/${source}/${skillId}`,
343
+ };
344
+ }
345
+
346
+ /**
347
+ * Search the public skills.sh ecosystem.
348
+ *
349
+ * Read-only: results carry the command to install, never an install. Network
350
+ * and protocol failures surface as plain Errors with a user-facing message.
351
+ */
352
+ export async function searchRegistrySkills(
353
+ query: string,
354
+ opts: { limit?: number; fetchImpl?: typeof fetch } = {},
355
+ ): Promise<RegistrySkillSearchResult> {
356
+ const trimmed = assertSearchableQuery(query);
357
+ const limit = clampLimit(opts.limit ?? MAX_SKILL_RESULTS);
358
+ const fetchImpl = opts.fetchImpl ?? fetch;
359
+ const url = `${REGISTRY_SEARCH_ENDPOINT}?q=${encodeURIComponent(trimmed)}&limit=${limit}`;
360
+
361
+ let res: Response;
362
+ try {
363
+ res = await fetchImpl(url, {
364
+ headers: { Accept: "application/json", "User-Agent": "ghfind/1.0" },
365
+ });
366
+ } catch (err) {
367
+ throw new Error(
368
+ `Could not reach skills.sh: ${err instanceof Error ? err.message : String(err)}`,
369
+ );
370
+ }
371
+ if (!res.ok) {
372
+ throw new Error(
373
+ `skills.sh returned HTTP ${res.status} for "${trimmed}". Retry, or search locally with --local.`,
374
+ );
375
+ }
376
+
377
+ let payload: unknown;
378
+ try {
379
+ payload = await res.json();
380
+ } catch {
381
+ throw new Error("skills.sh returned a response that was not JSON.");
382
+ }
383
+ const items = (payload as { skills?: unknown[] })?.skills ?? [];
384
+ const skills = items
385
+ .map(normalizeRegistrySkill)
386
+ .filter((s): s is RegistrySkillHit => s !== null)
387
+ .slice(0, limit);
388
+ return { query: trimmed, source: "registry", count: skills.length, skills };
389
+ }
390
+
391
+ /** Search skills by source — the single entry point CLI and MCP share. */
392
+ export async function searchSkills(
393
+ query: string,
394
+ opts: {
395
+ source?: SkillSource;
396
+ limit?: number;
397
+ roots?: SkillRoot[];
398
+ fetchImpl?: typeof fetch;
399
+ } = {},
400
+ ): Promise<SkillSearchResult> {
401
+ if ((opts.source ?? "local") === "registry") {
402
+ return searchRegistrySkills(query, opts);
403
+ }
404
+ return searchLocalSkills(query, opts);
405
+ }
406
+
407
+ // ─── Formatting ───────────────────────────────────────────────────────────
408
+
409
+ /** JSON payload for either source (the shape agents should parse). */
410
+ export function formatSkillSearchJson(result: SkillSearchResult): string {
411
+ return JSON.stringify(result, null, 2);
412
+ }
413
+
414
+ /** Trim a description to one readable line for text output. */
415
+ function shortDescription(description: string, width = 100): string {
416
+ if (description.length <= width) return description;
417
+ return `${description.slice(0, width - 1).trimEnd()}…`;
418
+ }
419
+
420
+ /** Human/agent-readable text for a search result. */
421
+ export function formatSkillSearchText(result: SkillSearchResult): string {
422
+ if (result.count === 0) {
423
+ return result.source === "local"
424
+ ? `No installed skills match "${result.query}". Try --remote to search the skills.sh ecosystem.`
425
+ : `No skills.sh entries match "${result.query}".`;
426
+ }
427
+ if (result.source === "local") {
428
+ return result.skills
429
+ .map(
430
+ (s) => `${s.name} (${s.origin})\n ${shortDescription(s.description)}`,
431
+ )
432
+ .join("\n");
433
+ }
434
+ return result.skills
435
+ .map(
436
+ (s) =>
437
+ `${s.name} ${s.installs.toLocaleString()} installs [${s.source}]\n ${s.installCommand}`,
438
+ )
439
+ .join("\n");
440
+ }
441
+
442
+ /** Names only — the pipe-friendly form (`ghfind skill search x --names`). */
443
+ export function formatSkillSearchNames(result: SkillSearchResult): string {
444
+ return result.skills.map((s) => s.name).join("\n");
445
+ }