github-search-cli 9.7.1 → 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/CHANGELOG.md +80 -0
- package/README.md +79 -24
- package/completions/ghfind.bash +5 -1
- package/completions/ghfind.fish +6 -1
- package/completions/ghfind.zsh +14 -1
- package/dist/cli.js +1506 -264
- package/package.json +7 -2
- package/release-notes/9.8.0.md +55 -0
- package/release-notes/9.8.1.md +74 -0
- package/src/agent-skill.ts +14 -0
- package/src/cli.ts +413 -1
- package/src/help.ts +2 -1
- package/src/image-loader.ts +277 -0
- package/src/image-render.ts +209 -0
- package/src/markdown-images.ts +123 -0
- package/src/markdown-view.ts +316 -0
- package/src/mcp-server.ts +60 -0
- package/src/png.ts +260 -0
- package/src/readme.ts +54 -0
- package/src/skill-finder.ts +445 -0
- package/src/tui.ts +72 -50
- package/src/markdown-render.ts +0 -211
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
|
+
}
|
package/src/tui.ts
CHANGED
|
@@ -74,7 +74,9 @@ import {
|
|
|
74
74
|
import { saveSession, restoreSession } from "./session";
|
|
75
75
|
import { fetchDeepDive, buildDeepDiveText } from "./deepdive";
|
|
76
76
|
import { buildComparisonTable } from "./compare";
|
|
77
|
-
import {
|
|
77
|
+
import { createMarkdownView } from "./markdown-view";
|
|
78
|
+
import { fetchImageBytes } from "./image-loader";
|
|
79
|
+
import { fetchReadme } from "./readme";
|
|
78
80
|
import { fetchTopics } from "./explore";
|
|
79
81
|
import { exportToFile, type ExportFormat } from "./output";
|
|
80
82
|
import {
|
|
@@ -1079,12 +1081,22 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1079
1081
|
paddingLeft: 2,
|
|
1080
1082
|
paddingRight: 2,
|
|
1081
1083
|
});
|
|
1082
|
-
|
|
1084
|
+
// Version header only — the release notes below it are rendered markdown.
|
|
1085
|
+
const updateHeader = new TextRenderable(renderer, {
|
|
1083
1086
|
content: "",
|
|
1084
1087
|
fg: colors.accent,
|
|
1085
1088
|
bg: colors.surfaceDim,
|
|
1086
1089
|
});
|
|
1087
|
-
|
|
1090
|
+
const updateNotes = createMarkdownView({
|
|
1091
|
+
renderer,
|
|
1092
|
+
colors,
|
|
1093
|
+
// Notes ship with the release and are mostly prose/lists; skip the
|
|
1094
|
+
// image pipeline in this narrow panel.
|
|
1095
|
+
images: false,
|
|
1096
|
+
background: colors.surfaceDim,
|
|
1097
|
+
});
|
|
1098
|
+
// ponytail: the notes are markdown now; keep the content a column so
|
|
1099
|
+
// blocks stack and the scroll box can measure them.
|
|
1088
1100
|
const updateScroll = new ScrollBoxRenderable(renderer, {
|
|
1089
1101
|
flexGrow: 1,
|
|
1090
1102
|
backgroundColor: colors.surfaceDim,
|
|
@@ -1187,7 +1199,8 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1187
1199
|
paddingX: 1,
|
|
1188
1200
|
});
|
|
1189
1201
|
updateBox.add(updateScroll);
|
|
1190
|
-
updateScroll.add(
|
|
1202
|
+
updateScroll.add(updateHeader);
|
|
1203
|
+
updateScroll.add(updateNotes.renderable);
|
|
1191
1204
|
updateBox.add(updateOptionsRow);
|
|
1192
1205
|
updateBox.add(updateFooter);
|
|
1193
1206
|
root.add(updateDim);
|
|
@@ -1597,17 +1610,11 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1597
1610
|
if (state.lastInstalledVersion === currentVersion) return;
|
|
1598
1611
|
// We just upgraded — show notes for the new version
|
|
1599
1612
|
const notes = fetchReleaseNotes(currentVersion);
|
|
1600
|
-
// Box is 55% of the terminal; subtract border + padding so wrapped
|
|
1601
|
-
// lines never clip horizontally.
|
|
1602
|
-
const notesWidth = Math.max(
|
|
1603
|
-
30,
|
|
1604
|
-
Math.floor((process.stdout.columns || 80) * 0.55) - 8,
|
|
1605
|
-
);
|
|
1606
|
-
const notesBlock = notes
|
|
1607
|
-
? renderMarkdown(notes, { width: notesWidth })
|
|
1608
|
-
: " No release notes found for this version.";
|
|
1609
1613
|
updateSelectedOption = 0;
|
|
1610
|
-
|
|
1614
|
+
updateHeader.content = ` Updated ghfind ${state.lastInstalledVersion} → ${currentVersion}\n`;
|
|
1615
|
+
updateNotes.setContent(
|
|
1616
|
+
notes ?? "_No release notes found for this version._",
|
|
1617
|
+
);
|
|
1611
1618
|
renderUpdateOptions();
|
|
1612
1619
|
showOverlay("update");
|
|
1613
1620
|
}
|
|
@@ -1625,14 +1632,8 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1625
1632
|
if (latest) {
|
|
1626
1633
|
updateSelectedOption = 0;
|
|
1627
1634
|
const notes = fetchReleaseNotes(latest);
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
Math.floor((process.stdout.columns || 80) * 0.55) - 8,
|
|
1631
|
-
);
|
|
1632
|
-
const notesBlock = notes
|
|
1633
|
-
? renderMarkdown(notes, { width: notesWidth })
|
|
1634
|
-
: ` A new version is available\n\n Current: ${currentVersion}\n Latest: ${latest}\n\n Run "npm install -g ghfind"`;
|
|
1635
|
-
updateText.content = ` A new version is available\n\n Current: ${currentVersion}\n Latest: ${latest}\n\n${notesBlock}`;
|
|
1635
|
+
updateHeader.content = ` A new version is available\n\n Current: ${currentVersion}\n Latest: ${latest}\n`;
|
|
1636
|
+
updateNotes.setContent(notes ?? "Run `npm install -g ghfind` to update.");
|
|
1636
1637
|
renderUpdateOptions();
|
|
1637
1638
|
showOverlay("update");
|
|
1638
1639
|
} else {
|
|
@@ -1852,15 +1853,22 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1852
1853
|
},
|
|
1853
1854
|
},
|
|
1854
1855
|
});
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1856
|
+
// Full markdown rendering (headings, tables, code, inline images).
|
|
1857
|
+
const readmeView = createMarkdownView({
|
|
1858
|
+
renderer,
|
|
1859
|
+
colors,
|
|
1860
|
+
loadImage: (url) => fetchImageBytes(url, { token: githubToken }),
|
|
1861
|
+
getImageWidth: () => Math.max(24, (process.stdout.columns || 80) - 12),
|
|
1859
1862
|
});
|
|
1860
|
-
readmeScroll.add(
|
|
1863
|
+
readmeScroll.add(readmeView.renderable);
|
|
1861
1864
|
readmeBox.add(readmeScroll);
|
|
1865
|
+
|
|
1866
|
+
const readmeFooterText = () =>
|
|
1867
|
+
` ↑↓/jk scroll PgUp/PgDn page i images: ${
|
|
1868
|
+
readmeView.imagesEnabled ? "on" : "off"
|
|
1869
|
+
} Esc/q close`;
|
|
1862
1870
|
const readmeFooter = new TextRenderable(renderer, {
|
|
1863
|
-
content:
|
|
1871
|
+
content: readmeFooterText(),
|
|
1864
1872
|
fg: colors.muted,
|
|
1865
1873
|
bg: colors.bg,
|
|
1866
1874
|
height: 1,
|
|
@@ -1876,33 +1884,21 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1876
1884
|
setStatus("No repo selected");
|
|
1877
1885
|
return;
|
|
1878
1886
|
}
|
|
1879
|
-
|
|
1887
|
+
readmeView.setContent(`Loading README for ${repo.fullName}…`);
|
|
1888
|
+
readmeFooter.content = readmeFooterText();
|
|
1889
|
+
readmeScroll.scrollTop = 0;
|
|
1880
1890
|
showOverlay("readme");
|
|
1881
1891
|
try {
|
|
1882
|
-
const
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
`${repo.owner}/${repo.name}/master/README.md`,
|
|
1888
|
-
`${repo.owner}/${repo.name}/main/README.rst`,
|
|
1889
|
-
]) {
|
|
1890
|
-
const r = await fetch(`https://raw.githubusercontent.com/${path}`, {
|
|
1891
|
-
headers,
|
|
1892
|
-
});
|
|
1893
|
-
if (r.ok) {
|
|
1894
|
-
text = await r.text();
|
|
1895
|
-
break;
|
|
1896
|
-
}
|
|
1897
|
-
}
|
|
1898
|
-
if (!text) {
|
|
1899
|
-
readmeText.content = ` (no README found for ${repo.fullName})`;
|
|
1892
|
+
const readme = await fetchReadme(repo.owner, repo.name, {
|
|
1893
|
+
token: githubToken,
|
|
1894
|
+
});
|
|
1895
|
+
if (readme) {
|
|
1896
|
+
readmeView.setContent(readme.text, readme.sourceUrl);
|
|
1900
1897
|
} else {
|
|
1901
|
-
|
|
1902
|
-
readmeText.content = renderMarkdown(text, { width: termW });
|
|
1898
|
+
readmeView.setContent(`_(no README found for ${repo.fullName})_`);
|
|
1903
1899
|
}
|
|
1904
1900
|
} catch {
|
|
1905
|
-
|
|
1901
|
+
readmeView.setContent("_Failed to load README._");
|
|
1906
1902
|
}
|
|
1907
1903
|
renderer.requestRender();
|
|
1908
1904
|
}
|
|
@@ -2505,6 +2501,24 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
2505
2501
|
}
|
|
2506
2502
|
});
|
|
2507
2503
|
|
|
2504
|
+
// Pasted content flows through "input"; purge JSON-shaped blobs that
|
|
2505
|
+
// slipped into the query (a search line is never raw JSON: objects/arrays
|
|
2506
|
+
// always arrive as paste payloads from API responses / deep-dive output).
|
|
2507
|
+
searchInput.on("input", () => {
|
|
2508
|
+
const v = searchInput.value;
|
|
2509
|
+
if (v.length > 0) {
|
|
2510
|
+
const t = v.trim();
|
|
2511
|
+
if (
|
|
2512
|
+
(t.startsWith("{") && t.endsWith("}")) ||
|
|
2513
|
+
(t.startsWith("[") && t.endsWith("]"))
|
|
2514
|
+
) {
|
|
2515
|
+
searchInput.value = "";
|
|
2516
|
+
setStatus("Ignored JSON paste — search queries accept plain text");
|
|
2517
|
+
renderer.requestRender();
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
});
|
|
2521
|
+
|
|
2508
2522
|
// Navigate results → update detail pane
|
|
2509
2523
|
resultsSelect.on("selectionChanged", () => {
|
|
2510
2524
|
const opt = resultsSelect.getSelectedOption();
|
|
@@ -2606,6 +2620,14 @@ ${pack.description ?? ""}`;
|
|
|
2606
2620
|
|
|
2607
2621
|
// README viewer: scrollable, Esc/q already handled above
|
|
2608
2622
|
if (currentOverlay === "readme") {
|
|
2623
|
+
if (key.name === "i") {
|
|
2624
|
+
readmeView.setImagesEnabled(!readmeView.imagesEnabled);
|
|
2625
|
+
readmeFooter.content = readmeFooterText();
|
|
2626
|
+
setStatus(
|
|
2627
|
+
readmeView.imagesEnabled ? "README images on" : "README images off",
|
|
2628
|
+
);
|
|
2629
|
+
renderer.requestRender();
|
|
2630
|
+
}
|
|
2609
2631
|
return;
|
|
2610
2632
|
}
|
|
2611
2633
|
|