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/CHANGELOG.md +109 -0
- package/LICENSE +21 -21
- package/README.md +81 -22
- package/completions/ghfind.bash +55 -51
- package/completions/ghfind.fish +29 -24
- package/completions/ghfind.zsh +52 -39
- package/demo.svg +71 -71
- package/dist/cli.js +1336 -551
- package/package.json +1 -1
- package/release-notes/9.8.1.md +74 -0
- package/release-notes/9.8.2.md +65 -0
- package/scripts/brew.rb +53 -53
- package/scripts/postinstall.js +38 -0
- package/src/agent-skill.ts +14 -0
- package/src/cli.ts +450 -20
- package/src/config.ts +12 -2
- package/src/deepdive.ts +70 -66
- package/src/doctor.ts +3 -2
- package/src/error-report.ts +81 -11
- package/src/errors.ts +0 -1
- package/src/history.ts +2 -5
- package/src/image-loader.ts +30 -1
- package/src/init.ts +1 -2
- package/src/markdown-view.ts +20 -4
- package/src/mcp-server.ts +65 -10
- package/src/notifications.ts +9 -3
- package/src/org.ts +15 -14
- package/src/output.ts +1 -13
- package/src/readme.ts +70 -0
- package/src/releases.ts +29 -27
- package/src/search.ts +4 -0
- package/src/skill-finder.ts +476 -0
- package/src/storage.ts +22 -47
- package/src/tui.ts +17 -33
- package/src/user.ts +15 -14
- package/dist/chunk-GYIL4S5X.js +0 -59
- package/dist/config-OCIPF2IF.js +0 -14
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.
|
|
62
|
-
* (offline,
|
|
63
|
-
*
|
|
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
|
-
|
|
93
|
+
res = await fetch(`https://api.github.com/repos/${canonical}`, {
|
|
73
94
|
headers: buildHeaders(token),
|
|
74
95
|
});
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
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",
|
package/src/error-report.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
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
|
-
|
|
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
|
-
`${
|
|
37
|
+
`${entries
|
|
41
38
|
.reverse()
|
|
42
39
|
.map((e) => JSON.stringify(e))
|
|
43
40
|
.join("\n")}\n`,
|
package/src/image-loader.ts
CHANGED
|
@@ -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("");
|
package/src/markdown-view.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
);
|
package/src/notifications.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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));
|