github-search-cli 9.8.0 → 9.8.2

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/src/output.ts CHANGED
@@ -136,18 +136,6 @@ export async function pipeExec(repos: Repo[], target: string): Promise<void> {
136
136
  }
137
137
  }
138
138
 
139
- // ─── Backward-compatible re-exports ─────────────────────────────────────
139
+ // ─── Narrowed aliases ──────────────────────────────────────────────────
140
140
 
141
141
  export type ExportFormat = "json" | "csv" | "markdown" | "text";
142
- export type FormatLine =
143
- "urls" | "names" | "ssh-urls" | "clone-commands" | "ids";
144
-
145
- /** @deprecated Use `format()` instead. */
146
- export function formatRepos(repos: Repo[], fmt: ExportFormat): string {
147
- return format(repos, fmt);
148
- }
149
-
150
- /** @deprecated Use `format()` instead. */
151
- export function formatLines(repos: Repo[], fmt: FormatLine): string {
152
- return format(repos, fmt);
153
- }
package/src/readme.ts ADDED
@@ -0,0 +1,70 @@
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
+ /**
31
+ * Headers for a README request. raw.githubusercontent.com is not the GitHub
32
+ * API, so the token is only ever attached to api.github.com — and only for
33
+ * private repos, which are the sole reason to authenticate a README fetch.
34
+ */
35
+ export function buildReadmeHeaders(
36
+ url: string,
37
+ token: string | undefined,
38
+ isPrivate = false,
39
+ ): Record<string, string> {
40
+ const headers: Record<string, string> = { "User-Agent": "ghfind/1.0" };
41
+ if (token && isPrivate && new URL(url).origin === "https://api.github.com") {
42
+ headers.Authorization = `Bearer ${token}`;
43
+ }
44
+ return headers;
45
+ }
46
+
47
+ export async function fetchReadme(
48
+ owner: string,
49
+ repo: string,
50
+ opts: { token?: string; private?: boolean; fetchImpl?: typeof fetch } = {},
51
+ ): Promise<Readme | null> {
52
+ const fetchImpl = opts.fetchImpl ?? fetch;
53
+
54
+ const attempts = CANDIDATES.map(async ([branch, file]) => {
55
+ const url = readmeBaseUrl(owner, repo, branch, file);
56
+ const headers = buildReadmeHeaders(url, opts.token, opts.private);
57
+ const res = await fetchImpl(url, { headers });
58
+ // Reject rather than resolve with null: Promise.any settles on the first
59
+ // *fulfilled* promise, so a 404 that resolved would beat the real README.
60
+ if (!res.ok) throw new Error(`no readme at ${url}`);
61
+ return { text: await res.text(), sourceUrl: url } satisfies Readme;
62
+ });
63
+
64
+ try {
65
+ return await Promise.any(attempts);
66
+ } catch {
67
+ // AggregateError: every candidate failed.
68
+ return null;
69
+ }
70
+ }
package/src/releases.ts CHANGED
@@ -5,6 +5,8 @@ import { loadConfig } from "./config";
5
5
 
6
6
  const RELEASES_FILE = "release-cache.json";
7
7
  const PER_REPO = 5;
8
+ /** Bookmarks checked in parallel — enough to be fast, few enough to stay polite. */
9
+ const CONCURRENCY = 6;
8
10
 
9
11
  export interface TrackedRelease {
10
12
  id: number;
@@ -87,47 +89,47 @@ export async function checkReleases(
87
89
  error?: string;
88
90
  }> = [];
89
91
 
90
- for (const bm of bookmarks) {
92
+ const checkOne = async (
93
+ fullName: string,
94
+ ): Promise<{
95
+ fullName: string;
96
+ newReleases: TrackedRelease[];
97
+ error?: string;
98
+ }> => {
91
99
  try {
92
- const prev = cache[bm.repo.fullName];
93
- const lastSeen = getLastSeen(bm.repo.fullName);
100
+ const prev = cache[fullName];
101
+ const lastSeen = getLastSeen(fullName);
94
102
  const isNew = (r: TrackedRelease) =>
95
103
  !r.publishedAt || new Date(r.publishedAt).getTime() > lastSeen;
96
104
 
97
105
  const { status, etag, releases } = await fetchReleases(
98
- bm.repo.fullName,
106
+ fullName,
99
107
  prev?.etag,
100
108
  token,
101
109
  );
102
110
 
103
111
  if (status === 304 && prev) {
104
112
  // Unchanged upstream; recompute against lastSeenAt (it may have moved).
105
- results.push({
106
- fullName: bm.repo.fullName,
107
- newReleases: prev.releases.filter(isNew),
108
- });
109
- continue;
113
+ return { fullName, newReleases: prev.releases.filter(isNew) };
110
114
  }
111
115
  if (status !== 200 || !releases) {
112
- results.push({
113
- fullName: bm.repo.fullName,
114
- newReleases: [],
115
- error: `HTTP ${status}`,
116
- });
117
- continue;
116
+ return { fullName, newReleases: [], error: `HTTP ${status}` };
118
117
  }
119
- cache[bm.repo.fullName] = { checkedAt: Date.now(), etag, releases };
120
- results.push({
121
- fullName: bm.repo.fullName,
122
- newReleases: releases.filter(isNew),
123
- });
118
+ cache[fullName] = { checkedAt: Date.now(), etag, releases };
119
+ return { fullName, newReleases: releases.filter(isNew) };
124
120
  } catch (err) {
125
- results.push({
126
- fullName: bm.repo.fullName,
127
- newReleases: [],
128
- error: (err as Error).message,
129
- });
121
+ return { fullName, newReleases: [], error: (err as Error).message };
130
122
  }
123
+ };
124
+
125
+ // Run in small batches rather than one-at-a-time: a serial loop over every
126
+ // bookmark spends all its time waiting on the network, and bursts straight
127
+ // through the unauthenticated rate limit.
128
+ for (let i = 0; i < bookmarks.length; i += CONCURRENCY) {
129
+ const batch = bookmarks
130
+ .slice(i, i + CONCURRENCY)
131
+ .map((bm) => checkOne(bm.repo.fullName));
132
+ results.push(...(await Promise.all(batch)));
131
133
  }
132
134
 
133
135
  saveCache(cache);
@@ -158,9 +160,9 @@ export function allCachedReleases(): Array<
158
160
  TrackedRelease & { fullName: string }
159
161
  > {
160
162
  const cache = loadCache();
161
- const bookmarks = getBookmarks();
163
+ const bookmarked = new Set(getBookmarks().map((b) => b.repo.fullName));
162
164
  return Object.entries(cache)
163
- .filter(([fullName]) => bookmarks.some((b) => b.repo.fullName === fullName))
165
+ .filter(([fullName]) => bookmarked.has(fullName))
164
166
  .flatMap(([fullName, s]) => s.releases.map((r) => ({ ...r, fullName })))
165
167
  .sort(
166
168
  (a, b) =>
package/src/search.ts CHANGED
@@ -58,6 +58,10 @@ export class MemoryCache<T> {
58
58
  }
59
59
 
60
60
  set(key: string, data: T, ttlMs?: number): void {
61
+ // Re-inserting an existing key must move it to the end of the
62
+ // insertion-ordered Map, otherwise a refreshed entry keeps its original
63
+ // position and is the first one evicted — FIFO instead of LRU.
64
+ this.cache.delete(key);
61
65
  if (this.cache.size >= MAX_CACHE_SIZE) {
62
66
  const firstKey = this.cache.keys().next().value;
63
67
  if (firstKey !== undefined) this.cache.delete(firstKey);
@@ -0,0 +1,476 @@
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
+ /**
84
+ * The only shape accepted for registry `source` / `skillId`. Both end up
85
+ * inside a shell command (`npx skills add …`) that an agent is expected to
86
+ * run, so anything with shell metacharacters is rejected outright.
87
+ */
88
+ const SAFE_ID_RE = /^[A-Za-z0-9._/-]+$/;
89
+
90
+ // ─── Local roots ──────────────────────────────────────────────────────────
91
+
92
+ /** One scan root: where it is, and the label results carry. */
93
+ interface SkillRoot {
94
+ path: string;
95
+ label: string;
96
+ }
97
+
98
+ /** Abbreviate a path for display: home becomes `~`, separators become `/`. */
99
+ function displayPath(path: string): string {
100
+ const home = homedir();
101
+ const normalized = path.replace(/\\/g, "/");
102
+ const homeNorm = home.replace(/\\/g, "/").replace(/\/$/, "");
103
+ if (normalized === homeNorm) return "~";
104
+ if (normalized.startsWith(`${homeNorm}/`)) {
105
+ return `~${normalized.slice(homeNorm.length)}`;
106
+ }
107
+ return normalized;
108
+ }
109
+
110
+ /**
111
+ * The four roots scanned for installed skills, highest priority first.
112
+ *
113
+ * User-level roots outrank project-level ones, and `~/.codex/skills` outranks
114
+ * `~/.agents/skills`: the two overlap heavily on real machines, and the
115
+ * Codex-managed copy is the one its own tooling reads.
116
+ */
117
+ export function defaultSkillRoots(cwd: string = process.cwd()): string[] {
118
+ const home = homedir();
119
+ return [
120
+ join(home, ".codex", "skills"),
121
+ join(home, ".agents", "skills"),
122
+ join(cwd, ".codex", "skills"),
123
+ join(cwd, ".github", "skills"),
124
+ ];
125
+ }
126
+
127
+ /**
128
+ * Scan roots in priority order. `GHFIND_SKILL_ROOTS` overrides the defaults —
129
+ * the seam tests and the MCP tool use it to point at a fixture directory
130
+ * instead of the real machine. It is split on the platform delimiter only
131
+ * (`;` on Windows, `:` elsewhere), so a Windows drive letter survives.
132
+ */
133
+ export function resolveSkillRoots(cwd: string = process.cwd()): SkillRoot[] {
134
+ const override = process.env.GHFIND_SKILL_ROOTS;
135
+ const paths = override?.trim()
136
+ ? override
137
+ .split(delimiter)
138
+ .filter((p) => p.trim())
139
+ .map((p) => p.trim())
140
+ : defaultSkillRoots(cwd);
141
+ return paths.map((path) => ({ path, label: displayPath(path) }));
142
+ }
143
+
144
+ // ─── Frontmatter ──────────────────────────────────────────────────────────
145
+
146
+ /** Directories never worth walking into. Hidden dirs ARE walked (`.system`). */
147
+ const SKIP_DIRS = new Set(["node_modules", ".git"]);
148
+
149
+ /** Cap on directory depth, so a symlink loop cannot hang a scan. */
150
+ const MAX_DEPTH = 4;
151
+
152
+ /** Strip one layer of matching quotes from a frontmatter scalar. */
153
+ function unquote(value: string): string {
154
+ const trimmed = value.trim();
155
+ if (
156
+ trimmed.length >= 2 &&
157
+ ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
158
+ (trimmed.startsWith("'") && trimmed.endsWith("'")))
159
+ ) {
160
+ return trimmed.slice(1, -1).trim();
161
+ }
162
+ return trimmed;
163
+ }
164
+
165
+ /**
166
+ * Read `name` and `description` out of a SKILL.md frontmatter block.
167
+ *
168
+ * Handles the three shapes real skills use: plain scalars, quoted scalars
169
+ * (`name: "imagegen"`), and folded/literal blocks (`description: >` followed
170
+ * by indented lines). Returns null when there is no frontmatter block.
171
+ */
172
+ export function parseSkillFrontmatter(
173
+ text: string,
174
+ ): { name: string; description: string } | null {
175
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
176
+ if (!match) return null;
177
+ const lines = match[1].split(/\r?\n/);
178
+ const fields: Record<string, string> = {};
179
+ for (let i = 0; i < lines.length; i++) {
180
+ const field = /^([A-Za-z_][\w-]*):[ \t]*(.*)$/.exec(lines[i]);
181
+ if (!field) continue;
182
+ const key = field[1].toLowerCase();
183
+ const inline = field[2].trim();
184
+ if (inline === ">" || inline === "|" || /^[>|][-+]?$/.test(inline)) {
185
+ // Block scalar: gather the following more-indented lines.
186
+ const literal = inline.startsWith("|");
187
+ const parts: string[] = [];
188
+ while (i + 1 < lines.length && /^[ \t]+\S/.test(lines[i + 1])) {
189
+ parts.push(lines[++i].trim());
190
+ }
191
+ fields[key] = literal ? parts.join("\n") : parts.join(" ");
192
+ } else {
193
+ fields[key] = unquote(inline);
194
+ }
195
+ }
196
+ const name = fields.name ?? "";
197
+ if (!name) return null;
198
+ return { name, description: fields.description ?? "" };
199
+ }
200
+
201
+ // ─── Local scan ───────────────────────────────────────────────────────────
202
+
203
+ /** Memoised scan results, keyed by root path + label. */
204
+ const scanCache = new Map<string, LocalSkillHit[]>();
205
+
206
+ /**
207
+ * Walk the roots and collect every readable SKILL.md, de-duplicated by name.
208
+ *
209
+ * The first root to claim a name wins; within a root the shallowest path wins
210
+ * (breadth-first order). Unreadable directories and malformed files are skipped
211
+ * rather than failing the whole scan — a scan is best-effort by nature.
212
+ */
213
+ export function scanLocalSkills(
214
+ roots: SkillRoot[] = resolveSkillRoots(),
215
+ ): LocalSkillHit[] {
216
+ // The filesystem does not change within a process, so a repeat scan is pure
217
+ // duplicate blocking I/O. Keyed on the roots so callers passing their own
218
+ // roots (tests, GHFIND_SKILL_ROOTS) still get a distinct cache entry.
219
+ const key = roots.map((r) => `${r.path}\u0000${r.label}`).join("\u0001");
220
+ const cached = scanCache.get(key);
221
+ if (cached) return cached;
222
+ const byName = new Map<string, LocalSkillHit>();
223
+ for (const root of roots) {
224
+ for (const dir of walkDirs(root.path)) {
225
+ const entries = listDir(dir);
226
+ if (!entries) continue;
227
+ if (!entries.some((e) => e.isFile() && e.name === "SKILL.md")) continue;
228
+ const file = join(dir, "SKILL.md");
229
+ try {
230
+ const parsed = parseSkillFrontmatter(readFileSync(file, "utf-8"));
231
+ if (!parsed || byName.has(parsed.name)) continue;
232
+ byName.set(parsed.name, {
233
+ name: parsed.name,
234
+ description: parsed.description.replace(/\s+/g, " ").trim(),
235
+ path: file,
236
+ origin: root.label,
237
+ });
238
+ } catch {
239
+ // unreadable file — skip it, the rest of the scan still stands
240
+ }
241
+ }
242
+ }
243
+ const hits = [...byName.values()];
244
+ scanCache.set(key, hits);
245
+ return hits;
246
+ }
247
+
248
+ /** Directory entries, or null when the directory is missing or unreadable. */
249
+ function listDir(dir: string): Dirent[] | null {
250
+ try {
251
+ return readdirSync(dir, { withFileTypes: true }) as Dirent[];
252
+ } catch {
253
+ return null;
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Directories under `root`, breadth-first and depth-capped so a symlink loop
259
+ * cannot hang a scan. `node_modules` and `.git` are pruned; other hidden
260
+ * directories (e.g. `.system`) are walked.
261
+ */
262
+ function* walkDirs(root: string): Generator<string> {
263
+ const queue: Array<[string, number]> = [[root, 0]];
264
+ while (queue.length > 0) {
265
+ const [dir, depth] = queue.shift() as [string, number];
266
+ const entries = listDir(dir);
267
+ if (!entries) continue;
268
+ yield dir;
269
+ if (depth >= MAX_DEPTH) continue;
270
+ for (const entry of entries) {
271
+ if (!entry.isDirectory() || SKIP_DIRS.has(entry.name)) continue;
272
+ queue.push([join(dir, entry.name), depth + 1]);
273
+ }
274
+ }
275
+ }
276
+
277
+ // ─── Ranking ──────────────────────────────────────────────────────────────
278
+
279
+ /** Match tiers, best first: exact name, name prefix, name, description. */
280
+ function localMatchTier(hit: LocalSkillHit, needle: string): number | null {
281
+ const name = hit.name.toLowerCase();
282
+ if (name === needle) return 0;
283
+ if (name.startsWith(needle)) return 1;
284
+ if (name.includes(needle)) return 2;
285
+ if (hit.description.toLowerCase().includes(needle)) return 3;
286
+ return null;
287
+ }
288
+
289
+ /** Filter + rank local skills for a query. */
290
+ export function rankLocalSkills(
291
+ hits: LocalSkillHit[],
292
+ query: string,
293
+ limit: number = MAX_SKILL_RESULTS,
294
+ ): LocalSkillHit[] {
295
+ const needle = query.trim().toLowerCase();
296
+ return hits
297
+ .map((hit) => ({ hit, tier: localMatchTier(hit, needle) }))
298
+ .filter(
299
+ (entry): entry is { hit: LocalSkillHit; tier: number } =>
300
+ entry.tier !== null,
301
+ )
302
+ .sort((a, b) => a.tier - b.tier || a.hit.name.localeCompare(b.hit.name))
303
+ .slice(0, clampLimit(limit))
304
+ .map((entry) => entry.hit);
305
+ }
306
+
307
+ /** Clamp a result limit into 1..MAX_SKILL_RESULTS. */
308
+ export function clampLimit(limit: number): number {
309
+ const n = Math.trunc(Number(limit));
310
+ if (!Number.isFinite(n)) return MAX_SKILL_RESULTS;
311
+ return Math.min(Math.max(n, 1), MAX_SKILL_RESULTS);
312
+ }
313
+
314
+ /** Throw for queries both sources reject, before any work is done. */
315
+ export function assertSearchableQuery(query: string): string {
316
+ const trimmed = query.trim();
317
+ if (trimmed.length < MIN_QUERY_LENGTH) {
318
+ throw new Error(
319
+ `Query must be at least ${MIN_QUERY_LENGTH} characters (got "${trimmed}").`,
320
+ );
321
+ }
322
+ return trimmed;
323
+ }
324
+
325
+ // ─── Search entry points ──────────────────────────────────────────────────
326
+
327
+ /** Search skills installed on this machine. */
328
+ export function searchLocalSkills(
329
+ query: string,
330
+ opts: { limit?: number; roots?: SkillRoot[] } = {},
331
+ ): LocalSkillSearchResult {
332
+ const trimmed = assertSearchableQuery(query);
333
+ const skills = rankLocalSkills(
334
+ scanLocalSkills(opts.roots ?? resolveSkillRoots()),
335
+ trimmed,
336
+ opts.limit ?? MAX_SKILL_RESULTS,
337
+ );
338
+ return { query: trimmed, source: "local", count: skills.length, skills };
339
+ }
340
+
341
+ /** Normalize one skills.sh search result. */
342
+ function normalizeRegistrySkill(raw: unknown): RegistrySkillHit | null {
343
+ if (typeof raw !== "object" || raw === null) return null;
344
+ const obj = raw as Record<string, unknown>;
345
+ const source = typeof obj.source === "string" ? obj.source : "";
346
+ const skillId = typeof obj.skillId === "string" ? obj.skillId : "";
347
+ // The registry is untrusted input and these two are interpolated into a
348
+ // shell command and a URL handed to an agent, so anything but a plain
349
+ // owner/repo + skill-id shape is dropped rather than escaped.
350
+ // `SAFE_ID_RE` allows `/` and `.`, so `../` passes it — reject traversal
351
+ // segments explicitly; they would land in the URL path and the npx command.
352
+ const hasTraversal = (s: string) => s === ".." || s.includes("..");
353
+ if (
354
+ !SAFE_ID_RE.test(source) ||
355
+ !SAFE_ID_RE.test(skillId) ||
356
+ hasTraversal(source) ||
357
+ hasTraversal(skillId)
358
+ ) {
359
+ return null;
360
+ }
361
+ const name =
362
+ typeof obj.name === "string" && obj.name.trim() ? obj.name : skillId;
363
+ const installs = typeof obj.installs === "number" ? obj.installs : 0;
364
+ const id =
365
+ typeof obj.id === "string" && obj.id ? obj.id : `${source}/${skillId}`;
366
+ return {
367
+ id,
368
+ source,
369
+ skillId,
370
+ name,
371
+ installs,
372
+ installCommand: `npx skills add ${source}@${skillId}`,
373
+ url: `https://skills.sh/${source}/${skillId}`,
374
+ };
375
+ }
376
+
377
+ /**
378
+ * Search the public skills.sh ecosystem.
379
+ *
380
+ * Read-only: results carry the command to install, never an install. Network
381
+ * and protocol failures surface as plain Errors with a user-facing message.
382
+ */
383
+ export async function searchRegistrySkills(
384
+ query: string,
385
+ opts: { limit?: number; fetchImpl?: typeof fetch } = {},
386
+ ): Promise<RegistrySkillSearchResult> {
387
+ const trimmed = assertSearchableQuery(query);
388
+ const limit = clampLimit(opts.limit ?? MAX_SKILL_RESULTS);
389
+ const fetchImpl = opts.fetchImpl ?? fetch;
390
+ const url = `${REGISTRY_SEARCH_ENDPOINT}?q=${encodeURIComponent(trimmed)}&limit=${limit}`;
391
+
392
+ let res: Response;
393
+ try {
394
+ res = await fetchImpl(url, {
395
+ headers: { Accept: "application/json", "User-Agent": "ghfind/1.0" },
396
+ });
397
+ } catch (err) {
398
+ throw new Error(
399
+ `Could not reach skills.sh: ${err instanceof Error ? err.message : String(err)}`,
400
+ );
401
+ }
402
+ if (!res.ok) {
403
+ throw new Error(
404
+ `skills.sh returned HTTP ${res.status} for "${trimmed}". Retry, or search locally with --local.`,
405
+ );
406
+ }
407
+
408
+ let payload: unknown;
409
+ try {
410
+ payload = await res.json();
411
+ } catch {
412
+ throw new Error("skills.sh returned a response that was not JSON.");
413
+ }
414
+ const items = (payload as { skills?: unknown[] })?.skills ?? [];
415
+ const skills = items
416
+ .map(normalizeRegistrySkill)
417
+ .filter((s): s is RegistrySkillHit => s !== null)
418
+ .slice(0, limit);
419
+ return { query: trimmed, source: "registry", count: skills.length, skills };
420
+ }
421
+
422
+ /** Search skills by source — the single entry point CLI and MCP share. */
423
+ export async function searchSkills(
424
+ query: string,
425
+ opts: {
426
+ source?: SkillSource;
427
+ limit?: number;
428
+ roots?: SkillRoot[];
429
+ fetchImpl?: typeof fetch;
430
+ } = {},
431
+ ): Promise<SkillSearchResult> {
432
+ if ((opts.source ?? "local") === "registry") {
433
+ return searchRegistrySkills(query, opts);
434
+ }
435
+ return searchLocalSkills(query, opts);
436
+ }
437
+
438
+ // ─── Formatting ───────────────────────────────────────────────────────────
439
+
440
+ /** JSON payload for either source (the shape agents should parse). */
441
+ export function formatSkillSearchJson(result: SkillSearchResult): string {
442
+ return JSON.stringify(result, null, 2);
443
+ }
444
+
445
+ /** Trim a description to one readable line for text output. */
446
+ function shortDescription(description: string, width = 100): string {
447
+ if (description.length <= width) return description;
448
+ return `${description.slice(0, width - 1).trimEnd()}…`;
449
+ }
450
+
451
+ /** Human/agent-readable text for a search result. */
452
+ export function formatSkillSearchText(result: SkillSearchResult): string {
453
+ if (result.count === 0) {
454
+ return result.source === "local"
455
+ ? `No installed skills match "${result.query}". Try --remote to search the skills.sh ecosystem.`
456
+ : `No skills.sh entries match "${result.query}".`;
457
+ }
458
+ if (result.source === "local") {
459
+ return result.skills
460
+ .map(
461
+ (s) => `${s.name} (${s.origin})\n ${shortDescription(s.description)}`,
462
+ )
463
+ .join("\n");
464
+ }
465
+ return result.skills
466
+ .map(
467
+ (s) =>
468
+ `${s.name} ${s.installs.toLocaleString()} installs [${s.source}]\n ${s.installCommand}`,
469
+ )
470
+ .join("\n");
471
+ }
472
+
473
+ /** Names only — the pipe-friendly form (`ghfind skill search x --names`). */
474
+ export function formatSkillSearchNames(result: SkillSearchResult): string {
475
+ return result.skills.map((s) => s.name).join("\n");
476
+ }