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/deepdive.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  /** Repo deep-dive — fetch and format rich repo details. */
2
2
  import type { Repo } from "./types";
3
- import { NetworkError } from "./errors";
4
3
 
5
4
  const USER_AGENT = "ghfind/1.0";
6
5
 
@@ -10,6 +9,119 @@ function buildHeaders(token?: string): Record<string, string> {
10
9
  return headers;
11
10
  }
12
11
 
12
+ // ─── Repo ref parsing (shared by the CLI deep-dive and the MCP server) ─
13
+
14
+ const REPO_REF_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
15
+
16
+ /**
17
+ * Parse a repo reference ("owner/name") into its parts. Tolerates a trailing
18
+ * `.git` and a leading `@`. Throws with a user-facing message for anything else.
19
+ */
20
+ export function parseRepoRef(input: string): { owner: string; name: string } {
21
+ const fullName = input
22
+ .trim()
23
+ .replace(/\.git$/, "")
24
+ .replace(/^@/, "");
25
+ if (!REPO_REF_RE.test(fullName)) {
26
+ throw new Error(
27
+ `"${input}" is not a repo name — use owner/name, e.g. facebook/react.`,
28
+ );
29
+ }
30
+ const [owner, name] = fullName.split("/");
31
+ return { owner, name };
32
+ }
33
+
34
+ /** Minimal Repo for deep-dive (the fetchers only read owner/name). */
35
+ export function repoFromRef(fullName: string): Repo {
36
+ const { owner, name } = parseRepoRef(fullName);
37
+ return {
38
+ id: 0,
39
+ fullName: `${owner}/${name}`,
40
+ name,
41
+ owner,
42
+ description: null,
43
+ url: `https://github.com/${owner}/${name}`,
44
+ stars: 0,
45
+ forks: 0,
46
+ watchers: 0,
47
+ language: null,
48
+ topics: [],
49
+ archived: false,
50
+ isFork: false,
51
+ private: false,
52
+ createdAt: "",
53
+ updatedAt: "",
54
+ pushedAt: "",
55
+ score: 0,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * 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.
64
+ */
65
+ export async function resolveRepoFromRef(
66
+ fullName: string,
67
+ token?: string,
68
+ ): Promise<Repo> {
69
+ const ref = parseRepoRef(fullName); // throws for malformed refs
70
+ const canonical = `${ref.owner}/${ref.name}`;
71
+ try {
72
+ const res = await fetch(`https://api.github.com/repos/${canonical}`, {
73
+ headers: buildHeaders(token),
74
+ });
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.
121
+ }
122
+ return repoFromRef(canonical);
123
+ }
124
+
13
125
  /** Language breakdown: { [lang]: bytes } */
14
126
  type LanguageMap = Record<string, number>;
15
127
 
@@ -27,11 +139,31 @@ export interface DeepDiveData {
27
139
  readme: string;
28
140
  }
29
141
 
30
- /** Fetch all deep-dive data for a repo. Returns formatted sections. */
31
- export async function fetchDeepDive(
142
+ /** Structured deep-dive payload — JSON-ready (for `ghfind deep-dive --json`). */
143
+ export interface DeepDiveJson {
144
+ repo: {
145
+ fullName: string;
146
+ url: string;
147
+ description: string | null;
148
+ stars: number;
149
+ forks: number;
150
+ language: string | null;
151
+ topics: string[];
152
+ };
153
+ /** Language breakdown sorted by bytes, descending. */
154
+ languages: { name: string; bytes: number; percent: number }[];
155
+ contributors: { login: string; contributions: number }[];
156
+ /** Raw README markdown (capped at README_MAX_CHARS). */
157
+ readme: string;
158
+ }
159
+
160
+ const README_MAX_CHARS = 5_000;
161
+
162
+ /** Fetch deep-dive data as structured JSON: language percents, contributors, raw README. */
163
+ export async function fetchDeepDiveRaw(
32
164
  repo: Repo,
33
165
  token?: string,
34
- ): Promise<DeepDiveData> {
166
+ ): Promise<DeepDiveJson> {
35
167
  const headers = buildHeaders(token);
36
168
 
37
169
  const [languagesRes, contributorsRes] = await Promise.all([
@@ -60,22 +192,64 @@ export async function fetchDeepDive(
60
192
  }
61
193
  }
62
194
 
195
+ const langEntries: [string, number][] = languagesRes.ok
196
+ ? Object.entries(
197
+ (await languagesRes.json().catch(() => ({}))) as LanguageMap,
198
+ )
199
+ : [];
200
+ const totalBytes = langEntries.reduce((s, [, v]) => s + v, 0);
201
+ const contributors: Contributor[] = contributorsRes.ok
202
+ ? ((await contributorsRes.json().catch(() => [])) as Contributor[])
203
+ : [];
204
+
205
+ return {
206
+ repo: {
207
+ fullName: repo.fullName,
208
+ url: repo.url,
209
+ description: repo.description,
210
+ stars: repo.stars,
211
+ forks: repo.forks,
212
+ language: repo.language,
213
+ topics: repo.topics,
214
+ },
215
+ languages: langEntries
216
+ .sort(([, a], [, b]) => b - a)
217
+ .map(([name, bytes]) => ({
218
+ name,
219
+ bytes,
220
+ percent: totalBytes > 0 ? (bytes / totalBytes) * 100 : 0,
221
+ })),
222
+ contributors: contributors.map((c) => ({
223
+ login: c.login,
224
+ contributions: c.contributions,
225
+ })),
226
+ readme: readmeText.slice(0, README_MAX_CHARS),
227
+ };
228
+ }
229
+
230
+ /** JSON string for `ghfind deep-dive --json`. */
231
+ export function buildDeepDiveJson(data: DeepDiveJson): string {
232
+ return JSON.stringify(data, null, 2);
233
+ }
234
+
235
+ /** Format structured deep-dive data into display sections. */
236
+ export function buildDeepDiveData(repo: Repo, raw: DeepDiveJson): DeepDiveData {
63
237
  return {
64
238
  summary: formatSummary(repo),
65
- languages: formatLanguages(
66
- languagesRes.ok
67
- ? ((await languagesRes.json().catch(() => ({}))) as LanguageMap)
68
- : {},
69
- ),
70
- contributors: formatContributors(
71
- contributorsRes.ok
72
- ? ((await contributorsRes.json().catch(() => [])) as Contributor[])
73
- : [],
74
- ),
75
- readme: formatReadme(readmeText),
239
+ languages: formatLanguages(raw.languages),
240
+ contributors: formatContributors(raw.contributors),
241
+ readme: formatReadme(raw.readme),
76
242
  };
77
243
  }
78
244
 
245
+ /** Fetch all deep-dive data for a repo. Returns formatted sections. */
246
+ export async function fetchDeepDive(
247
+ repo: Repo,
248
+ token?: string,
249
+ ): Promise<DeepDiveData> {
250
+ return buildDeepDiveData(repo, await fetchDeepDiveRaw(repo, token));
251
+ }
252
+
79
253
  function formatSummary(repo: Repo): string {
80
254
  const lines = [
81
255
  ` ${repo.fullName}`,
@@ -87,17 +261,14 @@ function formatSummary(repo: Repo): string {
87
261
  return lines.filter(Boolean).join("\n");
88
262
  }
89
263
 
90
- function formatLanguages(langs: LanguageMap): string {
91
- const entries = Object.entries(langs);
92
- if (entries.length === 0) return " (no language data)";
93
- const total = entries.reduce((s, [, v]) => s + v, 0);
264
+ function formatLanguages(langs: DeepDiveJson["languages"]): string {
265
+ if (langs.length === 0) return " (no language data)";
94
266
  const barW = 20;
95
- return entries
96
- .sort(([, a], [, b]) => b - a)
267
+ return langs
97
268
  .slice(0, 8)
98
- .map(([name, bytes]) => {
99
- const pct = ((bytes / total) * 100).toFixed(1);
100
- const filled = Math.round((bytes / total) * barW);
269
+ .map(({ name, percent }) => {
270
+ const pct = percent.toFixed(1);
271
+ const filled = Math.round((percent / 100) * barW);
101
272
  const bar = "█".repeat(filled) + "░".repeat(barW - filled);
102
273
  return ` ${name.padEnd(16)} ${bar} ${pct}%`;
103
274
  })
package/src/doctor.ts CHANGED
@@ -5,11 +5,11 @@
5
5
  * Runs a battery of checks and prints a PASS / WARN / FAIL report, then exits
6
6
  * non-zero if any FAIL check was detected.
7
7
  */
8
- import { existsSync, readFileSync, mkdirSync } from "fs";
9
- import { spawnSync } from "child_process";
10
- import { join, dirname } from "path";
11
- import { fileURLToPath } from "url";
12
- import { homedir, platform, arch } from "os";
8
+ import { existsSync, readFileSync, mkdirSync } from "node:fs";
9
+ import { spawnSync } from "node:child_process";
10
+ import { join, dirname } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { homedir, platform, arch } from "node:os";
13
13
  import { loadConfig, configPath, stateDir, cacheDir } from "./config";
14
14
 
15
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -31,9 +31,14 @@ function add(status: CheckStatus, label: string, detail: string): void {
31
31
  function run(
32
32
  cmd: string,
33
33
  args: string[],
34
+ opts: { shell?: boolean; timeout?: number } = {},
34
35
  ): { status: number | null; stdout: string; stderr: string } {
35
36
  try {
36
- const res = spawnSync(cmd, args, { encoding: "utf8", timeout: 15000 });
37
+ const res = spawnSync(cmd, args, {
38
+ encoding: "utf8",
39
+ timeout: 15000,
40
+ ...opts,
41
+ });
37
42
  return {
38
43
  status: res.status,
39
44
  stdout: res.stdout ?? "",
@@ -123,13 +128,13 @@ function checkSystemBun(bundledOk: boolean): void {
123
128
  add(
124
129
  "WARN",
125
130
  "System Bun",
126
- "No \`bun\` on PATH. Bundled Bun is working, so this is only a fallback.",
131
+ "No `bun` on PATH. Bundled Bun is working, so this is only a fallback.",
127
132
  );
128
133
  } else {
129
134
  add(
130
135
  "FAIL",
131
136
  "System Bun",
132
- "No \`bun\` on PATH. Install it: curl -fsSL https://bun.sh/install | bash",
137
+ "No `bun` on PATH. Install it: curl -fsSL https://bun.sh/install | bash",
133
138
  );
134
139
  }
135
140
  }
@@ -236,6 +241,136 @@ function checkDirs(): void {
236
241
  }
237
242
  }
238
243
 
244
+ const PKG_NAME = "github-search-cli";
245
+ const BIN_NAME = "ghfind";
246
+
247
+ function runningVersion(): string {
248
+ try {
249
+ const pkg = JSON.parse(
250
+ readFileSync(join(PKG_DIR, "package.json"), "utf8"),
251
+ ) as { version?: string };
252
+ return pkg.version ?? "";
253
+ } catch {
254
+ return "";
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Best-effort version of the install that owns a `ghfind` shim on PATH.
260
+ * Prefers reading the sibling package.json (cheap, exact); falls back to
261
+ * executing the shim with `--version` (handles bun/unknown layouts).
262
+ */
263
+ export function installVersionAt(
264
+ shimPath: string,
265
+ ): { version: string; dir: string } | null {
266
+ const dir = dirname(shimPath);
267
+ // npm layouts: <prefix>/node_modules/<pkg> (win) or <prefix>/lib/node_modules/<pkg> (unix)
268
+ for (const ancestor of [dir, dirname(dir)]) {
269
+ for (const sub of ["node_modules", join("lib", "node_modules")]) {
270
+ const pkgJson = join(ancestor, sub, PKG_NAME, "package.json");
271
+ if (existsSync(pkgJson)) {
272
+ try {
273
+ const pkg = JSON.parse(readFileSync(pkgJson, "utf8")) as {
274
+ version?: string;
275
+ };
276
+ return { version: pkg.version ?? "", dir: dirname(pkgJson) };
277
+ } catch {
278
+ // fall through to shim execution
279
+ }
280
+ }
281
+ }
282
+ }
283
+ // .cmd/.bat shims require shell:true under Node (EINVAL otherwise); the
284
+ // package.json read above already covered the common npm layouts.
285
+ const res = run(shimPath, ["--version"], {
286
+ shell: platform() === "win32",
287
+ timeout: 10000,
288
+ });
289
+ const m = /\d+\.\d+\.\d+/.exec(res.stdout);
290
+ return m ? { version: m[0], dir } : null;
291
+ }
292
+
293
+ /**
294
+ * Detect stale/shadowing global installs: multiple `ghfind` binaries on PATH
295
+ * resolving to different versions means the shell may keep launching an old
296
+ * copy even after a successful update — the exact "updater says OK but
297
+ * nothing changed" trap. Warns and names the offending prefixes.
298
+ */
299
+ function checkInstallShadowing(): void {
300
+ const isWin = platform() === "win32";
301
+ const res = isWin ? run("where", [BIN_NAME]) : run("which", ["-a", BIN_NAME]);
302
+ const shims = [
303
+ ...new Set(
304
+ res.stdout
305
+ .split(/\r?\n/)
306
+ .map((l) => l.trim())
307
+ .filter((l) => l.length > 0),
308
+ ),
309
+ ];
310
+ if (shims.length === 0) {
311
+ add("WARN", "Global installs", `${BIN_NAME} not found on PATH.`);
312
+ return;
313
+ }
314
+ const running = runningVersion();
315
+ const norm = (p: string) => (isWin ? p.replace(/\\/g, "/").toLowerCase() : p);
316
+ const seenDirs = new Set<string>();
317
+ const stale: string[] = [];
318
+ let knownCount = 0;
319
+ let staleVersion = "";
320
+ let staleDir = "";
321
+
322
+ for (const shim of shims) {
323
+ const info = installVersionAt(shim);
324
+ // Group shims that live in the same install (ghfind + ghfind.cmd + ghfind.ps1)
325
+ const dirKey = norm(dirname(shim));
326
+ if (seenDirs.has(dirKey)) continue;
327
+ seenDirs.add(dirKey);
328
+ knownCount++;
329
+ const isRunningInstall = info !== null && norm(info.dir) === norm(PKG_DIR);
330
+ if (
331
+ !isRunningInstall &&
332
+ info !== null &&
333
+ running &&
334
+ info.version !== running
335
+ ) {
336
+ stale.push(`${dirname(shim)} (v${info.version} ≠ running v${running})`);
337
+ staleVersion = info.version;
338
+ staleDir = dirname(shim);
339
+ }
340
+ }
341
+
342
+ if (stale.length > 0 && knownCount > 1) {
343
+ // Shadowing needs ≥2 installs: the shell can keep launching an old copy
344
+ // while updates land in another prefix.
345
+ add(
346
+ "WARN",
347
+ "Global installs",
348
+ `${knownCount} install(s) on PATH; stale copies shadowing updates → ${stale.join("; ")}. Remove with: npm uninstall -g ${PKG_NAME} --prefix <dir>`,
349
+ );
350
+ } else if (stale.length > 0) {
351
+ // Single install whose version differs from the running copy — normal
352
+ // when developing from source (running vNEWER than the published one).
353
+ // Uninstalling it would be harmful advice.
354
+ add(
355
+ "PASS",
356
+ "Global installs",
357
+ `v${staleVersion} at ${staleDir} (running v${running || "?"} from source/build) — single install, nothing shadowed.`,
358
+ );
359
+ } else if (knownCount > 1) {
360
+ add(
361
+ "PASS",
362
+ "Global installs",
363
+ `${knownCount} install(s) on PATH, all v${running || "?"}.`,
364
+ );
365
+ } else {
366
+ add(
367
+ "PASS",
368
+ "Global installs",
369
+ `v${running || "?"} at ${dirname(shims[0])}.`,
370
+ );
371
+ }
372
+ }
373
+
239
374
  function printReport(): void {
240
375
  // Respect NO_COLOR (https://no-color.org/): plain text when set (any value,
241
376
  // including the empty string) or when stdout is not a TTY.
@@ -284,6 +419,7 @@ export async function runDoctor(): Promise<void> {
284
419
  checkConfig();
285
420
  checkGithubToken();
286
421
  checkDirs();
422
+ checkInstallShadowing();
287
423
  printReport();
288
424
 
289
425
  const hasFail = results.some((r) => r.status === "FAIL");
@@ -10,8 +10,8 @@ import {
10
10
  arch as osArch,
11
11
  version as nodeVersion,
12
12
  versions as nodeVersions,
13
- } from "process";
14
- import { spawn } from "child_process";
13
+ } from "node:process";
14
+ import { spawn } from "node:child_process";
15
15
  import { getVersion } from "./version";
16
16
 
17
17
  // ponytail: in bun, process.platform is a string property; in node, same.
@@ -43,6 +43,7 @@ export function formatErrorBody(
43
43
  context: { command: string; argv: string[] },
44
44
  ): string {
45
45
  const e = err instanceof Error ? err : new Error(String(err));
46
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: strips ANSI color escapes from stack traces
46
47
  const stack = (e.stack ?? "(no stack)").replace(/\u001b\[[0-9;]*m/g, "");
47
48
  const message = (e.message || "(no message)").slice(0, 500);
48
49
  const cmd = `${context.command} ${context.argv.join(" ")}`.trim();
@@ -105,14 +106,19 @@ function tryOpenUrl(url: string): boolean {
105
106
  try {
106
107
  const p = osPlatform;
107
108
  if (typeof Bun !== "undefined") {
108
- if (p === "win32") Bun.spawn(["cmd", "/c", "start", "", url]);
109
- else if (p === "darwin") Bun.spawn(["open", url]);
109
+ if (p === "win32") {
110
+ // explorer.exe, not `cmd /c start` — cmd splits the URL at the first
111
+ // unquoted `&` (query separator) and runs the rest as a command,
112
+ // truncating every pre-filled issue URL. See src/open-url.ts.
113
+ Bun.spawn(["explorer", url]);
114
+ } else if (p === "darwin") Bun.spawn(["open", url]);
110
115
  else Bun.spawn(["xdg-open", url]);
111
116
  return true;
112
117
  }
113
118
  // Node fallback — use child_process.spawn detached so it survives exit
114
- const cmd = p === "win32" ? "cmd" : p === "darwin" ? "open" : "xdg-open";
115
- const args = p === "win32" ? ["/c", "start", "", url] : [url];
119
+ const cmd =
120
+ p === "win32" ? "explorer" : p === "darwin" ? "open" : "xdg-open";
121
+ const args = [url];
116
122
  const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
117
123
  child.unref();
118
124
  return true;
@@ -142,11 +148,11 @@ export function reportError(
142
148
  console.error("");
143
149
  const opened = tryOpenUrl(url);
144
150
  if (opened) {
145
- console.error(" → Opened: " + url);
151
+ console.error(` → Opened: ${url}`);
146
152
  } else {
147
153
  console.error(" Copy this URL to file a bug report:");
148
154
  console.error("");
149
- console.error(" " + url);
155
+ console.error(` ${url}`);
150
156
  }
151
157
  console.error("");
152
158
  return url;
package/src/export.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Export — format repos as JSON, CSV, Markdown, or plain text.
3
3
  */
4
- import { writeFileSync } from "fs";
4
+ import { writeFileSync } from "node:fs";
5
5
  import type { Repo } from "./types";
6
6
 
7
7
  export type ExportFormat = "json" | "csv" | "markdown" | "text";
package/src/history.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Persistent search history — append-only JSONL with rotation.
3
3
  */
4
4
  import type { HistoryEntry } from "./types";
5
- import { readJSONL, appendJSONL, writeJSON, writeRaw } from "./storage";
5
+ import { readJSONL, appendJSONL, writeRaw } from "./storage";
6
6
 
7
7
  const HISTORY_FILE = "history.jsonl";
8
8
  const MAX_ENTRIES = 500;
@@ -37,10 +37,10 @@ export function deleteHistoryEntry(index: number): void {
37
37
  );
38
38
  writeRaw(
39
39
  HISTORY_FILE,
40
- remaining
40
+ `${remaining
41
41
  .reverse()
42
42
  .map((e) => JSON.stringify(e))
43
- .join("\n") + "\n",
43
+ .join("\n")}\n`,
44
44
  );
45
45
  }
46
46
 
@@ -56,10 +56,10 @@ export function rotateHistory(): void {
56
56
  const trimmed = entries.slice(0, MAX_ENTRIES);
57
57
  writeRaw(
58
58
  HISTORY_FILE,
59
- trimmed
59
+ `${trimmed
60
60
  .reverse()
61
61
  .map((e) => JSON.stringify(e))
62
- .join("\n") + "\n",
62
+ .join("\n")}\n`,
63
63
  );
64
64
  }
65
65
 
package/src/init.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * ponytail: simple stdin prompts, no curses/readline UI.
5
5
  */
6
- import { createInterface } from "readline";
6
+ import { createInterface } from "node:readline";
7
7
  import { saveConfig } from "./config";
8
8
  import { isLikelyGithubToken } from "./login";
9
9
  import { listThemes } from "./themes";
@@ -44,7 +44,7 @@ export async function runInitWizard(): Promise<void> {
44
44
  defaultSort: (["best-match", "stars", "updated", "forks"].includes(sortStr)
45
45
  ? sortStr
46
46
  : "best-match") as SortStrategy,
47
- defaultLimit: Math.min(100, Math.max(10, parseInt(limitStr) || 50)),
47
+ defaultLimit: Math.min(100, Math.max(10, parseInt(limitStr, 10) || 50)),
48
48
  theme: themes.includes(themeStr) ? themeStr : themes[0],
49
49
  cacheTtlSeconds: 300,
50
50
  defaultTab: "search",
package/src/login.ts CHANGED
@@ -6,10 +6,10 @@
6
6
  * used by `ghfind init` (~/.config/ghfind/config.json). `GITHUB_TOKEN` in the
7
7
  * environment always takes precedence at runtime (see src/config.ts).
8
8
  */
9
- import { createInterface } from "readline";
10
- import { homedir } from "os";
11
- import { join } from "path";
12
- import { existsSync, readFileSync } from "fs";
9
+ import { createInterface } from "node:readline";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { existsSync, readFileSync } from "node:fs";
13
13
  import { loadConfig, saveConfig, configPath } from "./config";
14
14
  import type { Config } from "./types";
15
15
 
@@ -26,12 +26,12 @@ export function renderMarkdown(md: string, opts: RenderOptions = {}): string {
26
26
  if (codeLines.length === 0) return;
27
27
  const maxLen = Math.max(...codeLines.map((l) => l.length), codeLang.length);
28
28
  const barW = Math.min(maxLen + 4, width - 2);
29
- out.push("┌" + "─".repeat(barW) + "┐");
30
- if (codeLang) out.push("│ " + codeLang.padEnd(barW - 1) + "│");
29
+ out.push(`┌${"─".repeat(barW)}┐`);
30
+ if (codeLang) out.push(`│ ${codeLang.padEnd(barW - 1)}│`);
31
31
  for (const cl of codeLines) {
32
- out.push("│ " + cl.padEnd(barW - 1) + "│");
32
+ out.push(`│ ${cl.padEnd(barW - 1)}│`);
33
33
  }
34
- out.push("└" + "─".repeat(barW) + "┘");
34
+ out.push(`└${"─".repeat(barW)}┘`);
35
35
  out.push("");
36
36
  codeLines = [];
37
37
  codeLang = "";
@@ -54,16 +54,16 @@ export function renderMarkdown(md: string, opts: RenderOptions = {}): string {
54
54
  const totalW = colW.reduce((a, b) => a + b, 0) + colCount * 3 - 1;
55
55
  if (totalW > width) {
56
56
  for (const row of dataRows) {
57
- out.push(" " + row.map((c) => c.trim()).join(" │ "));
57
+ out.push(` ${row.map((c) => c.trim()).join(" │ ")}`);
58
58
  }
59
59
  } else {
60
- const sep = "┌" + colW.map((w) => "─".repeat(w + 2)).join("┬") + "┐";
61
- const mid = "├" + colW.map((w) => "─".repeat(w + 2)).join("┼") + "┤";
62
- const end = "└" + colW.map((w) => "─".repeat(w + 2)).join("┴") + "┘";
60
+ const sep = `┌${colW.map((w) => "─".repeat(w + 2)).join("┬")}┐`;
61
+ const mid = `├${colW.map((w) => "─".repeat(w + 2)).join("┼")}┤`;
62
+ const end = `└${colW.map((w) => "─".repeat(w + 2)).join("┴")}┘`;
63
63
  out.push(sep);
64
64
  dataRows.forEach((row, i) => {
65
65
  const cells = colW.map((w, ci) => (row[ci] ?? "").trim().padEnd(w));
66
- out.push("│ " + cells.map((c) => ` ${c} `).join("│") + "│");
66
+ out.push(`│ ${cells.map((c) => ` ${c} `).join("│")}│`);
67
67
  if (i === 0) out.push(mid);
68
68
  });
69
69
  out.push(end);
@@ -102,7 +102,7 @@ export function renderMarkdown(md: string, opts: RenderOptions = {}): string {
102
102
  }
103
103
 
104
104
  for (let i = 0; i < lines.length; i++) {
105
- let line = lines[i];
105
+ const line = lines[i];
106
106
 
107
107
  // Code block boundaries
108
108
  if (/^```/.test(line)) {
@@ -160,7 +160,7 @@ export function renderMarkdown(md: string, opts: RenderOptions = {}): string {
160
160
  if (bqMatch) {
161
161
  const inner = renderInline(bqMatch[1]);
162
162
  for (const wl of wrapLine(inner, width - 4)) {
163
- out.push("│ " + wl);
163
+ out.push(`│ ${wl}`);
164
164
  }
165
165
  continue;
166
166
  }
@@ -170,7 +170,7 @@ export function renderMarkdown(md: string, opts: RenderOptions = {}): string {
170
170
  if (ulMatch) {
171
171
  const indent = ulMatch[1].length;
172
172
  const depth = Math.floor(indent / 2);
173
- const prefix = " ".repeat(depth) + "• ";
173
+ const prefix = `${" ".repeat(depth)}• `;
174
174
  const inner = renderInline(ulMatch[2]);
175
175
  for (const wl of wrapLine(inner, width - prefix.length)) {
176
176
  out.push(prefix + wl);
@@ -183,7 +183,7 @@ export function renderMarkdown(md: string, opts: RenderOptions = {}): string {
183
183
  if (olMatch) {
184
184
  const indent = olMatch[1].length;
185
185
  const depth = Math.floor(indent / 2);
186
- const prefix = " ".repeat(depth) + "◦ ";
186
+ const prefix = `${" ".repeat(depth)}◦ `;
187
187
  const inner = renderInline(olMatch[2]);
188
188
  for (const wl of wrapLine(inner, width - prefix.length)) {
189
189
  out.push(prefix + wl);