github-search-cli 9.8.1 → 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 +63 -0
- package/LICENSE +21 -21
- package/README.md +11 -8
- package/completions/ghfind.bash +55 -55
- package/completions/ghfind.fish +28 -28
- package/completions/ghfind.zsh +52 -52
- package/demo.svg +71 -71
- package/dist/cli.js +681 -557
- package/package.json +1 -1
- package/release-notes/9.8.2.md +65 -0
- package/scripts/brew.rb +53 -53
- package/scripts/postinstall.js +38 -0
- package/src/cli.ts +40 -22
- 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/init.ts +1 -2
- package/src/mcp-server.ts +5 -10
- package/src/notifications.ts +9 -3
- package/src/org.ts +15 -14
- package/src/output.ts +1 -13
- package/src/readme.ts +19 -3
- package/src/releases.ts +29 -27
- package/src/search.ts +4 -0
- package/src/skill-finder.ts +33 -2
- package/src/storage.ts +22 -47
- package/src/tui.ts +9 -11
- package/src/user.ts +15 -14
- package/dist/chunk-GYIL4S5X.js +0 -59
- package/dist/config-OCIPF2IF.js +0 -14
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/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/mcp-server.ts
CHANGED
|
@@ -510,19 +510,14 @@ const TOOLS: ToolDefinition[] = [
|
|
|
510
510
|
throw new McpToolError("repos must contain 2-5 repo fullNames.");
|
|
511
511
|
}
|
|
512
512
|
const token = resolveToken(state, args.token);
|
|
513
|
-
const provider = createGitHubSearch(undefined, token ? [token] : []);
|
|
514
513
|
const found: Repo[] = [];
|
|
515
514
|
const missing: string[] = [];
|
|
516
515
|
for (const fullName of names) {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
token,
|
|
523
|
-
});
|
|
524
|
-
if (res.repos.length > 0) found.push(res.repos[0]);
|
|
525
|
-
else missing.push(fullName);
|
|
516
|
+
try {
|
|
517
|
+
found.push(await resolveRepoFromRef(fullName, token));
|
|
518
|
+
} catch {
|
|
519
|
+
missing.push(fullName);
|
|
520
|
+
}
|
|
526
521
|
}
|
|
527
522
|
if (found.length < 2) {
|
|
528
523
|
throw new McpToolError(
|
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));
|
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
|
-
// ───
|
|
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
CHANGED
|
@@ -27,17 +27,33 @@ const CANDIDATES: ReadonlyArray<readonly [string, string]> = [
|
|
|
27
27
|
["main", "README.rst"],
|
|
28
28
|
];
|
|
29
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
|
+
|
|
30
47
|
export async function fetchReadme(
|
|
31
48
|
owner: string,
|
|
32
49
|
repo: string,
|
|
33
|
-
opts: { token?: string; fetchImpl?: typeof fetch } = {},
|
|
50
|
+
opts: { token?: string; private?: boolean; fetchImpl?: typeof fetch } = {},
|
|
34
51
|
): Promise<Readme | null> {
|
|
35
52
|
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
53
|
|
|
39
54
|
const attempts = CANDIDATES.map(async ([branch, file]) => {
|
|
40
55
|
const url = readmeBaseUrl(owner, repo, branch, file);
|
|
56
|
+
const headers = buildReadmeHeaders(url, opts.token, opts.private);
|
|
41
57
|
const res = await fetchImpl(url, { headers });
|
|
42
58
|
// Reject rather than resolve with null: Promise.any settles on the first
|
|
43
59
|
// *fulfilled* promise, so a 404 that resolved would beat the real README.
|
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
|
-
|
|
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[
|
|
93
|
-
const lastSeen = getLastSeen(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[
|
|
120
|
-
|
|
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
|
-
|
|
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
|
|
163
|
+
const bookmarked = new Set(getBookmarks().map((b) => b.repo.fullName));
|
|
162
164
|
return Object.entries(cache)
|
|
163
|
-
.filter(([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);
|
package/src/skill-finder.ts
CHANGED
|
@@ -80,6 +80,13 @@ export const MIN_QUERY_LENGTH = 2;
|
|
|
80
80
|
/** Result cap, applied to both sources. */
|
|
81
81
|
export const MAX_SKILL_RESULTS = 100;
|
|
82
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
|
+
|
|
83
90
|
// ─── Local roots ──────────────────────────────────────────────────────────
|
|
84
91
|
|
|
85
92
|
/** One scan root: where it is, and the label results carry. */
|
|
@@ -193,6 +200,9 @@ export function parseSkillFrontmatter(
|
|
|
193
200
|
|
|
194
201
|
// ─── Local scan ───────────────────────────────────────────────────────────
|
|
195
202
|
|
|
203
|
+
/** Memoised scan results, keyed by root path + label. */
|
|
204
|
+
const scanCache = new Map<string, LocalSkillHit[]>();
|
|
205
|
+
|
|
196
206
|
/**
|
|
197
207
|
* Walk the roots and collect every readable SKILL.md, de-duplicated by name.
|
|
198
208
|
*
|
|
@@ -203,6 +213,12 @@ export function parseSkillFrontmatter(
|
|
|
203
213
|
export function scanLocalSkills(
|
|
204
214
|
roots: SkillRoot[] = resolveSkillRoots(),
|
|
205
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;
|
|
206
222
|
const byName = new Map<string, LocalSkillHit>();
|
|
207
223
|
for (const root of roots) {
|
|
208
224
|
for (const dir of walkDirs(root.path)) {
|
|
@@ -224,7 +240,9 @@ export function scanLocalSkills(
|
|
|
224
240
|
}
|
|
225
241
|
}
|
|
226
242
|
}
|
|
227
|
-
|
|
243
|
+
const hits = [...byName.values()];
|
|
244
|
+
scanCache.set(key, hits);
|
|
245
|
+
return hits;
|
|
228
246
|
}
|
|
229
247
|
|
|
230
248
|
/** Directory entries, or null when the directory is missing or unreadable. */
|
|
@@ -326,7 +344,20 @@ function normalizeRegistrySkill(raw: unknown): RegistrySkillHit | null {
|
|
|
326
344
|
const obj = raw as Record<string, unknown>;
|
|
327
345
|
const source = typeof obj.source === "string" ? obj.source : "";
|
|
328
346
|
const skillId = typeof obj.skillId === "string" ? obj.skillId : "";
|
|
329
|
-
|
|
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
|
+
}
|
|
330
361
|
const name =
|
|
331
362
|
typeof obj.name === "string" && obj.name.trim() ? obj.name : skillId;
|
|
332
363
|
const installs = typeof obj.installs === "number" ? obj.installs : 0;
|
package/src/storage.ts
CHANGED
|
@@ -2,16 +2,15 @@
|
|
|
2
2
|
* Storage adapter — single point of file I/O for state files.
|
|
3
3
|
*
|
|
4
4
|
* All domain modules (history, bookmarks, saved-searches, notifications,
|
|
5
|
-
* session, config) delegate file I/O here.
|
|
6
|
-
* implementation in tests.
|
|
5
|
+
* session, config) delegate file I/O here.
|
|
7
6
|
*/
|
|
8
7
|
import {
|
|
9
|
-
existsSync,
|
|
10
8
|
mkdirSync,
|
|
11
9
|
readFileSync,
|
|
12
10
|
writeFileSync,
|
|
13
11
|
appendFileSync,
|
|
14
12
|
unlinkSync,
|
|
13
|
+
renameSync,
|
|
15
14
|
} from "node:fs";
|
|
16
15
|
import { join } from "node:path";
|
|
17
16
|
import { stateDir } from "./config";
|
|
@@ -31,10 +30,27 @@ export function readJSON<T>(filename: string, fallback: T): T {
|
|
|
31
30
|
}
|
|
32
31
|
}
|
|
33
32
|
|
|
33
|
+
/** Write a file atomically: temp file in the same dir, then rename. */
|
|
34
|
+
function writeFileAtomic(filename: string, data: string): void {
|
|
35
|
+
ensureDir();
|
|
36
|
+
const target = join(stateDir(), filename);
|
|
37
|
+
const tmp = `${target}.tmp-${process.pid}`;
|
|
38
|
+
try {
|
|
39
|
+
writeFileSync(tmp, data, "utf-8");
|
|
40
|
+
renameSync(tmp, target);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
try {
|
|
43
|
+
unlinkSync(tmp);
|
|
44
|
+
} catch {
|
|
45
|
+
// temp already gone
|
|
46
|
+
}
|
|
47
|
+
throw e;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
34
51
|
/** Write data as JSON to a file. */
|
|
35
52
|
export function writeJSON(filename: string, data: unknown): void {
|
|
36
|
-
|
|
37
|
-
writeFileSync(join(stateDir(), filename), JSON.stringify(data, null, 2));
|
|
53
|
+
writeFileAtomic(filename, JSON.stringify(data, null, 2));
|
|
38
54
|
}
|
|
39
55
|
|
|
40
56
|
/** Append one JSON object as a line to a JSONL file. */
|
|
@@ -68,8 +84,7 @@ export function readJSONL<T>(filename: string): T[] {
|
|
|
68
84
|
|
|
69
85
|
/** Write raw text to a file (bypasses JSON encoding). */
|
|
70
86
|
export function writeRaw(filename: string, data: string): void {
|
|
71
|
-
|
|
72
|
-
writeFileSync(join(stateDir(), filename), data);
|
|
87
|
+
writeFileAtomic(filename, data);
|
|
73
88
|
}
|
|
74
89
|
|
|
75
90
|
/** Delete a file if it exists. */
|
|
@@ -94,43 +109,3 @@ export function debugLog(msg: string): void {
|
|
|
94
109
|
/* ignore */
|
|
95
110
|
}
|
|
96
111
|
}
|
|
97
|
-
|
|
98
|
-
/** Check if a file exists in the state dir. */
|
|
99
|
-
export function fileExists(filename: string): boolean {
|
|
100
|
-
return existsSync(join(stateDir(), filename));
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// ─── In-memory implementation for tests ────────────────────────────────
|
|
104
|
-
|
|
105
|
-
const memStore = new Map<string, string>();
|
|
106
|
-
|
|
107
|
-
export const memoryStorage = {
|
|
108
|
-
readJSON<T>(filename: string, fallback: T): T {
|
|
109
|
-
const raw = memStore.get(filename);
|
|
110
|
-
if (!raw) return fallback;
|
|
111
|
-
try {
|
|
112
|
-
return JSON.parse(raw) as T;
|
|
113
|
-
} catch {
|
|
114
|
-
return fallback;
|
|
115
|
-
}
|
|
116
|
-
},
|
|
117
|
-
writeJSON(filename: string, data: unknown): void {
|
|
118
|
-
memStore.set(filename, JSON.stringify(data, null, 2));
|
|
119
|
-
},
|
|
120
|
-
appendJSONL(filename: string, obj: unknown): void {
|
|
121
|
-
const existing = memStore.get(filename) ?? "";
|
|
122
|
-
memStore.set(filename, `${existing + JSON.stringify(obj)}\n`);
|
|
123
|
-
},
|
|
124
|
-
readJSONL<T>(filename: string): T[] {
|
|
125
|
-
const raw = memStore.get(filename);
|
|
126
|
-
if (!raw) return [];
|
|
127
|
-
return raw
|
|
128
|
-
.trim()
|
|
129
|
-
.split("\n")
|
|
130
|
-
.filter(Boolean)
|
|
131
|
-
.map((l) => JSON.parse(l) as T);
|
|
132
|
-
},
|
|
133
|
-
clear(): void {
|
|
134
|
-
memStore.clear();
|
|
135
|
-
},
|
|
136
|
-
};
|
package/src/tui.ts
CHANGED
|
@@ -1891,6 +1891,7 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
1891
1891
|
try {
|
|
1892
1892
|
const readme = await fetchReadme(repo.owner, repo.name, {
|
|
1893
1893
|
token: githubToken,
|
|
1894
|
+
private: repo.private,
|
|
1894
1895
|
});
|
|
1895
1896
|
if (readme) {
|
|
1896
1897
|
readmeView.setContent(readme.text, readme.sourceUrl);
|
|
@@ -2426,10 +2427,13 @@ export async function launchBrowser(theme_override?: string): Promise<void> {
|
|
|
2426
2427
|
}
|
|
2427
2428
|
}
|
|
2428
2429
|
} catch (err) {
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2430
|
+
// A failed "load more" page must not wipe the pages already on screen.
|
|
2431
|
+
if (!append) {
|
|
2432
|
+
currentRepos = [];
|
|
2433
|
+
resultsSelect.options = [
|
|
2434
|
+
{ name: " (error)", description: "", value: null },
|
|
2435
|
+
];
|
|
2436
|
+
}
|
|
2433
2437
|
const msg =
|
|
2434
2438
|
err instanceof SearchCliError
|
|
2435
2439
|
? err.userMessage
|
|
@@ -3071,18 +3075,12 @@ ${pack.description ?? ""}`;
|
|
|
3071
3075
|
// ── Utilities ─────────────────────────────────────────────────────────
|
|
3072
3076
|
|
|
3073
3077
|
function formatResultLine(repo: Repo): string {
|
|
3074
|
-
const stars =
|
|
3078
|
+
const stars = `★ ${fmtStars(repo.stars)}`;
|
|
3075
3079
|
const lang = (repo.language ?? "?").padEnd(12).slice(0, 12);
|
|
3076
3080
|
const name = repo.fullName.padEnd(40).slice(0, 40);
|
|
3077
3081
|
return `${name} ${lang} ${stars}`;
|
|
3078
3082
|
}
|
|
3079
3083
|
|
|
3080
|
-
function formatStars(n: number): string {
|
|
3081
|
-
if (n >= 1000000) return `★ ${(n / 1000000).toFixed(1).replace(/\.0$/, "")}M`;
|
|
3082
|
-
if (n >= 1000) return `★ ${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
|
|
3083
|
-
return `★ ${n}`;
|
|
3084
|
-
}
|
|
3085
|
-
|
|
3086
3084
|
function formatToolbar(
|
|
3087
3085
|
sort: SortStrategy | PackageSortMode,
|
|
3088
3086
|
limit: number,
|