@oomfware/cgr 0.1.2 → 0.1.4

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/README.md CHANGED
@@ -42,7 +42,7 @@ cgr clean --all
42
42
  ## commands
43
43
 
44
44
  ```
45
- cgr ask [-m opus|sonnet|haiku] [-w repo#branch ...] <repo#branch> <question>
45
+ cgr ask [-m opus|sonnet|haiku] [-w repo[#branch] ...] <repo>[#branch] <question>
46
46
  cgr clean [--all | <repo>]
47
47
  ```
48
48
 
@@ -78,11 +78,11 @@ alternatively, a more structured prompt:
78
78
 
79
79
  You can use `@oomfware/cgr` to ask questions about external repositories.
80
80
 
81
- npx @oomfware/cgr ask [options] <repo#branch> <question>
81
+ npx @oomfware/cgr ask [options] <repo>[#branch] <question>
82
82
 
83
83
  options:
84
- -m, --model <model> model to use: opus, sonnet, haiku (default: haiku)
85
- -w, --with <repo> additional repository to include (can be repeated)
84
+ -m, --model <model> model to use: opus, sonnet, haiku (default: haiku)
85
+ -w, --with <repo> additional repository to include, supports #branch (repeatable)
86
86
 
87
87
  Useful repositories:
88
88
 
@@ -1,5 +1,5 @@
1
- You are a code research assistant analyzing an external repository. Your goal is to answer the
2
- user's question accurately and thoroughly by exploring the codebase.
1
+ You are a code research assistant with read-only access to one or more repositories. Your goal is to
2
+ answer the user's question by exploring the codebase—you cannot modify any files.
3
3
 
4
4
  ## Available tools
5
5
 
@@ -28,23 +28,64 @@ You also have read-only Bash access for standard Unix tools when needed.
28
28
 
29
29
  ## Guidelines
30
30
 
31
+ - **Be direct** - Answer the question, don't narrate your process. Skip preamble like "Perfect!",
32
+ "Now I understand...", or "Let me explain..."
31
33
  - **Explore first** - Don't guess. Use Glob and Grep to find relevant files, then Read to understand
32
34
  them. Trace imports, function calls, and data flow.
33
- - **Cite your sources** - Reference file paths and line numbers. When specifics matter, include
34
- actual code snippets rather than paraphrasing.
35
- - **Explain the why** - Don't just describe what code does; explain why it exists and how it fits
36
- into the larger picture.
37
- - **Use history** - When relevant, use git log/blame/show to understand how code evolved.
38
- - **Admit uncertainty** - If you're unsure about something, say so and explain what you did find.
35
+ - **Cite your sources** - Back up claims with evidence:
36
+ 1. Add footnotes referencing where a statement is sourced:
37
+
38
+ ```
39
+ The cache is invalidated whenever a user updates their profile. [^1]
40
+
41
+ [^1]: **`src/services/user.ts:89`** - updateProfile() calls cache.invalidate()
42
+ ```
43
+
44
+ ```
45
+ The popover flips to the opposite side when it would overflow the viewport. [^2]
46
+
47
+ [^2]: **`src/utils/useAnchorPositioning.ts:215-220`** - flip middleware from Floating UI
48
+ ```
49
+
50
+ 2. Reference file paths and line numbers directly in prose:
51
+
52
+ ```
53
+ As shown in `src/config/database.ts:12`, the connection pool defaults to 10.
54
+ ```
39
55
 
40
- When citing code, use this format:
56
+ ```
57
+ The `useSignal` hook in `packages/react/src/index.ts:53` returns a stable reference.
58
+ ```
41
59
 
42
- **`path/to/file.ts:42-50`**
60
+ 3. Include code snippets when they help illustrate the point:
43
61
 
44
- ```typescript
45
- function example() {
46
- return 'actual code from the file';
47
- }
48
- ```
62
+ ```
63
+ Signals track dependencies automatically when accessed inside an effect:
49
64
 
50
- If examining multiple repositories, prefix paths with the repository name.
65
+ **`packages/core/src/index.ts:152-158`**
66
+
67
+ if (evalContext !== undefined) {
68
+ let node = evalContext._sources;
69
+ // Subscribe to the signal
70
+ node._source._subscribe(node);
71
+ }
72
+ ```
73
+
74
+ ```
75
+ Errors are wrapped with context before being rethrown:
76
+
77
+ **`src/utils/errors.ts:22-26`**
78
+
79
+ catch (err) {
80
+ throw new AppError(`Failed to ${operation}`, { cause: err });
81
+ }
82
+ ```
83
+
84
+ If examining multiple repositories, prefix paths with the repository name.
85
+
86
+ - **Explain the why** - Don't just describe what code does; explain why it exists and how it fits
87
+ into the larger picture.
88
+ - **Compare implementations** - When examining multiple repositories, highlight differences in
89
+ approach. Tables work well for summarizing tradeoffs.
90
+ - **Use history** - When relevant, use git log/blame/show to understand how code evolved.
91
+ - **Admit uncertainty** - If you're unsure about something, say so and explain what you did find.
package/dist/index.mjs CHANGED
@@ -2,32 +2,46 @@
2
2
  import { argument, choice, command, constant, message, object, option, or, string } from "@optique/core";
3
3
  import { run } from "@optique/run";
4
4
  import { spawn } from "node:child_process";
5
- import { dirname, join } from "node:path";
5
+ import { basename, dirname, join, relative } from "node:path";
6
6
  import { multiple, optional, withDefault } from "@optique/core/modifiers";
7
- import { existsSync, readdirSync, statSync } from "node:fs";
8
- import { mkdir, rm, symlink } from "node:fs/promises";
7
+ import { existsSync } from "node:fs";
8
+ import { mkdir, readdir, rm, stat, symlink } from "node:fs/promises";
9
9
  import { randomUUID } from "node:crypto";
10
10
  import { homedir } from "node:os";
11
- import { createInterface } from "node:readline";
11
+ import checkbox from "@inquirer/checkbox";
12
+ import confirm from "@inquirer/confirm";
12
13
 
14
+ //#region src/lib/debug.ts
15
+ const debugEnabled = process.env.DEBUG === "1" || process.env.CGR_DEBUG === "1";
16
+ /**
17
+ * logs a debug message to stderr if DEBUG=1 or CGR_DEBUG=1.
18
+ * @param message the message to log
19
+ */
20
+ const debug = (message$1) => {
21
+ if (debugEnabled) console.error(`[debug] ${message$1}`);
22
+ };
23
+
24
+ //#endregion
13
25
  //#region src/lib/git.ts
14
26
  /**
15
27
  * executes a git command silently, only showing output on failure.
28
+ * when debug is enabled, inherits stdio to show git progress.
16
29
  * @param args git command arguments
17
30
  * @param cwd working directory
18
31
  * @returns promise that resolves when the command completes
19
32
  */
20
33
  const git = (args, cwd) => new Promise((resolve, reject) => {
34
+ debug(`git ${args.join(" ")}${cwd ? ` (in ${cwd})` : ""}`);
21
35
  const proc = spawn("git", args, {
22
36
  cwd,
23
- stdio: [
37
+ stdio: debugEnabled ? "inherit" : [
24
38
  "inherit",
25
39
  "pipe",
26
40
  "pipe"
27
41
  ]
28
42
  });
29
43
  let stderr = "";
30
- proc.stderr.on("data", (data) => {
44
+ if (!debugEnabled) proc.stderr.on("data", (data) => {
31
45
  stderr += data.toString();
32
46
  });
33
47
  proc.on("close", (code) => {
@@ -41,17 +55,19 @@ const git = (args, cwd) => new Promise((resolve, reject) => {
41
55
  });
42
56
  /**
43
57
  * executes a git command and captures stdout, only showing stderr on failure.
58
+ * when debug is enabled, inherits stderr to show git progress.
44
59
  * @param args git command arguments
45
60
  * @param cwd working directory
46
61
  * @returns promise that resolves with stdout
47
62
  */
48
63
  const gitOutput = (args, cwd) => new Promise((resolve, reject) => {
64
+ debug(`git ${args.join(" ")}${cwd ? ` (in ${cwd})` : ""}`);
49
65
  const proc = spawn("git", args, {
50
66
  cwd,
51
67
  stdio: [
52
68
  "inherit",
53
69
  "pipe",
54
- "pipe"
70
+ debugEnabled ? "inherit" : "pipe"
55
71
  ]
56
72
  });
57
73
  let output = "";
@@ -59,7 +75,7 @@ const gitOutput = (args, cwd) => new Promise((resolve, reject) => {
59
75
  proc.stdout.on("data", (data) => {
60
76
  output += data.toString();
61
77
  });
62
- proc.stderr.on("data", (data) => {
78
+ if (!debugEnabled) proc.stderr.on("data", (data) => {
63
79
  stderr += data.toString();
64
80
  });
65
81
  proc.on("close", (code) => {
@@ -143,34 +159,52 @@ const getCacheDir = () => {
143
159
  * @returns the repos directory path
144
160
  */
145
161
  const getReposDir = () => join(getCacheDir(), "repos");
162
+ const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
163
+ const UNSAFE_CHARS = /[<>:"|?*\\]/g;
146
164
  /**
147
- * parses a git remote URL and extracts host, owner, and repo.
165
+ * sanitizes a path segment to be safe on all filesystems.
166
+ * encodes unsafe characters using percent-encoding and handles windows reserved names.
167
+ * @param segment the path segment to sanitize
168
+ * @returns sanitized segment
169
+ */
170
+ const sanitizeSegment = (segment) => {
171
+ let safe = segment.replace(/%/g, "%25").replace(UNSAFE_CHARS, (c) => `%${c.charCodeAt(0).toString(16).padStart(2, "0")}`).replace(/[. ]+$/, (m) => m.split("").map((c) => `%${c.charCodeAt(0).toString(16).padStart(2, "0")}`).join(""));
172
+ if (WINDOWS_RESERVED.test(safe)) safe = `${safe}_`;
173
+ return safe;
174
+ };
175
+ /**
176
+ * parses a git remote URL and extracts host and path.
148
177
  * supports HTTP(S), SSH, and bare URLs (assumes HTTPS).
149
178
  * @param remote the remote URL to parse
150
179
  * @returns parsed components or null if invalid
151
180
  */
152
181
  const parseRemote = (remote) => {
153
- const httpMatch = remote.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/);
182
+ const httpMatch = remote.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?$/);
154
183
  if (httpMatch) return {
155
184
  host: httpMatch[1],
156
- owner: httpMatch[2],
157
- repo: httpMatch[3]
185
+ path: httpMatch[2]
158
186
  };
159
- const sshMatch = remote.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/);
187
+ const sshMatch = remote.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
160
188
  if (sshMatch) return {
161
189
  host: sshMatch[1],
162
- owner: sshMatch[2],
163
- repo: sshMatch[3]
190
+ path: sshMatch[2]
164
191
  };
165
- const bareMatch = remote.match(/^([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/);
192
+ const bareMatch = remote.match(/^([^/]+)\/(.+?)(?:\.git)?$/);
166
193
  if (bareMatch) return {
167
194
  host: bareMatch[1],
168
- owner: bareMatch[2],
169
- repo: bareMatch[3]
195
+ path: bareMatch[2]
170
196
  };
171
197
  return null;
172
198
  };
173
199
  /**
200
+ * sanitizes a parsed remote path for safe filesystem storage.
201
+ * @param parsed the parsed remote (host + path)
202
+ * @returns sanitized path segments joined with the system separator
203
+ */
204
+ const sanitizeRemotePath = (parsed) => {
205
+ return join(sanitizeSegment(parsed.host.toLowerCase()), ...parsed.path.toLowerCase().split("/").map(sanitizeSegment));
206
+ };
207
+ /**
174
208
  * normalizes a remote URL by prepending https:// if no protocol is present.
175
209
  * @param remote the remote URL to normalize
176
210
  * @returns normalized URL with protocol
@@ -187,7 +221,7 @@ const normalizeRemote = (remote) => {
187
221
  const getRepoCachePath = (remote) => {
188
222
  const parsed = parseRemote(remote);
189
223
  if (!parsed) return null;
190
- return join(getReposDir(), parsed.host, parsed.owner, parsed.repo);
224
+ return join(getReposDir(), sanitizeRemotePath(parsed));
191
225
  };
192
226
  /**
193
227
  * parses `remote#branch` syntax.
@@ -240,11 +274,12 @@ const buildSymlinkDir = async (sessionPath, repos) => {
240
274
  const result$1 = /* @__PURE__ */ new Map();
241
275
  const usedNames = /* @__PURE__ */ new Set();
242
276
  for (const repo of repos) {
243
- let name = repo.parsed.repo;
277
+ const repoName = basename(repo.parsed.path);
278
+ let name = repoName;
244
279
  let suffix = 1;
245
280
  while (usedNames.has(name)) {
246
281
  suffix++;
247
- name = `${repo.parsed.repo}-${suffix}`;
282
+ name = `${repoName}-${suffix}`;
248
283
  }
249
284
  usedNames.add(name);
250
285
  result$1.set(name, repo);
@@ -277,8 +312,9 @@ const schema$1 = object({
277
312
  */
278
313
  const exitInvalidRemote = (remote) => {
279
314
  console.error(`error: invalid remote URL: ${remote}`);
280
- console.error("expected format: host/owner/repo, e.g.:");
315
+ console.error("expected format: host/path, e.g.:");
281
316
  console.error(" github.com/user/repo");
317
+ console.error(" gitlab.com/group/subgroup/repo");
282
318
  console.error(" https://github.com/user/repo");
283
319
  console.error(" git@github.com:user/repo.git");
284
320
  process.exit(1);
@@ -310,7 +346,7 @@ const parseRepoInput = (input) => {
310
346
  * @returns context prompt string
311
347
  */
312
348
  const buildSingleRepoContext = (repo) => {
313
- return `You are examining ${`${repo.parsed.host}/${repo.parsed.owner}/${repo.parsed.repo}`} (checked out on ${repo.branch ?? "default branch"}).`;
349
+ return `You are examining ${`${repo.parsed.host}/${repo.parsed.path}`} (checked out on ${repo.branch ?? "default branch"}).`;
314
350
  };
315
351
  /**
316
352
  * builds a context prompt for multiple repositories.
@@ -320,7 +356,7 @@ const buildSingleRepoContext = (repo) => {
320
356
  const buildMultiRepoContext = (dirMap) => {
321
357
  const lines = ["You are examining multiple repositories:", ""];
322
358
  for (const [dirName, repo] of dirMap) {
323
- const repoDisplay = `${repo.parsed.host}/${repo.parsed.owner}/${repo.parsed.repo}`;
359
+ const repoDisplay = `${repo.parsed.host}/${repo.parsed.path}`;
324
360
  const branchDisplay = repo.branch ?? "default branch";
325
361
  lines.push(`- ${dirName}/ -> ${repoDisplay} (checked out on ${branchDisplay})`);
326
362
  }
@@ -346,7 +382,7 @@ const spawnClaude = (cwd, contextPrompt, args) => new Promise((resolve, reject)
346
382
  "--append-system-prompt",
347
383
  contextPrompt
348
384
  ];
349
- console.error("spawning claude...");
385
+ console.error("summoning claude...");
350
386
  const claude = spawn("claude", claudeArgs, {
351
387
  cwd,
352
388
  stdio: "inherit"
@@ -368,7 +404,7 @@ const handler$1 = async (args) => {
368
404
  if (!mainRepo.branch && args.branch) mainRepo.branch = args.branch;
369
405
  if (args.with.length === 0) {
370
406
  const remoteUrl = normalizeRemote(mainRepo.remote);
371
- console.error(`preparing repository: ${mainRepo.parsed.host}/${mainRepo.parsed.owner}/${mainRepo.parsed.repo}`);
407
+ console.error(`preparing repository: ${mainRepo.parsed.host}/${mainRepo.parsed.path}`);
372
408
  try {
373
409
  await ensureRepo(remoteUrl, mainRepo.cachePath, mainRepo.branch);
374
410
  } catch (err) {
@@ -383,7 +419,7 @@ const handler$1 = async (args) => {
383
419
  console.error("preparing repositories...");
384
420
  const prepareResults = await Promise.allSettled(allRepos.map(async (repo) => {
385
421
  const remoteUrl = normalizeRemote(repo.remote);
386
- const display = `${repo.parsed.host}/${repo.parsed.owner}/${repo.parsed.repo}`;
422
+ const display = `${repo.parsed.host}/${repo.parsed.path}`;
387
423
  console.error(` preparing: ${display}`);
388
424
  await ensureRepo(remoteUrl, repo.cachePath, repo.branch);
389
425
  return repo;
@@ -393,7 +429,7 @@ const handler$1 = async (args) => {
393
429
  const result$1 = prepareResults[i];
394
430
  if (result$1.status === "rejected") {
395
431
  const repo = allRepos[i];
396
- const display = `${repo.parsed.host}/${repo.parsed.owner}/${repo.parsed.repo}`;
432
+ const display = `${repo.parsed.host}/${repo.parsed.path}`;
397
433
  failures.push(` ${display}: ${result$1.reason}`);
398
434
  }
399
435
  }
@@ -417,9 +453,23 @@ const handler$1 = async (args) => {
417
453
  const schema = object({
418
454
  command: constant("clean"),
419
455
  all: option("--all", { description: message`remove all cached repositories` }),
456
+ yes: option("-y", "--yes", { description: message`skip confirmation prompts` }),
420
457
  remote: optional(argument(string({ metavar: "URL" }), { description: message`specific remote URL to clean` }))
421
458
  });
422
459
  /**
460
+ * checks if a path exists.
461
+ * @param path the path to check
462
+ * @returns true if the path exists
463
+ */
464
+ const exists = async (path) => {
465
+ try {
466
+ await stat(path);
467
+ return true;
468
+ } catch {
469
+ return false;
470
+ }
471
+ };
472
+ /**
423
473
  * formats a byte size in human-readable form.
424
474
  * @param bytes size in bytes
425
475
  * @returns formatted string
@@ -435,69 +485,53 @@ const formatSize = (bytes) => {
435
485
  * @param dir directory path
436
486
  * @returns total size in bytes
437
487
  */
438
- const getDirSize = (dir) => {
488
+ const getDirSize = async (dir) => {
439
489
  let size = 0;
440
490
  try {
441
- const entries = readdirSync(dir, { withFileTypes: true });
491
+ const entries = await readdir(dir, { withFileTypes: true });
442
492
  for (const entry of entries) {
443
493
  const fullPath = join(dir, entry.name);
444
- if (entry.isDirectory()) size += getDirSize(fullPath);
445
- else size += statSync(fullPath).size;
494
+ if (entry.isDirectory()) size += await getDirSize(fullPath);
495
+ else {
496
+ const s = await stat(fullPath);
497
+ size += s.size;
498
+ }
446
499
  }
447
500
  } catch {}
448
501
  return size;
449
502
  };
450
503
  /**
504
+ * recursively finds git repositories within a directory.
505
+ * yields directories that contain a .git subdirectory.
506
+ * @param dir directory to search
507
+ */
508
+ async function* findRepos(dir) {
509
+ try {
510
+ const entries = await readdir(dir, { withFileTypes: true });
511
+ if (entries.some((e) => e.name === ".git" && e.isDirectory())) yield dir;
512
+ else for (const entry of entries) if (entry.isDirectory()) yield* findRepos(join(dir, entry.name));
513
+ } catch {}
514
+ }
515
+ /**
451
516
  * lists all cached repositories with their sizes.
452
517
  * @returns array of { path, displayPath, size } objects
453
518
  */
454
- const listCachedRepos = () => {
519
+ const listCachedRepos = async () => {
455
520
  const reposDir = getReposDir();
521
+ if (!await exists(reposDir)) return [];
456
522
  const repos = [];
457
- if (!existsSync(reposDir)) return repos;
458
- for (const host of readdirSync(reposDir)) {
459
- const hostPath = join(reposDir, host);
460
- if (!statSync(hostPath).isDirectory()) continue;
461
- for (const owner of readdirSync(hostPath)) {
462
- const ownerPath = join(hostPath, owner);
463
- if (!statSync(ownerPath).isDirectory()) continue;
464
- for (const repo of readdirSync(ownerPath)) {
465
- const repoPath = join(ownerPath, repo);
466
- if (!statSync(repoPath).isDirectory()) continue;
467
- repos.push({
468
- path: repoPath,
469
- displayPath: `${host}/${owner}/${repo}`,
470
- size: getDirSize(repoPath)
471
- });
472
- }
473
- }
523
+ for await (const repoPath of findRepos(reposDir)) {
524
+ const displayPath = relative(reposDir, repoPath);
525
+ const size = await getDirSize(repoPath);
526
+ repos.push({
527
+ path: repoPath,
528
+ displayPath,
529
+ size
530
+ });
474
531
  }
475
532
  return repos;
476
533
  };
477
534
  /**
478
- * prompts the user for confirmation.
479
- * @param msg the prompt message
480
- * @returns promise that resolves to true if confirmed, or exits on interrupt
481
- */
482
- const confirm = (msg) => new Promise((resolve) => {
483
- let answered = false;
484
- const rl = createInterface({
485
- input: process.stdin,
486
- output: process.stdout
487
- });
488
- rl.on("close", () => {
489
- if (!answered) {
490
- console.log();
491
- process.exit(130);
492
- }
493
- });
494
- rl.question(`${msg} [y/N] `, (answer) => {
495
- answered = true;
496
- rl.close();
497
- resolve(answer.toLowerCase() === "y");
498
- });
499
- });
500
- /**
501
535
  * handles the clean command.
502
536
  * @param args parsed command arguments
503
537
  */
@@ -505,16 +539,21 @@ const handler = async (args) => {
505
539
  const reposDir = getReposDir();
506
540
  const sessionsDir = getSessionsDir();
507
541
  if (args.all) {
508
- const reposExist = existsSync(reposDir);
509
- const sessionsExist$1 = existsSync(sessionsDir);
542
+ const reposExist = await exists(reposDir);
543
+ const sessionsExist$1 = await exists(sessionsDir);
510
544
  if (!reposExist && !sessionsExist$1) {
511
545
  console.log("no cached data found");
512
546
  return;
513
547
  }
514
548
  let totalSize$1 = 0;
515
- if (reposExist) totalSize$1 += getDirSize(reposDir);
516
- if (sessionsExist$1) totalSize$1 += getDirSize(sessionsDir);
517
- console.log(`removing all cached data (${formatSize(totalSize$1)})`);
549
+ if (reposExist) totalSize$1 += await getDirSize(reposDir);
550
+ if (sessionsExist$1) totalSize$1 += await getDirSize(sessionsDir);
551
+ if (!args.yes) {
552
+ if (!await confirm({
553
+ message: `remove all cached data? (${formatSize(totalSize$1)})`,
554
+ default: false
555
+ })) return;
556
+ }
518
557
  if (reposExist) await rm(reposDir, { recursive: true });
519
558
  if (sessionsExist$1) await rm(sessionsDir, { recursive: true });
520
559
  console.log("done");
@@ -526,43 +565,59 @@ const handler = async (args) => {
526
565
  console.error(`error: invalid remote URL: ${args.remote}`);
527
566
  process.exit(1);
528
567
  }
529
- if (!existsSync(cachePath)) {
568
+ if (!await exists(cachePath)) {
530
569
  console.error(`error: repository not cached: ${args.remote}`);
531
570
  process.exit(1);
532
571
  }
533
- const size = getDirSize(cachePath);
534
- console.log(`removing ${cachePath} (${formatSize(size)})`);
572
+ const size = await getDirSize(cachePath);
573
+ if (!args.yes) {
574
+ if (!await confirm({
575
+ message: `remove ${args.remote}? (${formatSize(size)})`,
576
+ default: false
577
+ })) return;
578
+ }
535
579
  await rm(cachePath, { recursive: true });
536
580
  console.log("done");
537
581
  return;
538
582
  }
539
- const repos = listCachedRepos();
540
- const sessionsExist = existsSync(sessionsDir);
541
- const sessionsSize = sessionsExist ? getDirSize(sessionsDir) : 0;
583
+ const repos = await listCachedRepos();
584
+ const sessionsExist = await exists(sessionsDir);
585
+ const sessionsSize = sessionsExist ? await getDirSize(sessionsDir) : 0;
542
586
  if (repos.length === 0 && !sessionsExist) {
543
587
  console.log("no cached data found");
544
588
  return;
545
589
  }
590
+ const maxNameLen = Math.max(...repos.map((r) => r.displayPath.length), sessionsExist ? 10 : 0);
591
+ const maxSizeLen = Math.max(...repos.map((r) => formatSize(r.size).length), sessionsExist ? formatSize(sessionsSize).length : 0);
592
+ const termWidth = process.stdout.columns ?? 80;
593
+ const usePadding = maxNameLen + 2 + maxSizeLen + 6 <= termWidth;
594
+ const formatChoice = (name, size) => usePadding ? `${name.padEnd(maxNameLen)} ${formatSize(size)}` : `${name} (${formatSize(size)})`;
595
+ const choices = repos.map((repo) => ({
596
+ name: formatChoice(repo.displayPath, repo.size),
597
+ value: repo.path,
598
+ short: repo.displayPath
599
+ }));
600
+ if (sessionsExist && sessionsSize > 0) choices.push({
601
+ name: formatChoice("(sessions)", sessionsSize),
602
+ value: sessionsDir,
603
+ short: "(sessions)"
604
+ });
605
+ const selected = await checkbox({
606
+ message: "select items to remove",
607
+ choices,
608
+ pageSize: 20
609
+ });
610
+ if (selected.length === 0) return;
546
611
  let totalSize = 0;
547
- if (repos.length > 0) {
548
- console.log("cached repositories:\n");
549
- for (const repo of repos) {
550
- console.log(` ${repo.displayPath.padEnd(50)} ${formatSize(repo.size)}`);
551
- totalSize += repo.size;
552
- }
553
- }
554
- if (sessionsExist && sessionsSize > 0) {
555
- if (repos.length > 0) console.log();
556
- console.log(`sessions: ${formatSize(sessionsSize)}`);
557
- totalSize += sessionsSize;
558
- }
559
- console.log(`\n total: ${formatSize(totalSize)}`);
560
- console.log();
561
- if (await confirm("remove all cached data?")) {
562
- if (repos.length > 0) await rm(reposDir, { recursive: true });
563
- if (sessionsExist) await rm(sessionsDir, { recursive: true });
564
- console.log("done");
612
+ for (const path of selected) totalSize += await getDirSize(path);
613
+ if (!args.yes) {
614
+ if (!await confirm({
615
+ message: `remove ${selected.length} item(s)? (${formatSize(totalSize)})`,
616
+ default: false
617
+ })) return;
565
618
  }
619
+ for (const path of selected) await rm(path, { recursive: true });
620
+ console.log("done");
566
621
  };
567
622
 
568
623
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oomfware/cgr",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "ask questions about git repositories using Claude Code",
5
5
  "license": "0BSD",
6
6
  "repository": {
@@ -21,6 +21,8 @@
21
21
  "access": "public"
22
22
  },
23
23
  "dependencies": {
24
+ "@inquirer/checkbox": "5.0.4",
25
+ "@inquirer/confirm": "6.0.4",
24
26
  "@optique/core": "^0.9.1",
25
27
  "@optique/run": "^0.9.1"
26
28
  },
@@ -1,5 +1,5 @@
1
- You are a code research assistant analyzing an external repository. Your goal is to answer the
2
- user's question accurately and thoroughly by exploring the codebase.
1
+ You are a code research assistant with read-only access to one or more repositories. Your goal is to
2
+ answer the user's question by exploring the codebase—you cannot modify any files.
3
3
 
4
4
  ## Available tools
5
5
 
@@ -28,23 +28,64 @@ You also have read-only Bash access for standard Unix tools when needed.
28
28
 
29
29
  ## Guidelines
30
30
 
31
+ - **Be direct** - Answer the question, don't narrate your process. Skip preamble like "Perfect!",
32
+ "Now I understand...", or "Let me explain..."
31
33
  - **Explore first** - Don't guess. Use Glob and Grep to find relevant files, then Read to understand
32
34
  them. Trace imports, function calls, and data flow.
33
- - **Cite your sources** - Reference file paths and line numbers. When specifics matter, include
34
- actual code snippets rather than paraphrasing.
35
- - **Explain the why** - Don't just describe what code does; explain why it exists and how it fits
36
- into the larger picture.
37
- - **Use history** - When relevant, use git log/blame/show to understand how code evolved.
38
- - **Admit uncertainty** - If you're unsure about something, say so and explain what you did find.
35
+ - **Cite your sources** - Back up claims with evidence:
36
+ 1. Add footnotes referencing where a statement is sourced:
37
+
38
+ ```
39
+ The cache is invalidated whenever a user updates their profile. [^1]
40
+
41
+ [^1]: **`src/services/user.ts:89`** - updateProfile() calls cache.invalidate()
42
+ ```
43
+
44
+ ```
45
+ The popover flips to the opposite side when it would overflow the viewport. [^2]
46
+
47
+ [^2]: **`src/utils/useAnchorPositioning.ts:215-220`** - flip middleware from Floating UI
48
+ ```
49
+
50
+ 2. Reference file paths and line numbers directly in prose:
51
+
52
+ ```
53
+ As shown in `src/config/database.ts:12`, the connection pool defaults to 10.
54
+ ```
39
55
 
40
- When citing code, use this format:
56
+ ```
57
+ The `useSignal` hook in `packages/react/src/index.ts:53` returns a stable reference.
58
+ ```
41
59
 
42
- **`path/to/file.ts:42-50`**
60
+ 3. Include code snippets when they help illustrate the point:
43
61
 
44
- ```typescript
45
- function example() {
46
- return 'actual code from the file';
47
- }
48
- ```
62
+ ```
63
+ Signals track dependencies automatically when accessed inside an effect:
49
64
 
50
- If examining multiple repositories, prefix paths with the repository name.
65
+ **`packages/core/src/index.ts:152-158`**
66
+
67
+ if (evalContext !== undefined) {
68
+ let node = evalContext._sources;
69
+ // Subscribe to the signal
70
+ node._source._subscribe(node);
71
+ }
72
+ ```
73
+
74
+ ```
75
+ Errors are wrapped with context before being rethrown:
76
+
77
+ **`src/utils/errors.ts:22-26`**
78
+
79
+ catch (err) {
80
+ throw new AppError(`Failed to ${operation}`, { cause: err });
81
+ }
82
+ ```
83
+
84
+ If examining multiple repositories, prefix paths with the repository name.
85
+
86
+ - **Explain the why** - Don't just describe what code does; explain why it exists and how it fits
87
+ into the larger picture.
88
+ - **Compare implementations** - When examining multiple repositories, highlight differences in
89
+ approach. Tables work well for summarizing tradeoffs.
90
+ - **Use history** - When relevant, use git log/blame/show to understand how code evolved.
91
+ - **Admit uncertainty** - If you're unsure about something, say so and explain what you did find.
@@ -58,8 +58,9 @@ export type Args = InferValue<typeof schema>;
58
58
  */
59
59
  const exitInvalidRemote = (remote: string): never => {
60
60
  console.error(`error: invalid remote URL: ${remote}`);
61
- console.error('expected format: host/owner/repo, e.g.:');
61
+ console.error('expected format: host/path, e.g.:');
62
62
  console.error(' github.com/user/repo');
63
+ console.error(' gitlab.com/group/subgroup/repo');
63
64
  console.error(' https://github.com/user/repo');
64
65
  console.error(' git@github.com:user/repo.git');
65
66
  process.exit(1);
@@ -90,7 +91,7 @@ const parseRepoInput = (input: string): RepoEntry => {
90
91
  * @returns context prompt string
91
92
  */
92
93
  const buildSingleRepoContext = (repo: RepoEntry): string => {
93
- const repoDisplay = `${repo.parsed.host}/${repo.parsed.owner}/${repo.parsed.repo}`;
94
+ const repoDisplay = `${repo.parsed.host}/${repo.parsed.path}`;
94
95
  const branchDisplay = repo.branch ?? 'default branch';
95
96
  return `You are examining ${repoDisplay} (checked out on ${branchDisplay}).`;
96
97
  };
@@ -103,7 +104,7 @@ const buildSingleRepoContext = (repo: RepoEntry): string => {
103
104
  const buildMultiRepoContext = (dirMap: Map<string, RepoEntry>): string => {
104
105
  const lines = ['You are examining multiple repositories:', ''];
105
106
  for (const [dirName, repo] of dirMap) {
106
- const repoDisplay = `${repo.parsed.host}/${repo.parsed.owner}/${repo.parsed.repo}`;
107
+ const repoDisplay = `${repo.parsed.host}/${repo.parsed.path}`;
107
108
  const branchDisplay = repo.branch ?? 'default branch';
108
109
  lines.push(`- ${dirName}/ -> ${repoDisplay} (checked out on ${branchDisplay})`);
109
110
  }
@@ -132,7 +133,7 @@ const spawnClaude = (cwd: string, contextPrompt: string, args: Args): Promise<nu
132
133
  contextPrompt,
133
134
  ];
134
135
 
135
- console.error('spawning claude...');
136
+ console.error('summoning claude...');
136
137
  const claude = spawn('claude', claudeArgs, {
137
138
  cwd,
138
139
  stdio: 'inherit',
@@ -165,9 +166,7 @@ export const handler = async (args: Args): Promise<void> => {
165
166
  if (args.with.length === 0) {
166
167
  // clone or update repository
167
168
  const remoteUrl = normalizeRemote(mainRepo.remote);
168
- console.error(
169
- `preparing repository: ${mainRepo.parsed.host}/${mainRepo.parsed.owner}/${mainRepo.parsed.repo}`,
170
- );
169
+ console.error(`preparing repository: ${mainRepo.parsed.host}/${mainRepo.parsed.path}`);
171
170
  try {
172
171
  await ensureRepo(remoteUrl, mainRepo.cachePath, mainRepo.branch);
173
172
  } catch (err) {
@@ -191,7 +190,7 @@ export const handler = async (args: Args): Promise<void> => {
191
190
  const prepareResults = await Promise.allSettled(
192
191
  allRepos.map(async (repo) => {
193
192
  const remoteUrl = normalizeRemote(repo.remote);
194
- const display = `${repo.parsed.host}/${repo.parsed.owner}/${repo.parsed.repo}`;
193
+ const display = `${repo.parsed.host}/${repo.parsed.path}`;
195
194
  console.error(` preparing: ${display}`);
196
195
  await ensureRepo(remoteUrl, repo.cachePath, repo.branch);
197
196
  return repo;
@@ -204,7 +203,7 @@ export const handler = async (args: Args): Promise<void> => {
204
203
  const result = prepareResults[i]!;
205
204
  if (result.status === 'rejected') {
206
205
  const repo = allRepos[i]!;
207
- const display = `${repo.parsed.host}/${repo.parsed.owner}/${repo.parsed.repo}`;
206
+ const display = `${repo.parsed.host}/${repo.parsed.path}`;
208
207
  failures.push(` ${display}: ${result.reason}`);
209
208
  }
210
209
  }
@@ -1,8 +1,8 @@
1
- import { existsSync, readdirSync, statSync } from 'node:fs';
2
- import { rm } from 'node:fs/promises';
3
- import { join } from 'node:path';
4
- import { createInterface } from 'node:readline';
1
+ import { readdir, rm, stat } from 'node:fs/promises';
2
+ import { join, relative } from 'node:path';
5
3
 
4
+ import checkbox from '@inquirer/checkbox';
5
+ import confirm from '@inquirer/confirm';
6
6
  import { argument, constant, type InferValue, message, object, option, string } from '@optique/core';
7
7
  import { optional } from '@optique/core/modifiers';
8
8
 
@@ -11,6 +11,7 @@ import { getRepoCachePath, getReposDir, getSessionsDir } from '../lib/paths.ts';
11
11
  export const schema = object({
12
12
  command: constant('clean'),
13
13
  all: option('--all', { description: message`remove all cached repositories` }),
14
+ yes: option('-y', '--yes', { description: message`skip confirmation prompts` }),
14
15
  remote: optional(
15
16
  argument(string({ metavar: 'URL' }), {
16
17
  description: message`specific remote URL to clean`,
@@ -20,6 +21,26 @@ export const schema = object({
20
21
 
21
22
  export type Args = InferValue<typeof schema>;
22
23
 
24
+ type Choice = {
25
+ name: string;
26
+ value: string;
27
+ short: string;
28
+ };
29
+
30
+ /**
31
+ * checks if a path exists.
32
+ * @param path the path to check
33
+ * @returns true if the path exists
34
+ */
35
+ const exists = async (path: string): Promise<boolean> => {
36
+ try {
37
+ await stat(path);
38
+ return true;
39
+ } catch {
40
+ return false;
41
+ }
42
+ };
43
+
23
44
  /**
24
45
  * formats a byte size in human-readable form.
25
46
  * @param bytes size in bytes
@@ -43,16 +64,17 @@ const formatSize = (bytes: number): string => {
43
64
  * @param dir directory path
44
65
  * @returns total size in bytes
45
66
  */
46
- const getDirSize = (dir: string): number => {
67
+ const getDirSize = async (dir: string): Promise<number> => {
47
68
  let size = 0;
48
69
  try {
49
- const entries = readdirSync(dir, { withFileTypes: true });
70
+ const entries = await readdir(dir, { withFileTypes: true });
50
71
  for (const entry of entries) {
51
72
  const fullPath = join(dir, entry.name);
52
73
  if (entry.isDirectory()) {
53
- size += getDirSize(fullPath);
74
+ size += await getDirSize(fullPath);
54
75
  } else {
55
- size += statSync(fullPath).size;
76
+ const s = await stat(fullPath);
77
+ size += s.size;
56
78
  }
57
79
  }
58
80
  } catch {
@@ -61,81 +83,50 @@ const getDirSize = (dir: string): number => {
61
83
  return size;
62
84
  };
63
85
 
86
+ /**
87
+ * recursively finds git repositories within a directory.
88
+ * yields directories that contain a .git subdirectory.
89
+ * @param dir directory to search
90
+ */
91
+ async function* findRepos(dir: string): AsyncGenerator<string> {
92
+ try {
93
+ const entries = await readdir(dir, { withFileTypes: true });
94
+ const hasGit = entries.some((e) => e.name === '.git' && e.isDirectory());
95
+
96
+ if (hasGit) {
97
+ yield dir;
98
+ } else {
99
+ for (const entry of entries) {
100
+ if (entry.isDirectory()) {
101
+ yield* findRepos(join(dir, entry.name));
102
+ }
103
+ }
104
+ }
105
+ } catch {
106
+ // ignore errors (e.g., permission denied)
107
+ }
108
+ }
109
+
64
110
  /**
65
111
  * lists all cached repositories with their sizes.
66
112
  * @returns array of { path, displayPath, size } objects
67
113
  */
68
- const listCachedRepos = (): {
69
- path: string;
70
- displayPath: string;
71
- size: number;
72
- }[] => {
114
+ const listCachedRepos = async (): Promise<{ path: string; displayPath: string; size: number }[]> => {
73
115
  const reposDir = getReposDir();
74
- const repos: { path: string; displayPath: string; size: number }[] = [];
75
116
 
76
- if (!existsSync(reposDir)) {
77
- return repos;
117
+ if (!(await exists(reposDir))) {
118
+ return [];
78
119
  }
79
120
 
80
- // iterate hosts
81
- for (const host of readdirSync(reposDir)) {
82
- const hostPath = join(reposDir, host);
83
- if (!statSync(hostPath).isDirectory()) {
84
- continue;
85
- }
86
-
87
- // iterate owners
88
- for (const owner of readdirSync(hostPath)) {
89
- const ownerPath = join(hostPath, owner);
90
- if (!statSync(ownerPath).isDirectory()) {
91
- continue;
92
- }
93
-
94
- // iterate repos
95
- for (const repo of readdirSync(ownerPath)) {
96
- const repoPath = join(ownerPath, repo);
97
- if (!statSync(repoPath).isDirectory()) {
98
- continue;
99
- }
100
-
101
- repos.push({
102
- path: repoPath,
103
- displayPath: `${host}/${owner}/${repo}`,
104
- size: getDirSize(repoPath),
105
- });
106
- }
107
- }
121
+ const repos: { path: string; displayPath: string; size: number }[] = [];
122
+ for await (const repoPath of findRepos(reposDir)) {
123
+ const displayPath = relative(reposDir, repoPath);
124
+ const size = await getDirSize(repoPath);
125
+ repos.push({ path: repoPath, displayPath, size });
108
126
  }
109
-
110
127
  return repos;
111
128
  };
112
129
 
113
- /**
114
- * prompts the user for confirmation.
115
- * @param msg the prompt message
116
- * @returns promise that resolves to true if confirmed, or exits on interrupt
117
- */
118
- const confirm = (msg: string): Promise<boolean> =>
119
- new Promise((resolve) => {
120
- let answered = false;
121
- const rl = createInterface({
122
- input: process.stdin,
123
- output: process.stdout,
124
- });
125
- rl.on('close', () => {
126
- if (!answered) {
127
- // handle Ctrl+C or stream close
128
- console.log();
129
- process.exit(130);
130
- }
131
- });
132
- rl.question(`${msg} [y/N] `, (answer) => {
133
- answered = true;
134
- rl.close();
135
- resolve(answer.toLowerCase() === 'y');
136
- });
137
- });
138
-
139
130
  /**
140
131
  * handles the clean command.
141
132
  * @param args parsed command arguments
@@ -144,10 +135,10 @@ export const handler = async (args: Args): Promise<void> => {
144
135
  const reposDir = getReposDir();
145
136
  const sessionsDir = getSessionsDir();
146
137
 
147
- // clean all
138
+ // #region clean all
148
139
  if (args.all) {
149
- const reposExist = existsSync(reposDir);
150
- const sessionsExist = existsSync(sessionsDir);
140
+ const reposExist = await exists(reposDir);
141
+ const sessionsExist = await exists(sessionsDir);
151
142
 
152
143
  if (!reposExist && !sessionsExist) {
153
144
  console.log('no cached data found');
@@ -156,13 +147,22 @@ export const handler = async (args: Args): Promise<void> => {
156
147
 
157
148
  let totalSize = 0;
158
149
  if (reposExist) {
159
- totalSize += getDirSize(reposDir);
150
+ totalSize += await getDirSize(reposDir);
160
151
  }
161
152
  if (sessionsExist) {
162
- totalSize += getDirSize(sessionsDir);
153
+ totalSize += await getDirSize(sessionsDir);
154
+ }
155
+
156
+ if (!args.yes) {
157
+ const confirmed = await confirm({
158
+ message: `remove all cached data? (${formatSize(totalSize)})`,
159
+ default: false,
160
+ });
161
+ if (!confirmed) {
162
+ return;
163
+ }
163
164
  }
164
165
 
165
- console.log(`removing all cached data (${formatSize(totalSize)})`);
166
166
  if (reposExist) {
167
167
  await rm(reposDir, { recursive: true });
168
168
  }
@@ -172,64 +172,110 @@ export const handler = async (args: Args): Promise<void> => {
172
172
  console.log('done');
173
173
  return;
174
174
  }
175
+ // #endregion
175
176
 
176
- // clean specific remote
177
+ // #region clean specific remote
177
178
  if (args.remote) {
178
179
  const cachePath = getRepoCachePath(args.remote);
179
180
  if (!cachePath) {
180
181
  console.error(`error: invalid remote URL: ${args.remote}`);
181
182
  process.exit(1);
182
183
  }
183
- if (!existsSync(cachePath)) {
184
+ if (!(await exists(cachePath))) {
184
185
  console.error(`error: repository not cached: ${args.remote}`);
185
186
  process.exit(1);
186
187
  }
187
- const size = getDirSize(cachePath);
188
- console.log(`removing ${cachePath} (${formatSize(size)})`);
188
+
189
+ const size = await getDirSize(cachePath);
190
+
191
+ if (!args.yes) {
192
+ const confirmed = await confirm({
193
+ message: `remove ${args.remote}? (${formatSize(size)})`,
194
+ default: false,
195
+ });
196
+ if (!confirmed) {
197
+ return;
198
+ }
199
+ }
200
+
189
201
  await rm(cachePath, { recursive: true });
190
202
  console.log('done');
191
203
  return;
192
204
  }
205
+ // #endregion
193
206
 
194
- // list repos and prompt for confirmation
195
- const repos = listCachedRepos();
196
- const sessionsExist = existsSync(sessionsDir);
197
- const sessionsSize = sessionsExist ? getDirSize(sessionsDir) : 0;
207
+ // #region interactive selection
208
+ const repos = await listCachedRepos();
209
+ const sessionsExist = await exists(sessionsDir);
210
+ const sessionsSize = sessionsExist ? await getDirSize(sessionsDir) : 0;
198
211
 
199
212
  if (repos.length === 0 && !sessionsExist) {
200
213
  console.log('no cached data found');
201
214
  return;
202
215
  }
203
216
 
204
- let totalSize = 0;
217
+ // build choices for checkbox
218
+ const maxNameLen = Math.max(
219
+ ...repos.map((r) => r.displayPath.length),
220
+ sessionsExist ? '(sessions)'.length : 0,
221
+ );
222
+ const maxSizeLen = Math.max(
223
+ ...repos.map((r) => formatSize(r.size).length),
224
+ sessionsExist ? formatSize(sessionsSize).length : 0,
225
+ );
205
226
 
206
- if (repos.length > 0) {
207
- console.log('cached repositories:\n');
208
- for (const repo of repos) {
209
- console.log(` ${repo.displayPath.padEnd(50)} ${formatSize(repo.size)}`);
210
- totalSize += repo.size;
211
- }
212
- }
227
+ // checkbox prefix is ~4 chars, leave some margin
228
+ const termWidth = process.stdout.columns ?? 80;
229
+ const usePadding = maxNameLen + 2 + maxSizeLen + 6 <= termWidth;
230
+
231
+ const formatChoice = (name: string, size: number): string =>
232
+ usePadding
233
+ ? `${name.padEnd(maxNameLen)} ${formatSize(size)}`
234
+ : `${name} (${formatSize(size)})`;
235
+
236
+ const choices: Choice[] = repos.map((repo) => ({
237
+ name: formatChoice(repo.displayPath, repo.size),
238
+ value: repo.path,
239
+ short: repo.displayPath,
240
+ }));
213
241
 
214
242
  if (sessionsExist && sessionsSize > 0) {
215
- if (repos.length > 0) {
216
- console.log();
217
- }
218
- console.log(`sessions: ${formatSize(sessionsSize)}`);
219
- totalSize += sessionsSize;
243
+ choices.push({
244
+ name: formatChoice('(sessions)', sessionsSize),
245
+ value: sessionsDir,
246
+ short: '(sessions)',
247
+ });
220
248
  }
221
249
 
222
- console.log(`\n total: ${formatSize(totalSize)}`);
223
- console.log();
250
+ const selected = await checkbox({
251
+ message: 'select items to remove',
252
+ choices,
253
+ pageSize: 20,
254
+ });
224
255
 
225
- const confirmed = await confirm('remove all cached data?');
226
- if (confirmed) {
227
- if (repos.length > 0) {
228
- await rm(reposDir, { recursive: true });
229
- }
230
- if (sessionsExist) {
231
- await rm(sessionsDir, { recursive: true });
256
+ if (selected.length === 0) {
257
+ return;
258
+ }
259
+
260
+ // calculate total size of selected items
261
+ let totalSize = 0;
262
+ for (const path of selected) {
263
+ totalSize += await getDirSize(path);
264
+ }
265
+
266
+ if (!args.yes) {
267
+ const confirmed = await confirm({
268
+ message: `remove ${selected.length} item(s)? (${formatSize(totalSize)})`,
269
+ default: false,
270
+ });
271
+ if (!confirmed) {
272
+ return;
232
273
  }
233
- console.log('done');
234
274
  }
275
+
276
+ for (const path of selected) {
277
+ await rm(path, { recursive: true });
278
+ }
279
+ console.log('done');
280
+ // #endregion
235
281
  };
@@ -0,0 +1,11 @@
1
+ export const debugEnabled = process.env.DEBUG === '1' || process.env.CGR_DEBUG === '1';
2
+
3
+ /**
4
+ * logs a debug message to stderr if DEBUG=1 or CGR_DEBUG=1.
5
+ * @param message the message to log
6
+ */
7
+ export const debug = (message: string): void => {
8
+ if (debugEnabled) {
9
+ console.error(`[debug] ${message}`);
10
+ }
11
+ };
package/src/lib/git.ts CHANGED
@@ -3,22 +3,28 @@ import { existsSync } from 'node:fs';
3
3
  import { mkdir } from 'node:fs/promises';
4
4
  import { dirname } from 'node:path';
5
5
 
6
+ import { debug, debugEnabled } from './debug.ts';
7
+
6
8
  /**
7
9
  * executes a git command silently, only showing output on failure.
10
+ * when debug is enabled, inherits stdio to show git progress.
8
11
  * @param args git command arguments
9
12
  * @param cwd working directory
10
13
  * @returns promise that resolves when the command completes
11
14
  */
12
15
  const git = (args: string[], cwd?: string): Promise<void> =>
13
16
  new Promise((resolve, reject) => {
17
+ debug(`git ${args.join(' ')}${cwd ? ` (in ${cwd})` : ''}`);
14
18
  const proc = spawn('git', args, {
15
19
  cwd,
16
- stdio: ['inherit', 'pipe', 'pipe'],
20
+ stdio: debugEnabled ? 'inherit' : ['inherit', 'pipe', 'pipe'],
17
21
  });
18
22
  let stderr = '';
19
- proc.stderr!.on('data', (data: Buffer) => {
20
- stderr += data.toString();
21
- });
23
+ if (!debugEnabled) {
24
+ proc.stderr!.on('data', (data: Buffer) => {
25
+ stderr += data.toString();
26
+ });
27
+ }
22
28
  proc.on('close', (code) => {
23
29
  if (code === 0) {
24
30
  resolve();
@@ -34,24 +40,28 @@ const git = (args: string[], cwd?: string): Promise<void> =>
34
40
 
35
41
  /**
36
42
  * executes a git command and captures stdout, only showing stderr on failure.
43
+ * when debug is enabled, inherits stderr to show git progress.
37
44
  * @param args git command arguments
38
45
  * @param cwd working directory
39
46
  * @returns promise that resolves with stdout
40
47
  */
41
48
  const gitOutput = (args: string[], cwd?: string): Promise<string> =>
42
49
  new Promise((resolve, reject) => {
50
+ debug(`git ${args.join(' ')}${cwd ? ` (in ${cwd})` : ''}`);
43
51
  const proc = spawn('git', args, {
44
52
  cwd,
45
- stdio: ['inherit', 'pipe', 'pipe'],
53
+ stdio: ['inherit', 'pipe', debugEnabled ? 'inherit' : 'pipe'],
46
54
  });
47
55
  let output = '';
48
56
  let stderr = '';
49
57
  proc.stdout!.on('data', (data: Buffer) => {
50
58
  output += data.toString();
51
59
  });
52
- proc.stderr!.on('data', (data: Buffer) => {
53
- stderr += data.toString();
54
- });
60
+ if (!debugEnabled) {
61
+ proc.stderr!.on('data', (data: Buffer) => {
62
+ stderr += data.toString();
63
+ });
64
+ }
55
65
  proc.on('close', (code) => {
56
66
  if (code === 0) {
57
67
  resolve(output.trim());
package/src/lib/paths.ts CHANGED
@@ -28,46 +28,88 @@ export const getCacheDir = (): string => {
28
28
  */
29
29
  export const getReposDir = (): string => join(getCacheDir(), 'repos');
30
30
 
31
+ // windows reserved names that cannot be used as filenames
32
+ const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
33
+
34
+ // characters that are invalid in windows filenames
35
+ const UNSAFE_CHARS = /[<>:"|?*\\]/g;
36
+
31
37
  /**
32
- * parses a git remote URL and extracts host, owner, and repo.
38
+ * sanitizes a path segment to be safe on all filesystems.
39
+ * encodes unsafe characters using percent-encoding and handles windows reserved names.
40
+ * @param segment the path segment to sanitize
41
+ * @returns sanitized segment
42
+ */
43
+ const sanitizeSegment = (segment: string): string => {
44
+ let safe = segment
45
+ // encode percent first to avoid double-encoding
46
+ .replace(/%/g, '%25')
47
+ // encode windows-unsafe characters
48
+ .replace(UNSAFE_CHARS, (c) => `%${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
49
+ // trim trailing dots and spaces (windows doesn't allow them)
50
+ .replace(/[. ]+$/, (m) =>
51
+ m
52
+ .split('')
53
+ .map((c) => `%${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
54
+ .join(''),
55
+ );
56
+
57
+ // handle windows reserved names by appending an underscore
58
+ if (WINDOWS_RESERVED.test(safe)) {
59
+ safe = `${safe}_`;
60
+ }
61
+
62
+ return safe;
63
+ };
64
+
65
+ /**
66
+ * parses a git remote URL and extracts host and path.
33
67
  * supports HTTP(S), SSH, and bare URLs (assumes HTTPS).
34
68
  * @param remote the remote URL to parse
35
69
  * @returns parsed components or null if invalid
36
70
  */
37
- export const parseRemote = (remote: string): { host: string; owner: string; repo: string } | null => {
38
- // HTTP(S): https://github.com/user/repo or https://github.com/user/repo.git
39
- const httpMatch = remote.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/);
71
+ export const parseRemote = (remote: string): { host: string; path: string } | null => {
72
+ // HTTP(S): https://github.com/user/repo[/more/paths] or ending with .git
73
+ const httpMatch = remote.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?$/);
40
74
  if (httpMatch) {
41
75
  return {
42
76
  host: httpMatch[1]!,
43
- owner: httpMatch[2]!,
44
- repo: httpMatch[3]!,
77
+ path: httpMatch[2]!,
45
78
  };
46
79
  }
47
80
 
48
- // SSH: git@github.com:user/repo.git or git@github.com:user/repo
49
- const sshMatch = remote.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/);
81
+ // SSH: git@github.com:path/to/repo.git or git@github.com:path/to/repo
82
+ const sshMatch = remote.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
50
83
  if (sshMatch) {
51
84
  return {
52
85
  host: sshMatch[1]!,
53
- owner: sshMatch[2]!,
54
- repo: sshMatch[3]!,
86
+ path: sshMatch[2]!,
55
87
  };
56
88
  }
57
89
 
58
90
  // bare URL: github.com/user/repo (assumes HTTPS)
59
- const bareMatch = remote.match(/^([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/);
91
+ const bareMatch = remote.match(/^([^/]+)\/(.+?)(?:\.git)?$/);
60
92
  if (bareMatch) {
61
93
  return {
62
94
  host: bareMatch[1]!,
63
- owner: bareMatch[2]!,
64
- repo: bareMatch[3]!,
95
+ path: bareMatch[2]!,
65
96
  };
66
97
  }
67
98
 
68
99
  return null;
69
100
  };
70
101
 
102
+ /**
103
+ * sanitizes a parsed remote path for safe filesystem storage.
104
+ * @param parsed the parsed remote (host + path)
105
+ * @returns sanitized path segments joined with the system separator
106
+ */
107
+ export const sanitizeRemotePath = (parsed: { host: string; path: string }): string => {
108
+ const host = sanitizeSegment(parsed.host.toLowerCase());
109
+ const pathSegments = parsed.path.toLowerCase().split('/').map(sanitizeSegment);
110
+ return join(host, ...pathSegments);
111
+ };
112
+
71
113
  /**
72
114
  * normalizes a remote URL by prepending https:// if no protocol is present.
73
115
  * @param remote the remote URL to normalize
@@ -90,7 +132,7 @@ export const getRepoCachePath = (remote: string): string | null => {
90
132
  if (!parsed) {
91
133
  return null;
92
134
  }
93
- return join(getReposDir(), parsed.host, parsed.owner, parsed.repo);
135
+ return join(getReposDir(), sanitizeRemotePath(parsed));
94
136
  };
95
137
 
96
138
  /**
@@ -1,8 +1,8 @@
1
1
  import { symlink } from 'node:fs/promises';
2
- import { join } from 'node:path';
2
+ import { basename, join } from 'node:path';
3
3
 
4
4
  /** parsed remote information */
5
- export type ParsedRemote = { host: string; owner: string; repo: string };
5
+ export type ParsedRemote = { host: string; path: string };
6
6
 
7
7
  /** repository entry with all metadata needed for symlinking */
8
8
  export type RepoEntry = {
@@ -27,13 +27,15 @@ export const buildSymlinkDir = async (
27
27
  const usedNames = new Set<string>();
28
28
 
29
29
  for (const repo of repos) {
30
- let name = repo.parsed.repo;
30
+ // use the last component of the path as the directory name
31
+ const repoName = basename(repo.parsed.path);
32
+ let name = repoName;
31
33
  let suffix = 1;
32
34
 
33
35
  // handle name conflicts
34
36
  while (usedNames.has(name)) {
35
37
  suffix++;
36
- name = `${repo.parsed.repo}-${suffix}`;
38
+ name = `${repoName}-${suffix}`;
37
39
  }
38
40
 
39
41
  usedNames.add(name);