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/deepdive.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /** Repo deep-dive — fetch and format rich repo details. */
2
2
  import type { Repo } from "./types";
3
+ import { fetchReadme } from "./readme";
3
4
 
4
5
  const USER_AGENT = "ghfind/1.0";
5
6
 
@@ -56,11 +57,29 @@ export function repoFromRef(fullName: string): Repo {
56
57
  };
57
58
  }
58
59
 
60
+ /** Repo payload as returned by GET /repos/{owner}/{repo}. */
61
+ interface GitHubRepoApi {
62
+ description: string | null;
63
+ html_url: string;
64
+ stargazers_count: number;
65
+ forks_count: number;
66
+ subscribers_count?: number;
67
+ language: string | null;
68
+ topics?: string[];
69
+ archived: boolean;
70
+ fork: boolean;
71
+ private?: boolean;
72
+ created_at: string;
73
+ updated_at: string;
74
+ pushed_at: string;
75
+ }
76
+
59
77
  /**
60
78
  * Resolve a repo ref to a real Repo by fetching canonical metadata from the
61
- * GitHub API. Returns a stub via repoFromRef when the metadata fetch fails
62
- * (offline, rate limit) — the ref is still validated first. Deep-dive
63
- * sub-fetches tolerate missing data either way.
79
+ * GitHub API. The ref is validated first. Only transport-level failures
80
+ * (offline, unparseable body) degrade to a stub via repoFromRef — an auth
81
+ * error or a rate limit is surfaced, because a stub with `stars: 0` is
82
+ * indistinguishable from a real repo with no data.
64
83
  */
65
84
  export async function resolveRepoFromRef(
66
85
  fullName: string,
@@ -68,58 +87,50 @@ export async function resolveRepoFromRef(
68
87
  ): Promise<Repo> {
69
88
  const ref = parseRepoRef(fullName); // throws for malformed refs
70
89
  const canonical = `${ref.owner}/${ref.name}`;
90
+
91
+ let res: Response;
71
92
  try {
72
- const res = await fetch(`https://api.github.com/repos/${canonical}`, {
93
+ res = await fetch(`https://api.github.com/repos/${canonical}`, {
73
94
  headers: buildHeaders(token),
74
95
  });
75
- if (res.status === 404) {
76
- throw new Error(`Repo not found: ${canonical}`);
77
- }
78
- if (res.ok) {
79
- const api = (await res.json()) as {
80
- description: string | null;
81
- html_url: string;
82
- stargazers_count: number;
83
- forks_count: number;
84
- subscribers_count?: number;
85
- language: string | null;
86
- topics?: string[];
87
- archived: boolean;
88
- fork: boolean;
89
- created_at: string;
90
- updated_at: string;
91
- pushed_at: string;
92
- };
93
- return {
94
- id: 0,
95
- fullName: canonical,
96
- name: ref.name,
97
- owner: ref.owner,
98
- description: api.description,
99
- url: api.html_url,
100
- stars: api.stargazers_count ?? 0,
101
- forks: api.forks_count ?? 0,
102
- watchers: api.subscribers_count ?? 0,
103
- language: api.language,
104
- topics: api.topics ?? [],
105
- archived: api.archived ?? false,
106
- isFork: api.fork ?? false,
107
- private: false,
108
- createdAt: api.created_at ?? "",
109
- updatedAt: api.updated_at ?? "",
110
- pushedAt: api.pushed_at ?? "",
111
- score: 0,
112
- };
113
- }
114
- } catch (err) {
115
- // Let the not-found error propagate; only network/parse failures degrade
116
- // to the stub below.
117
- if (err instanceof Error && err.message.startsWith("Repo not found")) {
118
- throw err;
119
- }
120
- // Fall through to the stub.
96
+ } catch {
97
+ return repoFromRef(canonical);
98
+ }
99
+
100
+ if (res.status === 404) {
101
+ throw new Error(`Repo not found: ${canonical}`);
102
+ }
103
+ if (!res.ok) {
104
+ throw new Error(`GitHub API error ${res.status} for ${canonical}`);
105
+ }
106
+
107
+ let api: GitHubRepoApi;
108
+ try {
109
+ api = (await res.json()) as GitHubRepoApi;
110
+ } catch {
111
+ return repoFromRef(canonical);
121
112
  }
122
- return repoFromRef(canonical);
113
+
114
+ return {
115
+ id: 0,
116
+ fullName: canonical,
117
+ name: ref.name,
118
+ owner: ref.owner,
119
+ description: api.description,
120
+ url: api.html_url,
121
+ stars: api.stargazers_count ?? 0,
122
+ forks: api.forks_count ?? 0,
123
+ watchers: api.subscribers_count ?? 0,
124
+ language: api.language,
125
+ topics: api.topics ?? [],
126
+ archived: api.archived ?? false,
127
+ isFork: api.fork ?? false,
128
+ private: api.private ?? false,
129
+ createdAt: api.created_at ?? "",
130
+ updatedAt: api.updated_at ?? "",
131
+ pushedAt: api.pushed_at ?? "",
132
+ score: 0,
133
+ };
123
134
  }
124
135
 
125
136
  /** Language breakdown: { [lang]: bytes } */
@@ -176,21 +187,14 @@ export async function fetchDeepDiveRaw(
176
187
  ),
177
188
  ]);
178
189
 
179
- // README with branch fallback: main → master → README.rst
180
- let readmeText = "";
181
- for (const path of [
182
- `${repo.owner}/${repo.name}/main/README.md`,
183
- `${repo.owner}/${repo.name}/master/README.md`,
184
- `${repo.owner}/${repo.name}/main/README.rst`,
185
- ]) {
186
- const r = await fetch(`https://raw.githubusercontent.com/${path}`, {
187
- headers,
188
- });
189
- if (r.ok) {
190
- readmeText = await r.text();
191
- break;
192
- }
193
- }
190
+ // README: main → master → README.rst, probed in parallel by fetchReadme.
191
+ // Public READMEs are served unauthenticated, so the token is only forwarded
192
+ // to raw.githubusercontent.com when the repo actually needs it.
193
+ const readme = await fetchReadme(repo.owner, repo.name, {
194
+ token: repo.private ? token : undefined,
195
+ private: repo.private,
196
+ });
197
+ const readmeText = readme?.text ?? "";
194
198
 
195
199
  const langEntries: [string, number][] = languagesRes.ok
196
200
  ? Object.entries(
package/src/doctor.ts CHANGED
@@ -11,6 +11,7 @@ import { join, dirname } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { homedir, platform, arch } from "node:os";
13
13
  import { loadConfig, configPath, stateDir, cacheDir } from "./config";
14
+ import { maskToken } from "./login";
14
15
 
15
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
17
  const PKG_DIR = join(__dirname, "..");
@@ -212,8 +213,8 @@ function checkGithubToken(): void {
212
213
  const config = loadConfig();
213
214
  const token = config.githubToken || process.env.GITHUB_TOKEN;
214
215
  if (token) {
215
- const preview = token.slice(0, 7);
216
- add("PASS", "GitHub token", `Configured (${preview}…).`);
216
+ const preview = maskToken(token);
217
+ add("PASS", "GitHub token", `Configured (${preview}).`);
217
218
  } else {
218
219
  add(
219
220
  "WARN",
@@ -3,7 +3,8 @@
3
3
  * error, print it, and offer to open it in the user's browser.
4
4
  *
5
5
  * Privacy: never auto-posts. The user sees the URL and decides whether to
6
- * submit. We redact env vars and only include what the diagnostic needs.
6
+ * submit. Credentials found in argv or the error text are redacted before
7
+ * they can reach the URL, and a redacted report is never auto-opened.
7
8
  */
8
9
  import {
9
10
  platform as osPlatform,
@@ -24,6 +25,59 @@ void _osArch;
24
25
  const REPO = "codersdfs/search-cli";
25
26
  const NEW_ISSUE_URL = `https://github.com/${REPO}/issues/new`;
26
27
 
28
+ const SECRET_PLACEHOLDER = "***";
29
+
30
+ /** Flags whose *next* argument is a credential. */
31
+ const SECRET_FLAGS = /^(?:--token|-t)$/;
32
+
33
+ /** GitHub credential shapes: classic (ghp_/gho_/ghu_/ghs_/ghr_) and fine-grained. */
34
+ const SECRET_PATTERNS: RegExp[] = [
35
+ /gh[pousr]_[A-Za-z0-9]{16,}/g,
36
+ /github_pat_[A-Za-z0-9_]{20,}/g,
37
+ ];
38
+
39
+ /** Replace anything that looks like a token inside a free-form string. */
40
+ export function redactSecrets(text: string): string {
41
+ let out = text;
42
+ for (const pattern of SECRET_PATTERNS) {
43
+ out = out.replace(pattern, SECRET_PLACEHOLDER);
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /**
49
+ * Redact an argv: the value following a credential flag, `--token=…`/`-t=…`
50
+ * forms, and any bare argument that looks like a token.
51
+ */
52
+ export function redactArgv(argv: string[]): string[] {
53
+ const out: string[] = [];
54
+ let redactNext = false;
55
+ for (const arg of argv) {
56
+ if (redactNext) {
57
+ out.push(SECRET_PLACEHOLDER);
58
+ redactNext = false;
59
+ continue;
60
+ }
61
+ if (SECRET_FLAGS.test(arg)) {
62
+ out.push(arg);
63
+ redactNext = true;
64
+ continue;
65
+ }
66
+ const eq = arg.indexOf("=");
67
+ if (eq > 0 && SECRET_FLAGS.test(arg.slice(0, eq))) {
68
+ out.push(`${arg.slice(0, eq)}=${SECRET_PLACEHOLDER}`);
69
+ continue;
70
+ }
71
+ out.push(redactSecrets(arg));
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /** True when `argv` carries a credential that redaction would remove. */
77
+ function argvHasSecret(argv: string[]): boolean {
78
+ return redactArgv(argv).some((arg, i) => arg !== argv[i]);
79
+ }
80
+
27
81
  function getPackageVersion(): string {
28
82
  return getVersion();
29
83
  }
@@ -43,10 +97,12 @@ export function formatErrorBody(
43
97
  context: { command: string; argv: string[] },
44
98
  ): string {
45
99
  const e = err instanceof Error ? err : new Error(String(err));
46
- // biome-ignore lint/suspicious/noControlCharactersInRegex: strips ANSI color escapes from stack traces
47
- const stack = (e.stack ?? "(no stack)").replace(/\u001b\[[0-9;]*m/g, "");
48
- const message = (e.message || "(no message)").slice(0, 500);
49
- const cmd = `${context.command} ${context.argv.join(" ")}`.trim();
100
+ const stack = redactSecrets(
101
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: strips ANSI color escapes from stack traces
102
+ (e.stack ?? "(no stack)").replace(/\u001b\[[0-9;]*m/g, ""),
103
+ );
104
+ const message = redactSecrets(e.message || "(no message)").slice(0, 500);
105
+ const cmd = `${context.command} ${redactArgv(context.argv).join(" ")}`.trim();
50
106
  const ghfindVersion = getPackageVersion();
51
107
 
52
108
  return [
@@ -88,7 +144,9 @@ export function buildIssueUrl(
88
144
  context: { command: string; argv: string[] },
89
145
  ): string {
90
146
  const e = err instanceof Error ? err : new Error(String(err));
91
- const title = e.message.slice(0, 80).replace(/[\r\n]+/g, " ");
147
+ const title = redactSecrets(e.message)
148
+ .slice(0, 80)
149
+ .replace(/[\r\n]+/g, " ");
92
150
  const body = formatErrorBody(err, context);
93
151
  const params = new URLSearchParams({
94
152
  title: `[bug] ${title || "Unhandled error"}`,
@@ -138,15 +196,27 @@ export function reportError(
138
196
  argv: process.argv.slice(2),
139
197
  },
140
198
  ): string {
141
- const url = buildIssueUrl(err, context);
199
+ const hadSecret = argvHasSecret(context.argv);
200
+ // Build the URL from the scrubbed argv, never the raw one.
201
+ const url = buildIssueUrl(err, {
202
+ command: context.command,
203
+ argv: redactArgv(context.argv),
204
+ });
142
205
  console.error("");
143
206
  console.error(" ⚠ ghfind encountered an unexpected error.");
144
207
  console.error("");
145
- console.error(" Help us fix it by opening a bug report with the");
146
- console.error(" details pre-filled. Your browser will open the new-issue");
147
- console.error(" page; review and submit (or close the tab to skip).");
208
+ if (hadSecret) {
209
+ console.error(" A token was found in the command line and has been");
210
+ console.error(" redacted. Your browser will NOT be opened — review the");
211
+ console.error(" scrubbed URL below before sharing it anywhere.");
212
+ } else {
213
+ console.error(" Help us fix it by opening a bug report with the");
214
+ console.error(" details pre-filled. Your browser will open the new-issue");
215
+ console.error(" page; review and submit (or close the tab to skip).");
216
+ }
148
217
  console.error("");
149
- const opened = tryOpenUrl(url);
218
+ // Never auto-open a report that contained a credential.
219
+ const opened = hadSecret ? false : tryOpenUrl(url);
150
220
  if (opened) {
151
221
  console.error(` → Opened: ${url}`);
152
222
  } else {
package/src/errors.ts CHANGED
@@ -4,7 +4,6 @@ export class SearchCliError extends Error {
4
4
  constructor(
5
5
  message: string,
6
6
  public readonly userMessage: string,
7
- public readonly recoverable: boolean = true,
8
7
  ) {
9
8
  super(message);
10
9
  this.name = "SearchCliError";
package/src/history.ts CHANGED
@@ -31,13 +31,10 @@ export function readHistory(): HistoryEntry[] {
31
31
  export function deleteHistoryEntry(index: number): void {
32
32
  const entries = readHistory();
33
33
  if (index < 0 || index >= entries.length) return;
34
- const removed = entries[index];
35
- const remaining = entries.filter(
36
- (e) => e.timestamp !== removed.timestamp || e.query !== removed.query,
37
- );
34
+ entries.splice(index, 1);
38
35
  writeRaw(
39
36
  HISTORY_FILE,
40
- `${remaining
37
+ `${entries
41
38
  .reverse()
42
39
  .map((e) => JSON.stringify(e))
43
40
  .join("\n")}\n`,
@@ -33,8 +33,37 @@ const RASTER_EXT = /\.(png|jpe?g|webp|gif|bmp|avif|heic|tiff?)(?:[?#]|$)/i;
33
33
  /** Cap on memoised downloads — a long session can open many READMEs. */
34
34
  const CACHE_LIMIT = 24;
35
35
 
36
+ /**
37
+ * How many image downloads may be in flight at once. A README can reference
38
+ * dozens of images; firing them all at the same time makes them compete for
39
+ * bandwidth so the first one lands later, not sooner.
40
+ */
41
+ export const MAX_CONCURRENT_IMAGE_FETCHES = 4;
42
+
36
43
  const cache = new Map<string, Promise<Uint8Array | null>>();
37
44
 
45
+ let activeFetches = 0;
46
+ const waiting: Array<() => void> = [];
47
+
48
+ /** Hand the next queued caller its turn, if the cap allows it. */
49
+ function releaseSlot(): void {
50
+ activeFetches--;
51
+ waiting.shift()?.();
52
+ }
53
+
54
+ /** Run `job` as soon as fewer than the cap are already running. */
55
+ async function withSlot<T>(job: () => Promise<T>): Promise<T> {
56
+ if (activeFetches >= MAX_CONCURRENT_IMAGE_FETCHES) {
57
+ await new Promise<void>((resolve) => waiting.push(resolve));
58
+ }
59
+ activeFetches++;
60
+ try {
61
+ return await job();
62
+ } finally {
63
+ releaseSlot();
64
+ }
65
+ }
66
+
38
67
  /** Drop memoised image downloads (used by tests). */
39
68
  export function clearImageByteCache(): void {
40
69
  cache.clear();
@@ -169,7 +198,7 @@ export async function fetchImageBytes(
169
198
  ): Promise<Uint8Array | null> {
170
199
  const cached = cache.get(url);
171
200
  if (cached) return cached;
172
- const pending = loadImageBytes(url, opts);
201
+ const pending = withSlot(() => loadImageBytes(url, opts));
173
202
  cache.set(url, pending);
174
203
  // Map iteration order is insertion order, so the first key is the oldest.
175
204
  while (cache.size > CACHE_LIMIT) {
package/src/init.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * ponytail: simple stdin prompts, no curses/readline UI.
5
5
  */
6
6
  import { createInterface } from "node:readline";
7
- import { saveConfig } from "./config";
7
+ import { saveConfig, configPath } from "./config";
8
8
  import { isLikelyGithubToken } from "./login";
9
9
  import { listThemes } from "./themes";
10
10
  import type { Config, SortStrategy } from "./types";
@@ -53,7 +53,6 @@ export async function runInitWizard(): Promise<void> {
53
53
  if (token) config.githubToken = token;
54
54
 
55
55
  saveConfig(config);
56
- const { configPath } = await import("./config.ts");
57
56
  console.log(` ✓ Config saved to ${configPath()}`);
58
57
  console.log(" Run 'ghfind' to start browsing!");
59
58
  console.log("");
@@ -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
 
@@ -501,19 +510,14 @@ const TOOLS: ToolDefinition[] = [
501
510
  throw new McpToolError("repos must contain 2-5 repo fullNames.");
502
511
  }
503
512
  const token = resolveToken(state, args.token);
504
- const provider = createGitHubSearch(undefined, token ? [token] : []);
505
513
  const found: Repo[] = [];
506
514
  const missing: string[] = [];
507
515
  for (const fullName of names) {
508
- const res = await provider.search(parseQuery(fullName), {
509
- limit: 1,
510
- sort: "stars",
511
- json: false,
512
- verbose: false,
513
- token,
514
- });
515
- if (res.repos.length > 0) found.push(res.repos[0]);
516
- else missing.push(fullName);
516
+ try {
517
+ found.push(await resolveRepoFromRef(fullName, token));
518
+ } catch {
519
+ missing.push(fullName);
520
+ }
517
521
  }
518
522
  if (found.length < 2) {
519
523
  throw new McpToolError(
@@ -599,6 +603,56 @@ const TOOLS: ToolDefinition[] = [
599
603
  };
600
604
  },
601
605
  },
606
+ {
607
+ name: "ghfind_skill_search",
608
+ title: "Search agent skills",
609
+ description:
610
+ "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.",
611
+ inputSchema: {
612
+ type: "object",
613
+ properties: {
614
+ query: {
615
+ type: "string",
616
+ description:
617
+ 'Keyword to match against skill names and descriptions, e.g. "changelog" or "testing" (2+ characters).',
618
+ },
619
+ source: {
620
+ type: "string",
621
+ description:
622
+ "Where to search: 'local' for skills installed on this machine (default), 'registry' for the public skills.sh ecosystem.",
623
+ enum: [...SKILL_SOURCES],
624
+ },
625
+ limit: {
626
+ type: "number",
627
+ description: "Max results, 1-100 (default 20).",
628
+ minimum: 1,
629
+ maximum: 100,
630
+ },
631
+ },
632
+ required: ["query"],
633
+ },
634
+ async run(args) {
635
+ const source = coerceEnum<SkillSource>(
636
+ args.source,
637
+ SKILL_SOURCES,
638
+ "local",
639
+ );
640
+ const limit = coerceInt(args.limit, 20, 1, 100);
641
+ const result: SkillSearchResult = await searchSkills(
642
+ String(args.query ?? ""),
643
+ { source, limit },
644
+ );
645
+ const where =
646
+ result.source === "local"
647
+ ? "installed skills on this machine"
648
+ : "the public skills.sh ecosystem";
649
+ const header = `${result.count} match(es) for "${result.query}" in ${where}:`;
650
+ return {
651
+ text: `${header}\n\n${formatSkillSearchText(result)}`,
652
+ data: { ...result },
653
+ };
654
+ },
655
+ },
602
656
  ];
603
657
 
604
658
  /** Tools/list payload (name, title, description, inputSchema only). */
@@ -737,6 +791,7 @@ export async function processServerLine(
737
791
  },
738
792
  instructions:
739
793
  "ghfind gives agents GitHub search: repositories, trending, npm packages, org/user profiles, repo comparison and deep-dives. " +
794
+ "ghfind_skill_search finds agent skills, either installed on this machine or in the public skills.sh ecosystem. " +
740
795
  "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
796
  }),
742
797
  );
@@ -12,10 +12,14 @@ export interface Notification {
12
12
 
13
13
  let nextId = 1;
14
14
  let notifs: Notification[] = [];
15
+ let loaded = false;
15
16
  const MAX_NOTIFS = 50;
16
17
  const FILE = "notifications.json";
17
18
 
18
- function load(): void {
19
+ /** Load once per process: re-reading on an empty list would reissue ids. */
20
+ function ensureLoaded(): void {
21
+ if (loaded) return;
22
+ loaded = true;
19
23
  notifs = readJSON<Notification[]>(FILE, []);
20
24
  nextId = Math.max(...notifs.map((n) => n.id), 0) + 1;
21
25
  }
@@ -28,7 +32,7 @@ export function addNotification(
28
32
  type: Notification["type"],
29
33
  message: string,
30
34
  ): Notification {
31
- if (notifs.length === 0) load();
35
+ ensureLoaded();
32
36
  const n: Notification = {
33
37
  id: nextId++,
34
38
  type,
@@ -60,16 +64,18 @@ function iconFor(type: Notification["type"]): string {
60
64
  }
61
65
 
62
66
  export function getNotifications(): Notification[] {
63
- if (notifs.length === 0) load();
67
+ ensureLoaded();
64
68
  return [...notifs];
65
69
  }
66
70
 
67
71
  export function dismissNotification(id: number): void {
72
+ ensureLoaded();
68
73
  notifs = notifs.filter((n) => n.id !== id);
69
74
  save();
70
75
  }
71
76
 
72
77
  export function dismissAll(): void {
78
+ ensureLoaded();
73
79
  notifs = [];
74
80
  save();
75
81
  }
package/src/org.ts CHANGED
@@ -171,20 +171,21 @@ export async function fetchOrgProfile(
171
171
  if (orgPayload.public_repos > 0) {
172
172
  const perPage = Math.min(limit, 100);
173
173
  const maxPages = Math.ceil(limit / perPage);
174
- for (let page = 1; page <= maxPages; page++) {
175
- const url =
176
- `https://api.github.com/orgs/${encodeURIComponent(name)}/repos` +
177
- `?per_page=${perPage}&sort=pushed&page=${page}`;
178
- const res = await fetch(url, { headers: apiHeaders(token) });
179
- if (!res.ok) {
180
- // Org metadata succeeded; degraded summary beats a hard failure.
181
- break;
182
- }
183
- const items = (await res.json()) as GitHubRepoPayload[];
184
- if (!Array.isArray(items)) break; // unexpected shape — degrade gracefully
185
- repos.push(...items);
186
- if (repos.length >= limit || items.length < perPage) break;
187
- }
174
+ const pages = await Promise.all(
175
+ Array.from({ length: maxPages }, (_, i) => i + 1).map(async (page) => {
176
+ const url =
177
+ `https://api.github.com/orgs/${encodeURIComponent(name)}/repos` +
178
+ `?per_page=${perPage}&sort=pushed&page=${page}`;
179
+ const res = await fetch(url, { headers: apiHeaders(token) });
180
+ if (!res.ok) {
181
+ // Org metadata succeeded; degraded summary beats a hard failure.
182
+ return [];
183
+ }
184
+ const items = (await res.json()) as GitHubRepoPayload[];
185
+ return Array.isArray(items) ? items : []; // unexpected shape — skip
186
+ }),
187
+ );
188
+ repos.push(...pages.flat());
188
189
  }
189
190
 
190
191
  return normalizeOrg(orgPayload, repos.slice(0, limit));