github-search-cli 9.6.0 → 9.7.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/src/mcp-server.ts CHANGED
@@ -27,8 +27,7 @@ import {
27
27
  rankRepos,
28
28
  createGitHubSearch,
29
29
  createTrendingSearch,
30
- TRENDING_LANGUAGES,
31
- trendingLanguageSlug,
30
+ resolveTrendingLanguage,
32
31
  } from "./search";
33
32
  import {
34
33
  createPackageSearch,
@@ -37,7 +36,11 @@ import {
37
36
  } from "./package";
38
37
  import { fetchOrgProfile, formatOrgText } from "./org";
39
38
  import { fetchUserProfile, formatUserJson } from "./user";
40
- import { fetchDeepDive, buildDeepDiveText } from "./deepdive";
39
+ import {
40
+ fetchDeepDive,
41
+ buildDeepDiveText,
42
+ resolveRepoFromRef,
43
+ } from "./deepdive";
41
44
  import { buildComparisonTable } from "./compare";
42
45
  import { allCachedReleases } from "./releases";
43
46
  import { getBookmarks } from "./bookmarks";
@@ -199,45 +202,9 @@ function formatPackageLines(
199
202
  .join("\n");
200
203
  }
201
204
 
202
- const REPO_NAME_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
203
-
204
- function parseRepoName(input: string): { owner: string; name: string } {
205
- const fullName = input
206
- .trim()
207
- .replace(/\.git$/, "")
208
- .replace(/^@/, "");
209
- if (!REPO_NAME_RE.test(fullName)) {
210
- throw new McpToolError(
211
- `"${input}" is not a repo name — use owner/name, e.g. facebook/react.`,
212
- );
213
- }
214
- const [owner, name] = fullName.split("/");
215
- return { owner, name };
216
- }
217
-
218
- /** Minimal Repo for deep-dive (fetchDeepDive only reads owner/name). */
219
- function stubRepo(fullName: string): Repo {
220
- const { owner, name } = parseRepoName(fullName);
221
- return {
222
- id: 0,
223
- fullName: `${owner}/${name}`,
224
- name,
225
- owner,
226
- description: null,
227
- url: `https://github.com/${owner}/${name}`,
228
- stars: 0,
229
- forks: 0,
230
- watchers: 0,
231
- language: null,
232
- topics: [],
233
- archived: false,
234
- isFork: false,
235
- private: false,
236
- createdAt: "",
237
- updatedAt: "",
238
- pushedAt: "",
239
- score: 0,
240
- };
205
+ /** Wrap a domain error as McpToolError so its message surfaces verbatim. */
206
+ function asMcpToolError(err: unknown): McpToolError {
207
+ return new McpToolError(err instanceof Error ? err.message : String(err));
241
208
  }
242
209
 
243
210
  const TRENDING_QUERY = { keywords: [], qualifiers: [], raw: "trending" };
@@ -366,16 +333,12 @@ const TOOLS: ToolDefinition[] = [
366
333
  const token = resolveToken(state, args.token);
367
334
 
368
335
  let language: string | undefined;
369
- const langArg =
370
- typeof args.language === "string" ? args.language.trim() : "";
371
- if (langArg) {
372
- const slug = trendingLanguageSlug(langArg);
373
- if (!TRENDING_LANGUAGES.has(slug)) {
374
- throw new McpToolError(
375
- `"${langArg}" is not a trending language filter. Try one of: rust, python, typescript, javascript, go, zig.`,
376
- );
377
- }
378
- language = slug;
336
+ try {
337
+ language = resolveTrendingLanguage(
338
+ typeof args.language === "string" ? args.language : undefined,
339
+ );
340
+ } catch (err) {
341
+ throw asMcpToolError(err);
379
342
  }
380
343
 
381
344
  const trending = createTrendingSearch();
@@ -582,7 +545,12 @@ const TOOLS: ToolDefinition[] = [
582
545
  async run(args, state) {
583
546
  const repo = String(args.repo ?? "").trim();
584
547
  if (!repo) throw new McpToolError("repo must be a non-empty string.");
585
- const stub = stubRepo(repo);
548
+ let stub: Repo;
549
+ try {
550
+ stub = await resolveRepoFromRef(repo, resolveToken(state, args.token));
551
+ } catch (err) {
552
+ throw asMcpToolError(err);
553
+ }
586
554
  const data = await fetchDeepDive(stub, resolveToken(state, args.token));
587
555
  return { text: buildDeepDiveText(data), data: { ...data } };
588
556
  },
@@ -720,8 +688,11 @@ export async function processServerLine(
720
688
  return;
721
689
  }
722
690
 
723
- const isNotification = msg.id === undefined || msg.id === null;
724
- const id = isNotification ? null : msg.id;
691
+ // Extract the id into a const so TS narrows `undefined` out of it below
692
+ // (aliased conditional narrowing doesn't apply through the `let msg` bind).
693
+ const rawId = msg.id;
694
+ const isNotification = rawId === undefined || rawId === null;
695
+ const id: JsonRpcId = isNotification ? null : rawId;
725
696
 
726
697
  // Notifications never get responses.
727
698
  if (msg.method === "notifications/initialized") {
@@ -830,7 +801,7 @@ export async function processServerLine(
830
801
  // ─── stdio transport ───────────────────────────────────────────────────
831
802
 
832
803
  function writeStdout(line: string): void {
833
- process.stdout.write(line + "\n");
804
+ process.stdout.write(`${line}\n`);
834
805
  }
835
806
 
836
807
  /**
@@ -860,8 +831,9 @@ export async function runMcpServer(
860
831
  let buffer = "";
861
832
  process.stdin.on("data", (chunk: string) => {
862
833
  buffer += chunk;
863
- let idx: number;
864
- while ((idx = buffer.indexOf("\n")) >= 0) {
834
+ for (;;) {
835
+ const idx = buffer.indexOf("\n");
836
+ if (idx < 0) break;
865
837
  const line = buffer.slice(0, idx).trim();
866
838
  buffer = buffer.slice(idx + 1);
867
839
  if (!line) continue;
package/src/open-url.ts CHANGED
@@ -1,15 +1,26 @@
1
1
  /**
2
- * Open a URL in the user's default browser — platform-aware.
2
+ * Open a URL in the user's default browser — platform- and runtime-aware.
3
3
  *
4
- * only if the open/xdg-open pattern fails somewhere.
4
+ * Windows note: `explorer.exe` is used instead of the classic
5
+ * `cmd /c start "" <url>` idiom. cmd splits the URL at the first unquoted
6
+ * `&` (a query-string separator) and executes the rest as a command, which
7
+ * truncated every URL with query params. explorer.exe receives the URL as a
8
+ * single CreateProcess argument, so `&` is never interpreted.
5
9
  */
10
+ import { spawn } from "node:child_process";
6
11
 
7
12
  export function openUrl(url: string): void {
8
13
  try {
9
14
  const p = process.platform;
10
- if (p === "win32") Bun.spawn(["cmd", "/c", "start", "", url]);
11
- else if (p === "darwin") Bun.spawn(["open", url]);
12
- else Bun.spawn(["xdg-open", url]);
15
+ const cmd =
16
+ p === "win32" ? "explorer" : p === "darwin" ? "open" : "xdg-open";
17
+ if (typeof Bun !== "undefined") {
18
+ Bun.spawn([cmd, url]);
19
+ return;
20
+ }
21
+ // Node fallback — detached so the child survives our exit.
22
+ const child = spawn(cmd, [url], { detached: true, stdio: "ignore" });
23
+ child.unref();
13
24
  } catch {
14
25
  // non-critical — user can copy the URL manually
15
26
  }
package/src/output.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * clone-commands, ids) register in a single registry. One `format()` function
6
6
  * dispatches. `exportToFile` and `pipeExec` are methods on the same module.
7
7
  */
8
- import { writeFileSync } from "fs";
8
+ import { writeFileSync } from "node:fs";
9
9
  import type { Repo } from "./types";
10
10
  import { openUrl } from "./open-url";
11
11
 
@@ -49,7 +49,7 @@ function formatPackageLine(pack: Package): StyledText {
49
49
  const scoreStr = `score ${pack.score.toFixed(2)}`.padEnd(12).slice(0, 12);
50
50
  const desc =
51
51
  (pack.description ?? "").length > 60
52
- ? (pack.description ?? "").slice(0, 57) + "..."
52
+ ? `${(pack.description ?? "").slice(0, 57)}...`
53
53
  : (pack.description ?? "");
54
54
  const line1 = t`${bold(fg(C.nameText)(nameStr))}${fg(C.cyan)(dlStr)}${fg(C.gold)(scoreStr)}`;
55
55
  const line2 = t`${dim(fg(C.descText)(` ${desc}`))}`;
package/src/releases.ts CHANGED
@@ -1,16 +1,3 @@
1
- /**
2
- * Release tracker — checks bookmarked repos for new GitHub releases.
3
- *
4
- * Decisions (map 003):
5
- * - Signal: new GitHub releases only (`GET /repos/{o}/{r}/releases?per_page=5`).
6
- * - Seen state: `lastSeenAt` per bookmark; "new" = published after it.
7
- * - Rate budget: etag conditional requests + token when present.
8
- * - Prereleases shown (tagged `[pre]`), drafts never.
9
- *
10
- * ponytail: sequential fetches — fine for ≤100 bookmarks at launch;
11
- * parallelize with p-limit if users report slow startup.
12
- */
13
- import type { Bookmark } from "./types";
14
1
  import { readJSON, writeJSON } from "./storage";
15
2
  import { getBookmarks, getLastSeen, markSeen } from "./bookmarks";
16
3
  import { addNotification } from "./notifications";
@@ -68,7 +55,7 @@ async function fetchReleases(
68
55
  Accept: "application/vnd.github+json",
69
56
  };
70
57
  if (etag) headers["If-None-Match"] = etag;
71
- if (token) headers["Authorization"] = `Bearer ${token}`;
58
+ if (token) headers.Authorization = `Bearer ${token}`;
72
59
  const res = await fetch(
73
60
  `https://api.github.com/repos/${fullName}/releases?per_page=${PER_REPO}`,
74
61
  { headers },
@@ -4,7 +4,7 @@
4
4
  * The TUI view layer reads from SearchState and dispatches actions.
5
5
  * This module is testable without the OpenTUI renderer.
6
6
  */
7
- import type { Repo, SearchOptions, SortStrategy, SessionState } from "./types";
7
+ import type { Repo, SortStrategy, SessionState } from "./types";
8
8
  import type { TabName } from "./trending";
9
9
  import { tabSince } from "./trending";
10
10
  import {
@@ -24,7 +24,6 @@ import {
24
24
  touchSavedSearch,
25
25
  } from "./saved-searches";
26
26
  import { fetchDeepDive, buildDeepDiveText } from "./deepdive";
27
- import { fetchTopics } from "./explore";
28
27
  import { loadConfig } from "./config";
29
28
 
30
29
  export type Overlay =
@@ -131,8 +130,6 @@ export class SearchStateManager {
131
130
  timestamp: Date.now(),
132
131
  resultCount: response.repos.length,
133
132
  });
134
- } catch (err) {
135
- throw err;
136
133
  } finally {
137
134
  this.state.isLoading = false;
138
135
  }
package/src/search.ts CHANGED
@@ -181,7 +181,7 @@ export function suggestFor(input: string): string[] {
181
181
  return QUALIFIER_SUGGESTIONS[trimmed];
182
182
  }
183
183
  const matches = KNOWN_QUALIFIERS.filter((k) => k.startsWith(trimmed));
184
- return matches.map((k) => k + ":");
184
+ return matches.map((k) => `${k}:`);
185
185
  }
186
186
 
187
187
  export function tokenize(input: string): string[] {
@@ -329,7 +329,6 @@ export function githubSortParam(sort: SortStrategy): {
329
329
  return { sort: "forks", order: "desc" };
330
330
  case "updated":
331
331
  return { sort: "updated", order: "desc" };
332
- case "best-match":
333
332
  default:
334
333
  return {};
335
334
  }
@@ -359,7 +358,6 @@ export function rankRepos(repos: Repo[], strategy: SortStrategy): Repo[] {
359
358
  case "updated":
360
359
  copy.sort(byKey((r) => Date.parse(r.updatedAt)));
361
360
  break;
362
- case "best-match":
363
361
  default:
364
362
  break;
365
363
  }
@@ -473,7 +471,7 @@ export class GitHubSearchAdapter implements SearchAdapter {
473
471
  if (
474
472
  !parsed ||
475
473
  typeof parsed !== "object" ||
476
- !Array.isArray((parsed as any).items)
474
+ !Array.isArray((parsed as { items?: unknown }).items)
477
475
  ) {
478
476
  this.logger.error(
479
477
  `[github] unexpected response shape: ${JSON.stringify(parsed).slice(0, 200)}`,
@@ -570,6 +568,25 @@ export function trendingLanguageSlug(language: string): string {
570
568
  return language.trim().toLowerCase().replace(/\s+/g, "-");
571
569
  }
572
570
 
571
+ /**
572
+ * Validate a user-supplied trending language and return its slug, or null if
573
+ * empty. Throws with a user-facing suggestion list for unknown languages.
574
+ * Shared by the MCP trending tool and the CLI trending filter.
575
+ */
576
+ export function resolveTrendingLanguage(
577
+ language: string | undefined | null,
578
+ ): string | undefined {
579
+ const trimmed = typeof language === "string" ? language.trim() : "";
580
+ if (!trimmed) return undefined;
581
+ const slug = trendingLanguageSlug(trimmed);
582
+ if (!TRENDING_LANGUAGES.has(slug)) {
583
+ throw new Error(
584
+ `"${trimmed}" is not a trending language filter. Try one of: rust, python, typescript, javascript, go, zig.`,
585
+ );
586
+ }
587
+ return slug;
588
+ }
589
+
573
590
  /** Build the github.com/trending URL for a period + optional language filter. */
574
591
  export function buildTrendingUrl(
575
592
  since: "daily" | "weekly" | "monthly",
@@ -611,6 +628,7 @@ function trendingRepoToRepo(r: RawTrendingRepo): Repo {
611
628
  */
612
629
  export class TrendingAdapter implements SearchAdapter {
613
630
  readonly name = "trending";
631
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: part of the adapter constructor contract — callers pass a shared logger (search.ts:757) even though this adapter currently logs nothing itself.
614
632
  private readonly logger: Logger;
615
633
 
616
634
  constructor(logger: Logger = noopLogger) {
package/src/share.ts CHANGED
@@ -19,7 +19,7 @@ export function formatShare(repo: Repo, format: ShareFormat): string {
19
19
  }
20
20
 
21
21
  /** Copy text to clipboard via platform command. */
22
- import { spawn } from "child_process";
22
+ import { spawn } from "node:child_process";
23
23
 
24
24
  export async function copyToClipboard(text: string): Promise<boolean> {
25
25
  try {
@@ -46,7 +46,7 @@ export async function copyToClipboard(text: string): Promise<boolean> {
46
46
  }
47
47
 
48
48
  async function findLinuxClipboard(): Promise<string[] | null> {
49
- const { execFileSync } = await import("child_process");
49
+ const { execFileSync } = await import("node:child_process");
50
50
  try {
51
51
  execFileSync("xclip", ["-version"], { stdio: "ignore" });
52
52
  return ["xclip", "-selection", "clipboard"];
package/src/storage.ts CHANGED
@@ -12,8 +12,8 @@ import {
12
12
  writeFileSync,
13
13
  appendFileSync,
14
14
  unlinkSync,
15
- } from "fs";
16
- import { join } from "path";
15
+ } from "node:fs";
16
+ import { join } from "node:path";
17
17
  import { stateDir } from "./config";
18
18
 
19
19
  /** Ensure the state directory exists. */
@@ -43,7 +43,7 @@ export function appendJSONL(filename: string, obj: unknown): void {
43
43
  try {
44
44
  appendFileSync(
45
45
  join(stateDir(), filename),
46
- JSON.stringify(obj) + "\n",
46
+ `${JSON.stringify(obj)}\n`,
47
47
  "utf-8",
48
48
  );
49
49
  } catch {
@@ -119,7 +119,7 @@ export const memoryStorage = {
119
119
  },
120
120
  appendJSONL(filename: string, obj: unknown): void {
121
121
  const existing = memStore.get(filename) ?? "";
122
- memStore.set(filename, existing + JSON.stringify(obj) + "\n");
122
+ memStore.set(filename, `${existing + JSON.stringify(obj)}\n`);
123
123
  },
124
124
  readJSONL<T>(filename: string): T[] {
125
125
  const raw = memStore.get(filename);
package/src/themes.ts CHANGED
@@ -242,6 +242,7 @@ export async function detectTerminalBackground(
242
242
  // Reply: ESC ] 11 ; <spec> (BEL | ESC \)
243
243
  const onData = (d: Buffer) => {
244
244
  buffer += d.toString("latin1");
245
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matches raw ANSI OSC-11 escape bytes, which are control characters by definition
245
246
  const m = buffer.match(/\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)/);
246
247
  if (m) finish(parseOscColor(m[1]));
247
248
  };
@@ -35,7 +35,7 @@ function parseRepoLink(block: string): RepoLink | undefined {
35
35
  // `/login?...&return_to=...` href fails the bare-path requirement and the
36
36
  // stargazers/forks links don't carry the heading's class="Link".
37
37
  const anchor = block.match(
38
- /<a[^>]*?href="\/([A-Za-z0-9._-]+)\/([^\"?#]+)"[^>]*class="Link"/,
38
+ /<a[^>]*?href="\/([A-Za-z0-9._-]+)\/([^"?#]+)"[^>]*class="Link"/,
39
39
  );
40
40
  if (!anchor) return undefined;
41
41
  return { owner: anchor[1], name: anchor[2] };
@@ -43,7 +43,7 @@ function parseRepoLink(block: string): RepoLink | undefined {
43
43
 
44
44
  function parseStars(block: string): number {
45
45
  const starsMatch = block.match(
46
- /href="\/[^\/\"]+\/[^\/\"]+\/stargazers"[^>]*>.*?<\/svg>\s*(\d[\d,]*)/,
46
+ /href="\/[^/"]+\/[^/"]+\/stargazers"[^>]*>.*?<\/svg>\s*(\d[\d,]*)/,
47
47
  );
48
48
  return starsMatch ? parseInt(starsMatch[1].replace(/,/g, ""), 10) : 0;
49
49
  }
@@ -69,10 +69,9 @@ export function parseTrendingHtml(html: string): RawTrendingRepo[] {
69
69
  const flat = flattenHtml(html);
70
70
  const repos: RawTrendingRepo[] = [];
71
71
  const articleRe = /<article class="Box-row">(.*?)<\/article>/gi;
72
- let match: RegExpExecArray | null;
73
72
  let rank = 0;
74
73
 
75
- while ((match = articleRe.exec(flat)) !== null) {
74
+ for (const match of flat.matchAll(articleRe)) {
76
75
  rank++;
77
76
  const block = match[1];
78
77
  const link = parseRepoLink(block);
package/src/trending.ts CHANGED
@@ -118,13 +118,13 @@ export function formatRepoLine(repo: Repo, rank: number): StyledText {
118
118
  const rankStr = String(rank).padStart(2, "0");
119
119
  const full = `${repo.owner}/${repo.name}`;
120
120
  const lc = langColor(repo.language ?? "");
121
- const rc = rankColor(rank);
121
+ const _rc = rankColor(rank);
122
122
  const star = "★";
123
123
  const arrow = repo.score > 0 ? "▲" : repo.score < 0 ? "▼" : "—";
124
124
  const growth = `${arrow} ${fmtSigned(repo.score)} today`;
125
125
  const desc =
126
126
  (repo.description ?? "").length > 60
127
- ? (repo.description ?? "").slice(0, 57) + "..."
127
+ ? `${(repo.description ?? "").slice(0, 57)}...`
128
128
  : (repo.description ?? "");
129
129
  const line1 = t`${dim(fg(C.rankBg)(`[${rankStr}]`))} ${bold(fg("#c0caf5")(full))} ${fg(lc)(`● ${repo.language}`)} ${fg(C.gold)(`${star} ${fmtStars(repo.stars)}`)} ${bold(fg(C.green)(growth))}`;
130
130
  const line2 = t` ${dim(fg(C.muted)(desc))}`;
@@ -182,7 +182,7 @@ export async function launchTrending(): Promise<void> {
182
182
  let currentPeriod = "this week";
183
183
  let repos: Repo[] = [];
184
184
  let selectedRepoIdx = 0;
185
- let isLoading = false;
185
+ let _isLoading = false;
186
186
  // ── Fetch from github.com/trending ──
187
187
  async function loadTab(index: number) {
188
188
  if (index < 0 || index >= TAB_NAMES.length) return;
@@ -194,7 +194,7 @@ export async function launchTrending(): Promise<void> {
194
194
  : since === "weekly"
195
195
  ? "this week"
196
196
  : "this month";
197
- isLoading = true;
197
+ _isLoading = true;
198
198
  for (const rb of rowBoxes) scrollBox.remove(rb);
199
199
  rowBoxes.length = 0;
200
200
  rowTexts.length = 0;
@@ -233,7 +233,7 @@ export async function launchTrending(): Promise<void> {
233
233
  });
234
234
  scrollBox.add(errText);
235
235
  }
236
- isLoading = false;
236
+ _isLoading = false;
237
237
  renderTabBar();
238
238
  renderer.requestRender();
239
239
  }
@@ -359,7 +359,7 @@ export async function launchTrending(): Promise<void> {
359
359
  const lc = langColor(r.language ?? "");
360
360
  const desc =
361
361
  (r.description ?? "").length > 55
362
- ? (r.description ?? "").slice(0, 52) + "..."
362
+ ? `${(r.description ?? "").slice(0, 52)}...`
363
363
  : (r.description ?? "");
364
364
  const arrow = r.score > 0 ? "▲" : r.score < 0 ? "▼" : "—";
365
365
  const growthStr = `${arrow} ${fmtStars(Math.abs(r.score))} ${currentPeriod}`;
@@ -373,7 +373,7 @@ export async function launchTrending(): Promise<void> {
373
373
  fg: rc,
374
374
  height: 1,
375
375
  });
376
- const descStr = desc.length > 60 ? desc.slice(0, 57) + "..." : desc;
376
+ const descStr = desc.length > 60 ? `${desc.slice(0, 57)}...` : desc;
377
377
  const pad = " ";
378
378
  const line1 = t`${bold(fg("#c0caf5")(nameStr))}${fg(lc)(langStr)}${fg(C.muted)(starsStr)}${bold(fg(C.green)(growthStr))}`;
379
379
  const line2 = t`${fg(C.descText)(`${pad}${descStr}`)}`;