@oomfware/cgr 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,7 +16,10 @@ cgr ask github.com/facebook/react "How do hooks track dependencies to avoid stal
16
16
  cgr ask -m sonnet github.com/shadcn-ui/ui "How does the registry system resolve component dependencies?"
17
17
 
18
18
  # checkout a specific branch
19
- cgr ask -b canary github.com/vercel/next.js "Where is dynamic route resolution handled in the app router?"
19
+ cgr ask github.com/vercel/next.js#canary "Where is dynamic route resolution handled in the app router?"
20
+
21
+ # ask about multiple repositories at once
22
+ cgr ask github.com/facebook/react -w github.com/vercel/next.js "How does Next.js integrate with React's server components?"
20
23
 
21
24
  # works with any git host
22
25
  cgr ask tangled.sh/mary.my.id/atcute "How do I validate AT Protocol lexicon schemas at runtime?"
@@ -39,7 +42,7 @@ cgr clean --all
39
42
  ## commands
40
43
 
41
44
  ```
42
- cgr ask [-m opus|sonnet|haiku] [-b branch] <repo> <question>
45
+ cgr ask [-m opus|sonnet|haiku] [-w repo#branch ...] <repo#branch> <question>
43
46
  cgr clean [--all | <repo>]
44
47
  ```
45
48
 
@@ -55,14 +58,17 @@ add this to your `~/.claude/CLAUDE.md` or project's `CLAUDE.md` to let Claude Co
55
58
  ```markdown
56
59
  ## cgr
57
60
 
58
- Use `npx @oomfware/cgr ask <repo> <question>` to research external repositories. examples:
61
+ Use `npx @oomfware/cgr ask <repo> <question>` to ask questions about external repositories.
59
62
 
60
63
  - `npx @oomfware/cgr ask github.com/facebook/react "How do hooks track dependencies to avoid stale closures in useEffect?"`
61
- - `npx @oomfware/cgr ask -b canary github.com/vercel/next.js "Where is dynamic route resolution handled in the app router?"`
64
+ - `npx @oomfware/cgr ask github.com/vercel/next.js#canary "Where is dynamic route resolution handled in the app router?"`
62
65
  - `npx @oomfware/cgr ask -m sonnet github.com/shadcn-ui/ui "How do I configure path aliases so components install to the right location?"`
66
+ - `npx @oomfware/cgr ask github.com/facebook/react -w github.com/vercel/next.js "How does Next.js integrate with React's server components?"`
67
+
68
+ cgr works best with detailed questions. Include file/folder paths when you know them, and reference
69
+ details from previous answers in follow-ups.
63
70
 
64
- This clones the repo locally and runs Claude Code in read-only mode to analyze it. Run
65
- `npx @oomfware/cgr --help` for more options.
71
+ Run `npx @oomfware/cgr --help` for more options.
66
72
  ```
67
73
 
68
74
  alternatively, a more structured prompt:
@@ -72,13 +78,13 @@ alternatively, a more structured prompt:
72
78
 
73
79
  You can use `@oomfware/cgr` to ask questions about external repositories.
74
80
 
75
- npx @oomfware/cgr ask [options] <repo> <question>
81
+ npx @oomfware/cgr ask [options] <repo#branch> <question>
76
82
 
77
83
  options:
78
- -m, --model <model> model to use: opus, sonnet, haiku (default: haiku)
79
- -b, --branch <branch> branch to checkout
84
+ -m, --model <model> model to use: opus, sonnet, haiku (default: haiku)
85
+ -w, --with <repo> additional repository to include (can be repeated)
80
86
 
81
- useful repositories:
87
+ Useful repositories:
82
88
 
83
89
  - `github.com/facebook/react` for React internals, hooks, reconciler
84
90
  - `github.com/vercel/next.js` for Next.js app router, server components
@@ -86,6 +92,8 @@ useful repositories:
86
92
  - `github.com/tailwindlabs/tailwindcss` for Tailwind internals, plugin API
87
93
  - `github.com/bluesky-social/atproto` for AT Protocol, Bluesky API
88
94
 
89
- This clones the repo locally and runs Claude Code in read-only mode. Run `npx @oomfware/cgr --help`
90
- for more options.
95
+ cgr works best with detailed questions. Include file/folder paths when you know them, and reference
96
+ details from previous answers in follow-ups.
97
+
98
+ Run `npx @oomfware/cgr --help` for more options.
91
99
  ```
@@ -26,21 +26,25 @@ user's question accurately and thoroughly by exploring the codebase.
26
26
 
27
27
  You also have read-only Bash access for standard Unix tools when needed.
28
28
 
29
- ## Approach
30
-
31
- 1. **Explore before answering** - Don't guess. Use Glob and Grep to find relevant files, then Read
32
- to understand them.
33
- 2. **Trace the code** - Follow imports, function calls, and data flow to build a complete picture.
34
- 3. **Check history when relevant** - Use git log/blame/show to understand why code exists or how it
35
- evolved.
36
- 4. **Cite your sources** - Reference specific files and line numbers (e.g.,
37
- `src/hooks/useState.ts:42`).
38
- 5. **Use web resources** - If the codebase references external concepts or you need context, search
39
- for documentation.
40
-
41
- ## Response style
42
-
43
- - Be thorough but focused on the question asked
44
- - Include code snippets when they help explain concepts
45
- - Explain the "why" not just the "what"
46
- - If you're uncertain about something, say so and explain what you did find
29
+ ## Guidelines
30
+
31
+ - **Explore first** - Don't guess. Use Glob and Grep to find relevant files, then Read to understand
32
+ 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.
39
+
40
+ When citing code, use this format:
41
+
42
+ **`path/to/file.ts:42-50`**
43
+
44
+ ```typescript
45
+ function example() {
46
+ return 'actual code from the file';
47
+ }
48
+ ```
49
+
50
+ If examining multiple repositories, prefix paths with the repository name.
package/dist/index.mjs CHANGED
@@ -3,15 +3,16 @@ import { argument, choice, command, constant, message, object, option, or, strin
3
3
  import { run } from "@optique/run";
4
4
  import { spawn } from "node:child_process";
5
5
  import { dirname, join } from "node:path";
6
- import { optional, withDefault } from "@optique/core/modifiers";
6
+ import { multiple, optional, withDefault } from "@optique/core/modifiers";
7
7
  import { existsSync, readdirSync, statSync } from "node:fs";
8
- import { mkdir, rm } from "node:fs/promises";
8
+ import { mkdir, rm, symlink } from "node:fs/promises";
9
+ import { randomUUID } from "node:crypto";
9
10
  import { homedir } from "node:os";
10
11
  import { createInterface } from "node:readline";
11
12
 
12
13
  //#region src/lib/git.ts
13
14
  /**
14
- * executes a git command and returns the result.
15
+ * executes a git command silently, only showing output on failure.
15
16
  * @param args git command arguments
16
17
  * @param cwd working directory
17
18
  * @returns promise that resolves when the command completes
@@ -19,16 +20,27 @@ import { createInterface } from "node:readline";
19
20
  const git = (args, cwd) => new Promise((resolve, reject) => {
20
21
  const proc = spawn("git", args, {
21
22
  cwd,
22
- stdio: "inherit"
23
+ stdio: [
24
+ "inherit",
25
+ "pipe",
26
+ "pipe"
27
+ ]
28
+ });
29
+ let stderr = "";
30
+ proc.stderr.on("data", (data) => {
31
+ stderr += data.toString();
23
32
  });
24
33
  proc.on("close", (code) => {
25
34
  if (code === 0) resolve();
26
- else reject(/* @__PURE__ */ new Error(`git ${args[0]} failed with code ${code}`));
35
+ else {
36
+ if (stderr) process.stderr.write(stderr);
37
+ reject(/* @__PURE__ */ new Error(`git ${args[0]} failed with code ${code}`));
38
+ }
27
39
  });
28
40
  proc.on("error", reject);
29
41
  });
30
42
  /**
31
- * executes a git command and captures stdout.
43
+ * executes a git command and captures stdout, only showing stderr on failure.
32
44
  * @param args git command arguments
33
45
  * @param cwd working directory
34
46
  * @returns promise that resolves with stdout
@@ -39,16 +51,23 @@ const gitOutput = (args, cwd) => new Promise((resolve, reject) => {
39
51
  stdio: [
40
52
  "inherit",
41
53
  "pipe",
42
- "inherit"
54
+ "pipe"
43
55
  ]
44
56
  });
45
57
  let output = "";
58
+ let stderr = "";
46
59
  proc.stdout.on("data", (data) => {
47
60
  output += data.toString();
48
61
  });
62
+ proc.stderr.on("data", (data) => {
63
+ stderr += data.toString();
64
+ });
49
65
  proc.on("close", (code) => {
50
66
  if (code === 0) resolve(output.trim());
51
- else reject(/* @__PURE__ */ new Error(`git ${args[0]} failed with code ${code}`));
67
+ else {
68
+ if (stderr) process.stderr.write(stderr);
69
+ reject(/* @__PURE__ */ new Error(`git ${args[0]} failed with code ${code}`));
70
+ }
52
71
  });
53
72
  proc.on("error", reject);
54
73
  });
@@ -170,6 +189,70 @@ const getRepoCachePath = (remote) => {
170
189
  if (!parsed) return null;
171
190
  return join(getReposDir(), parsed.host, parsed.owner, parsed.repo);
172
191
  };
192
+ /**
193
+ * parses `remote#branch` syntax.
194
+ * @param input the input string, e.g. `github.com/owner/repo#develop`
195
+ * @returns object with remote and optional branch
196
+ */
197
+ const parseRemoteWithBranch = (input) => {
198
+ const hashIndex = input.lastIndexOf("#");
199
+ if (hashIndex === -1) return { remote: input };
200
+ return {
201
+ remote: input.slice(0, hashIndex),
202
+ branch: input.slice(hashIndex + 1)
203
+ };
204
+ };
205
+ /**
206
+ * returns the sessions directory within the cache.
207
+ * @returns the sessions directory path
208
+ */
209
+ const getSessionsDir = () => join(getCacheDir(), "sessions");
210
+ /**
211
+ * creates a new session directory with a random UUID.
212
+ * @returns the path to the created session directory
213
+ */
214
+ const createSessionDir = async () => {
215
+ const sessionPath = join(getSessionsDir(), randomUUID());
216
+ await mkdir(sessionPath, { recursive: true });
217
+ return sessionPath;
218
+ };
219
+ /**
220
+ * removes a session directory.
221
+ * @param sessionPath the session directory path
222
+ */
223
+ const cleanupSessionDir = async (sessionPath) => {
224
+ await rm(sessionPath, {
225
+ recursive: true,
226
+ force: true
227
+ });
228
+ };
229
+
230
+ //#endregion
231
+ //#region src/lib/symlink.ts
232
+ /**
233
+ * builds symlinks in a session directory for all repositories.
234
+ * handles name conflicts by appending `-2`, `-3`, etc.
235
+ * @param sessionPath the session directory path
236
+ * @param repos array of repository entries to symlink
237
+ * @returns map of directory name -> repo entry
238
+ */
239
+ const buildSymlinkDir = async (sessionPath, repos) => {
240
+ const result$1 = /* @__PURE__ */ new Map();
241
+ const usedNames = /* @__PURE__ */ new Set();
242
+ for (const repo of repos) {
243
+ let name = repo.parsed.repo;
244
+ let suffix = 1;
245
+ while (usedNames.has(name)) {
246
+ suffix++;
247
+ name = `${repo.parsed.repo}-${suffix}`;
248
+ }
249
+ usedNames.add(name);
250
+ result$1.set(name, repo);
251
+ const linkPath = join(sessionPath, name);
252
+ await symlink(repo.cachePath, linkPath);
253
+ }
254
+ return result$1;
255
+ };
173
256
 
174
257
  //#endregion
175
258
  //#region src/commands/ask.ts
@@ -183,40 +266,75 @@ const schema$1 = object({
183
266
  "sonnet",
184
267
  "haiku"
185
268
  ]), { description: message`model to use for analysis` }), "haiku"),
186
- branch: optional(option("-b", "--branch", string(), { description: message`branch to checkout` })),
187
- remote: argument(string({ metavar: "REPO" }), { description: message`git remote URL (HTTP/HTTPS/SSH)` }),
269
+ branch: optional(option("-b", "--branch", string(), { description: message`branch to checkout (deprecated: use repo#branch instead)` })),
270
+ with: withDefault(multiple(option("-w", "--with", string(), { description: message`additional repository to include` })), []),
271
+ remote: argument(string({ metavar: "REPO" }), { description: message`git remote URL (HTTP/HTTPS/SSH), optionally with #branch` }),
188
272
  question: argument(string({ metavar: "QUESTION" }), { description: message`question to ask about the repository` })
189
273
  });
190
274
  /**
191
- * handles the ask command.
192
- * clones/updates the repository and spawns Claude Code to answer the question.
193
- * @param args parsed command arguments
275
+ * prints an error message for invalid remote URL and exits.
276
+ * @param remote the invalid remote URL
194
277
  */
195
- const handler$1 = async (args) => {
196
- const parsed = parseRemote(args.remote);
197
- if (!parsed) {
198
- console.error(`error: invalid remote URL: ${args.remote}`);
199
- console.error("expected format: host/owner/repo, e.g.:");
200
- console.error(" github.com/user/repo");
201
- console.error(" https://github.com/user/repo");
202
- console.error(" git@github.com:user/repo.git");
203
- process.exit(1);
204
- }
205
- const cachePath = getRepoCachePath(args.remote);
278
+ const exitInvalidRemote = (remote) => {
279
+ console.error(`error: invalid remote URL: ${remote}`);
280
+ console.error("expected format: host/owner/repo, e.g.:");
281
+ console.error(" github.com/user/repo");
282
+ console.error(" https://github.com/user/repo");
283
+ console.error(" git@github.com:user/repo.git");
284
+ process.exit(1);
285
+ };
286
+ /**
287
+ * validates and parses a remote string (with optional #branch).
288
+ * @param input the remote string, possibly with #branch suffix
289
+ * @returns parsed repo entry or exits on error
290
+ */
291
+ const parseRepoInput = (input) => {
292
+ const { remote, branch } = parseRemoteWithBranch(input);
293
+ const parsed = parseRemote(remote);
294
+ if (!parsed) return exitInvalidRemote(remote);
295
+ const cachePath = getRepoCachePath(remote);
206
296
  if (!cachePath) {
207
- console.error(`error: could not determine cache path for: ${args.remote}`);
297
+ console.error(`error: could not determine cache path for: ${remote}`);
208
298
  process.exit(1);
209
299
  }
210
- const remoteUrl = normalizeRemote(args.remote);
211
- console.error(`preparing repository: ${parsed.host}/${parsed.owner}/${parsed.repo}`);
212
- try {
213
- await ensureRepo(remoteUrl, cachePath, args.branch);
214
- } catch (err) {
215
- console.error(`error: failed to prepare repository: ${err}`);
216
- process.exit(1);
300
+ return {
301
+ remote,
302
+ parsed,
303
+ cachePath,
304
+ branch
305
+ };
306
+ };
307
+ /**
308
+ * builds a context prompt for a single repository.
309
+ * @param repo the repo entry
310
+ * @returns context prompt string
311
+ */
312
+ const buildSingleRepoContext = (repo) => {
313
+ return `You are examining ${`${repo.parsed.host}/${repo.parsed.owner}/${repo.parsed.repo}`} (checked out on ${repo.branch ?? "default branch"}).`;
314
+ };
315
+ /**
316
+ * builds a context prompt for multiple repositories.
317
+ * @param dirMap map of directory name -> repo entry
318
+ * @returns context prompt string
319
+ */
320
+ const buildMultiRepoContext = (dirMap) => {
321
+ const lines = ["You are examining multiple repositories:", ""];
322
+ for (const [dirName, repo] of dirMap) {
323
+ const repoDisplay = `${repo.parsed.host}/${repo.parsed.owner}/${repo.parsed.repo}`;
324
+ const branchDisplay = repo.branch ?? "default branch";
325
+ lines.push(`- ${dirName}/ -> ${repoDisplay} (checked out on ${branchDisplay})`);
217
326
  }
218
- const contextPrompt = `You are examining ${`${parsed.host}/${parsed.owner}/${parsed.repo}`} (checked out on ${args.branch ?? "default branch"}).`;
219
- const claude = spawn("claude", [
327
+ return lines.join("\n");
328
+ };
329
+ /**
330
+ * spawns Claude Code and waits for it to exit.
331
+ * @param cwd working directory
332
+ * @param contextPrompt context prompt for Claude
333
+ * @param args command arguments
334
+ * @returns promise that resolves with exit code
335
+ */
336
+ const spawnClaude = (cwd, contextPrompt, args) => new Promise((resolve, reject) => {
337
+ const claudeArgs = [
220
338
  "-p",
221
339
  args.question,
222
340
  "--model",
@@ -227,17 +345,71 @@ const handler$1 = async (args) => {
227
345
  systemPromptPath,
228
346
  "--append-system-prompt",
229
347
  contextPrompt
230
- ], {
231
- cwd: cachePath,
348
+ ];
349
+ console.error("spawning claude...");
350
+ const claude = spawn("claude", claudeArgs, {
351
+ cwd,
232
352
  stdio: "inherit"
233
353
  });
234
354
  claude.on("close", (code) => {
235
- process.exit(code ?? 0);
355
+ resolve(code ?? 0);
236
356
  });
237
357
  claude.on("error", (err) => {
238
- console.error(`error: failed to spawn claude: ${err}`);
239
- process.exit(1);
358
+ reject(/* @__PURE__ */ new Error(`failed to spawn claude: ${err}`));
240
359
  });
360
+ });
361
+ /**
362
+ * handles the ask command.
363
+ * clones/updates the repository and spawns Claude Code to answer the question.
364
+ * @param args parsed command arguments
365
+ */
366
+ const handler$1 = async (args) => {
367
+ const mainRepo = parseRepoInput(args.remote);
368
+ if (!mainRepo.branch && args.branch) mainRepo.branch = args.branch;
369
+ if (args.with.length === 0) {
370
+ const remoteUrl = normalizeRemote(mainRepo.remote);
371
+ console.error(`preparing repository: ${mainRepo.parsed.host}/${mainRepo.parsed.owner}/${mainRepo.parsed.repo}`);
372
+ try {
373
+ await ensureRepo(remoteUrl, mainRepo.cachePath, mainRepo.branch);
374
+ } catch (err) {
375
+ console.error(`error: failed to prepare repository: ${err}`);
376
+ process.exit(1);
377
+ }
378
+ const contextPrompt = buildSingleRepoContext(mainRepo);
379
+ const exitCode$1 = await spawnClaude(mainRepo.cachePath, contextPrompt, args);
380
+ process.exit(exitCode$1);
381
+ }
382
+ const allRepos = [mainRepo, ...args.with.map(parseRepoInput)];
383
+ console.error("preparing repositories...");
384
+ const prepareResults = await Promise.allSettled(allRepos.map(async (repo) => {
385
+ const remoteUrl = normalizeRemote(repo.remote);
386
+ const display = `${repo.parsed.host}/${repo.parsed.owner}/${repo.parsed.repo}`;
387
+ console.error(` preparing: ${display}`);
388
+ await ensureRepo(remoteUrl, repo.cachePath, repo.branch);
389
+ return repo;
390
+ }));
391
+ const failures = [];
392
+ for (let i = 0; i < prepareResults.length; i++) {
393
+ const result$1 = prepareResults[i];
394
+ if (result$1.status === "rejected") {
395
+ const repo = allRepos[i];
396
+ const display = `${repo.parsed.host}/${repo.parsed.owner}/${repo.parsed.repo}`;
397
+ failures.push(` ${display}: ${result$1.reason}`);
398
+ }
399
+ }
400
+ if (failures.length > 0) {
401
+ console.error("error: failed to prepare repositories:");
402
+ for (const failure of failures) console.error(failure);
403
+ process.exit(1);
404
+ }
405
+ const sessionPath = await createSessionDir();
406
+ let exitCode = 1;
407
+ try {
408
+ exitCode = await spawnClaude(sessionPath, buildMultiRepoContext(await buildSymlinkDir(sessionPath, allRepos)), args);
409
+ } finally {
410
+ await cleanupSessionDir(sessionPath);
411
+ }
412
+ process.exit(exitCode);
241
413
  };
242
414
 
243
415
  //#endregion
@@ -305,14 +477,22 @@ const listCachedRepos = () => {
305
477
  /**
306
478
  * prompts the user for confirmation.
307
479
  * @param msg the prompt message
308
- * @returns promise that resolves to true if confirmed
480
+ * @returns promise that resolves to true if confirmed, or exits on interrupt
309
481
  */
310
482
  const confirm = (msg) => new Promise((resolve) => {
483
+ let answered = false;
311
484
  const rl = createInterface({
312
485
  input: process.stdin,
313
486
  output: process.stdout
314
487
  });
488
+ rl.on("close", () => {
489
+ if (!answered) {
490
+ console.log();
491
+ process.exit(130);
492
+ }
493
+ });
315
494
  rl.question(`${msg} [y/N] `, (answer) => {
495
+ answered = true;
316
496
  rl.close();
317
497
  resolve(answer.toLowerCase() === "y");
318
498
  });
@@ -323,14 +503,20 @@ const confirm = (msg) => new Promise((resolve) => {
323
503
  */
324
504
  const handler = async (args) => {
325
505
  const reposDir = getReposDir();
506
+ const sessionsDir = getSessionsDir();
326
507
  if (args.all) {
327
- if (!existsSync(reposDir)) {
328
- console.log("no cached repositories found");
508
+ const reposExist = existsSync(reposDir);
509
+ const sessionsExist$1 = existsSync(sessionsDir);
510
+ if (!reposExist && !sessionsExist$1) {
511
+ console.log("no cached data found");
329
512
  return;
330
513
  }
331
- const size = getDirSize(reposDir);
332
- console.log(`removing all cached repositories (${formatSize(size)})`);
333
- await rm(reposDir, { recursive: true });
514
+ 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)})`);
518
+ if (reposExist) await rm(reposDir, { recursive: true });
519
+ if (sessionsExist$1) await rm(sessionsDir, { recursive: true });
334
520
  console.log("done");
335
521
  return;
336
522
  }
@@ -351,20 +537,30 @@ const handler = async (args) => {
351
537
  return;
352
538
  }
353
539
  const repos = listCachedRepos();
354
- if (repos.length === 0) {
355
- console.log("no cached repositories found");
540
+ const sessionsExist = existsSync(sessionsDir);
541
+ const sessionsSize = sessionsExist ? getDirSize(sessionsDir) : 0;
542
+ if (repos.length === 0 && !sessionsExist) {
543
+ console.log("no cached data found");
356
544
  return;
357
545
  }
358
- console.log("cached repositories:\n");
359
546
  let totalSize = 0;
360
- for (const repo of repos) {
361
- console.log(` ${repo.displayPath.padEnd(50)} ${formatSize(repo.size)}`);
362
- totalSize += repo.size;
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;
363
558
  }
364
559
  console.log(`\n total: ${formatSize(totalSize)}`);
365
560
  console.log();
366
- if (await confirm("remove all cached repositories?")) {
367
- await rm(reposDir, { recursive: true });
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 });
368
564
  console.log("done");
369
565
  }
370
566
  };
@@ -372,6 +568,7 @@ const handler = async (args) => {
372
568
  //#endregion
373
569
  //#region src/index.ts
374
570
  const result = run(or(command("ask", schema$1, { description: message`ask a question about a repository` }), command("clean", schema, { description: message`remove cached repositories` })), {
571
+ programName: "cgr",
375
572
  help: "both",
376
573
  version: {
377
574
  value: "0.1.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oomfware/cgr",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "ask questions about git repositories using Claude Code",
5
5
  "license": "0BSD",
6
6
  "repository": {
@@ -26,21 +26,25 @@ user's question accurately and thoroughly by exploring the codebase.
26
26
 
27
27
  You also have read-only Bash access for standard Unix tools when needed.
28
28
 
29
- ## Approach
30
-
31
- 1. **Explore before answering** - Don't guess. Use Glob and Grep to find relevant files, then Read
32
- to understand them.
33
- 2. **Trace the code** - Follow imports, function calls, and data flow to build a complete picture.
34
- 3. **Check history when relevant** - Use git log/blame/show to understand why code exists or how it
35
- evolved.
36
- 4. **Cite your sources** - Reference specific files and line numbers (e.g.,
37
- `src/hooks/useState.ts:42`).
38
- 5. **Use web resources** - If the codebase references external concepts or you need context, search
39
- for documentation.
40
-
41
- ## Response style
42
-
43
- - Be thorough but focused on the question asked
44
- - Include code snippets when they help explain concepts
45
- - Explain the "why" not just the "what"
46
- - If you're uncertain about something, say so and explain what you did find
29
+ ## Guidelines
30
+
31
+ - **Explore first** - Don't guess. Use Glob and Grep to find relevant files, then Read to understand
32
+ 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.
39
+
40
+ When citing code, use this format:
41
+
42
+ **`path/to/file.ts:42-50`**
43
+
44
+ ```typescript
45
+ function example() {
46
+ return 'actual code from the file';
47
+ }
48
+ ```
49
+
50
+ If examining multiple repositories, prefix paths with the repository name.
@@ -2,10 +2,18 @@ import { spawn } from 'node:child_process';
2
2
  import { join } from 'node:path';
3
3
 
4
4
  import { argument, choice, constant, type InferValue, message, object, option, string } from '@optique/core';
5
- import { optional, withDefault } from '@optique/core/modifiers';
5
+ import { multiple, optional, withDefault } from '@optique/core/modifiers';
6
6
 
7
7
  import { ensureRepo } from '../lib/git.ts';
8
- import { getRepoCachePath, normalizeRemote, parseRemote } from '../lib/paths.ts';
8
+ import {
9
+ cleanupSessionDir,
10
+ createSessionDir,
11
+ getRepoCachePath,
12
+ normalizeRemote,
13
+ parseRemote,
14
+ parseRemoteWithBranch,
15
+ } from '../lib/paths.ts';
16
+ import { buildSymlinkDir, type RepoEntry } from '../lib/symlink.ts';
9
17
 
10
18
  // resolve asset paths relative to the bundle (dist/index.mjs -> dist/assets/)
11
19
  const assetsDir = join(import.meta.dirname, 'assets');
@@ -20,13 +28,22 @@ export const schema = object({
20
28
  }),
21
29
  'haiku',
22
30
  ),
31
+ // TODO: deprecated in favor of #branch syntax, remove in future version
23
32
  branch: optional(
24
33
  option('-b', '--branch', string(), {
25
- description: message`branch to checkout`,
34
+ description: message`branch to checkout (deprecated: use repo#branch instead)`,
26
35
  }),
27
36
  ),
37
+ with: withDefault(
38
+ multiple(
39
+ option('-w', '--with', string(), {
40
+ description: message`additional repository to include`,
41
+ }),
42
+ ),
43
+ [],
44
+ ),
28
45
  remote: argument(string({ metavar: 'REPO' }), {
29
- description: message`git remote URL (HTTP/HTTPS/SSH)`,
46
+ description: message`git remote URL (HTTP/HTTPS/SSH), optionally with #branch`,
30
47
  }),
31
48
  question: argument(string({ metavar: 'QUESTION' }), {
32
49
  description: message`question to ask about the repository`,
@@ -35,70 +52,184 @@ export const schema = object({
35
52
 
36
53
  export type Args = InferValue<typeof schema>;
37
54
 
55
+ /**
56
+ * prints an error message for invalid remote URL and exits.
57
+ * @param remote the invalid remote URL
58
+ */
59
+ const exitInvalidRemote = (remote: string): never => {
60
+ console.error(`error: invalid remote URL: ${remote}`);
61
+ console.error('expected format: host/owner/repo, e.g.:');
62
+ console.error(' github.com/user/repo');
63
+ console.error(' https://github.com/user/repo');
64
+ console.error(' git@github.com:user/repo.git');
65
+ process.exit(1);
66
+ };
67
+
68
+ /**
69
+ * validates and parses a remote string (with optional #branch).
70
+ * @param input the remote string, possibly with #branch suffix
71
+ * @returns parsed repo entry or exits on error
72
+ */
73
+ const parseRepoInput = (input: string): RepoEntry => {
74
+ const { remote, branch } = parseRemoteWithBranch(input);
75
+ const parsed = parseRemote(remote);
76
+ if (!parsed) {
77
+ return exitInvalidRemote(remote);
78
+ }
79
+ const cachePath = getRepoCachePath(remote);
80
+ if (!cachePath) {
81
+ console.error(`error: could not determine cache path for: ${remote}`);
82
+ process.exit(1);
83
+ }
84
+ return { remote, parsed, cachePath, branch };
85
+ };
86
+
87
+ /**
88
+ * builds a context prompt for a single repository.
89
+ * @param repo the repo entry
90
+ * @returns context prompt string
91
+ */
92
+ const buildSingleRepoContext = (repo: RepoEntry): string => {
93
+ const repoDisplay = `${repo.parsed.host}/${repo.parsed.owner}/${repo.parsed.repo}`;
94
+ const branchDisplay = repo.branch ?? 'default branch';
95
+ return `You are examining ${repoDisplay} (checked out on ${branchDisplay}).`;
96
+ };
97
+
98
+ /**
99
+ * builds a context prompt for multiple repositories.
100
+ * @param dirMap map of directory name -> repo entry
101
+ * @returns context prompt string
102
+ */
103
+ const buildMultiRepoContext = (dirMap: Map<string, RepoEntry>): string => {
104
+ const lines = ['You are examining multiple repositories:', ''];
105
+ for (const [dirName, repo] of dirMap) {
106
+ const repoDisplay = `${repo.parsed.host}/${repo.parsed.owner}/${repo.parsed.repo}`;
107
+ const branchDisplay = repo.branch ?? 'default branch';
108
+ lines.push(`- ${dirName}/ -> ${repoDisplay} (checked out on ${branchDisplay})`);
109
+ }
110
+ return lines.join('\n');
111
+ };
112
+
113
+ /**
114
+ * spawns Claude Code and waits for it to exit.
115
+ * @param cwd working directory
116
+ * @param contextPrompt context prompt for Claude
117
+ * @param args command arguments
118
+ * @returns promise that resolves with exit code
119
+ */
120
+ const spawnClaude = (cwd: string, contextPrompt: string, args: Args): Promise<number> =>
121
+ new Promise((resolve, reject) => {
122
+ const claudeArgs = [
123
+ '-p',
124
+ args.question,
125
+ '--model',
126
+ args.model,
127
+ '--settings',
128
+ settingsPath,
129
+ '--system-prompt-file',
130
+ systemPromptPath,
131
+ '--append-system-prompt',
132
+ contextPrompt,
133
+ ];
134
+
135
+ console.error('spawning claude...');
136
+ const claude = spawn('claude', claudeArgs, {
137
+ cwd,
138
+ stdio: 'inherit',
139
+ });
140
+
141
+ claude.on('close', (code) => {
142
+ resolve(code ?? 0);
143
+ });
144
+
145
+ claude.on('error', (err) => {
146
+ reject(new Error(`failed to spawn claude: ${err}`));
147
+ });
148
+ });
149
+
38
150
  /**
39
151
  * handles the ask command.
40
152
  * clones/updates the repository and spawns Claude Code to answer the question.
41
153
  * @param args parsed command arguments
42
154
  */
43
155
  export const handler = async (args: Args): Promise<void> => {
44
- // validate remote URL
45
- const parsed = parseRemote(args.remote);
46
- if (!parsed) {
47
- console.error(`error: invalid remote URL: ${args.remote}`);
48
- console.error('expected format: host/owner/repo, e.g.:');
49
- console.error(' github.com/user/repo');
50
- console.error(' https://github.com/user/repo');
51
- console.error(' git@github.com:user/repo.git');
52
- process.exit(1);
156
+ // parse main remote (with optional #branch)
157
+ const mainRepo = parseRepoInput(args.remote);
158
+
159
+ // #branch takes precedence over -b flag
160
+ if (!mainRepo.branch && args.branch) {
161
+ mainRepo.branch = args.branch;
53
162
  }
54
163
 
55
- // get cache path
56
- const cachePath = getRepoCachePath(args.remote);
57
- if (!cachePath) {
58
- console.error(`error: could not determine cache path for: ${args.remote}`);
59
- process.exit(1);
164
+ // #region single repo mode
165
+ if (args.with.length === 0) {
166
+ // clone or update repository
167
+ const remoteUrl = normalizeRemote(mainRepo.remote);
168
+ console.error(
169
+ `preparing repository: ${mainRepo.parsed.host}/${mainRepo.parsed.owner}/${mainRepo.parsed.repo}`,
170
+ );
171
+ try {
172
+ await ensureRepo(remoteUrl, mainRepo.cachePath, mainRepo.branch);
173
+ } catch (err) {
174
+ console.error(`error: failed to prepare repository: ${err}`);
175
+ process.exit(1);
176
+ }
177
+
178
+ const contextPrompt = buildSingleRepoContext(mainRepo);
179
+ const exitCode = await spawnClaude(mainRepo.cachePath, contextPrompt, args);
180
+ process.exit(exitCode);
60
181
  }
182
+ // #endregion
61
183
 
62
- // clone or update repository
63
- const remoteUrl = normalizeRemote(args.remote);
64
- console.error(`preparing repository: ${parsed.host}/${parsed.owner}/${parsed.repo}`);
65
- try {
66
- await ensureRepo(remoteUrl, cachePath, args.branch);
67
- } catch (err) {
68
- console.error(`error: failed to prepare repository: ${err}`);
184
+ // #region multi repo mode
185
+ // parse all -w remotes
186
+ const additionalRepos = args.with.map(parseRepoInput);
187
+ const allRepos = [mainRepo, ...additionalRepos];
188
+
189
+ // clone/update all repos in parallel
190
+ console.error('preparing repositories...');
191
+ const prepareResults = await Promise.allSettled(
192
+ allRepos.map(async (repo) => {
193
+ const remoteUrl = normalizeRemote(repo.remote);
194
+ const display = `${repo.parsed.host}/${repo.parsed.owner}/${repo.parsed.repo}`;
195
+ console.error(` preparing: ${display}`);
196
+ await ensureRepo(remoteUrl, repo.cachePath, repo.branch);
197
+ return repo;
198
+ }),
199
+ );
200
+
201
+ // check for failures
202
+ const failures: string[] = [];
203
+ for (let i = 0; i < prepareResults.length; i++) {
204
+ const result = prepareResults[i]!;
205
+ if (result.status === 'rejected') {
206
+ const repo = allRepos[i]!;
207
+ const display = `${repo.parsed.host}/${repo.parsed.owner}/${repo.parsed.repo}`;
208
+ failures.push(` ${display}: ${result.reason}`);
209
+ }
210
+ }
211
+
212
+ if (failures.length > 0) {
213
+ console.error('error: failed to prepare repositories:');
214
+ for (const failure of failures) {
215
+ console.error(failure);
216
+ }
69
217
  process.exit(1);
70
218
  }
71
219
 
72
- // build context for append prompt
73
- const repoDisplay = `${parsed.host}/${parsed.owner}/${parsed.repo}`;
74
- const branchDisplay = args.branch ?? 'default branch';
75
- const contextPrompt = `You are examining ${repoDisplay} (checked out on ${branchDisplay}).`;
76
-
77
- // spawn Claude Code
78
- const claudeArgs = [
79
- '-p',
80
- args.question,
81
- '--model',
82
- args.model,
83
- '--settings',
84
- settingsPath,
85
- '--system-prompt-file',
86
- systemPromptPath,
87
- '--append-system-prompt',
88
- contextPrompt,
89
- ];
90
-
91
- const claude = spawn('claude', claudeArgs, {
92
- cwd: cachePath,
93
- stdio: 'inherit',
94
- });
220
+ // create session directory and symlinks
221
+ const sessionPath = await createSessionDir();
222
+ let exitCode = 1;
95
223
 
96
- claude.on('close', (code) => {
97
- process.exit(code ?? 0);
98
- });
224
+ try {
225
+ const dirMap = await buildSymlinkDir(sessionPath, allRepos);
226
+ const contextPrompt = buildMultiRepoContext(dirMap);
227
+ exitCode = await spawnClaude(sessionPath, contextPrompt, args);
228
+ } finally {
229
+ // always clean up session directory
230
+ await cleanupSessionDir(sessionPath);
231
+ }
99
232
 
100
- claude.on('error', (err) => {
101
- console.error(`error: failed to spawn claude: ${err}`);
102
- process.exit(1);
103
- });
233
+ process.exit(exitCode);
234
+ // #endregion
104
235
  };
@@ -6,7 +6,7 @@ import { createInterface } from 'node:readline';
6
6
  import { argument, constant, type InferValue, message, object, option, string } from '@optique/core';
7
7
  import { optional } from '@optique/core/modifiers';
8
8
 
9
- import { getRepoCachePath, getReposDir } from '../lib/paths.ts';
9
+ import { getRepoCachePath, getReposDir, getSessionsDir } from '../lib/paths.ts';
10
10
 
11
11
  export const schema = object({
12
12
  command: constant('clean'),
@@ -113,15 +113,24 @@ const listCachedRepos = (): {
113
113
  /**
114
114
  * prompts the user for confirmation.
115
115
  * @param msg the prompt message
116
- * @returns promise that resolves to true if confirmed
116
+ * @returns promise that resolves to true if confirmed, or exits on interrupt
117
117
  */
118
118
  const confirm = (msg: string): Promise<boolean> =>
119
119
  new Promise((resolve) => {
120
+ let answered = false;
120
121
  const rl = createInterface({
121
122
  input: process.stdin,
122
123
  output: process.stdout,
123
124
  });
125
+ rl.on('close', () => {
126
+ if (!answered) {
127
+ // handle Ctrl+C or stream close
128
+ console.log();
129
+ process.exit(130);
130
+ }
131
+ });
124
132
  rl.question(`${msg} [y/N] `, (answer) => {
133
+ answered = true;
125
134
  rl.close();
126
135
  resolve(answer.toLowerCase() === 'y');
127
136
  });
@@ -133,16 +142,33 @@ const confirm = (msg: string): Promise<boolean> =>
133
142
  */
134
143
  export const handler = async (args: Args): Promise<void> => {
135
144
  const reposDir = getReposDir();
145
+ const sessionsDir = getSessionsDir();
136
146
 
137
147
  // clean all
138
148
  if (args.all) {
139
- if (!existsSync(reposDir)) {
140
- console.log('no cached repositories found');
149
+ const reposExist = existsSync(reposDir);
150
+ const sessionsExist = existsSync(sessionsDir);
151
+
152
+ if (!reposExist && !sessionsExist) {
153
+ console.log('no cached data found');
141
154
  return;
142
155
  }
143
- const size = getDirSize(reposDir);
144
- console.log(`removing all cached repositories (${formatSize(size)})`);
145
- await rm(reposDir, { recursive: true });
156
+
157
+ let totalSize = 0;
158
+ if (reposExist) {
159
+ totalSize += getDirSize(reposDir);
160
+ }
161
+ if (sessionsExist) {
162
+ totalSize += getDirSize(sessionsDir);
163
+ }
164
+
165
+ console.log(`removing all cached data (${formatSize(totalSize)})`);
166
+ if (reposExist) {
167
+ await rm(reposDir, { recursive: true });
168
+ }
169
+ if (sessionsExist) {
170
+ await rm(sessionsDir, { recursive: true });
171
+ }
146
172
  console.log('done');
147
173
  return;
148
174
  }
@@ -167,23 +193,43 @@ export const handler = async (args: Args): Promise<void> => {
167
193
 
168
194
  // list repos and prompt for confirmation
169
195
  const repos = listCachedRepos();
170
- if (repos.length === 0) {
171
- console.log('no cached repositories found');
196
+ const sessionsExist = existsSync(sessionsDir);
197
+ const sessionsSize = sessionsExist ? getDirSize(sessionsDir) : 0;
198
+
199
+ if (repos.length === 0 && !sessionsExist) {
200
+ console.log('no cached data found');
172
201
  return;
173
202
  }
174
203
 
175
- console.log('cached repositories:\n');
176
204
  let totalSize = 0;
177
- for (const repo of repos) {
178
- console.log(` ${repo.displayPath.padEnd(50)} ${formatSize(repo.size)}`);
179
- totalSize += repo.size;
205
+
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
+ }
180
212
  }
213
+
214
+ if (sessionsExist && sessionsSize > 0) {
215
+ if (repos.length > 0) {
216
+ console.log();
217
+ }
218
+ console.log(`sessions: ${formatSize(sessionsSize)}`);
219
+ totalSize += sessionsSize;
220
+ }
221
+
181
222
  console.log(`\n total: ${formatSize(totalSize)}`);
182
223
  console.log();
183
224
 
184
- const confirmed = await confirm('remove all cached repositories?');
225
+ const confirmed = await confirm('remove all cached data?');
185
226
  if (confirmed) {
186
- await rm(reposDir, { recursive: true });
227
+ if (repos.length > 0) {
228
+ await rm(reposDir, { recursive: true });
229
+ }
230
+ if (sessionsExist) {
231
+ await rm(sessionsDir, { recursive: true });
232
+ }
187
233
  console.log('done');
188
234
  }
189
235
  };
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ const parser = or(
16
16
  );
17
17
 
18
18
  const result = run(parser, {
19
+ programName: 'cgr',
19
20
  help: 'both',
20
21
  version: { value: '0.1.0', mode: 'option' },
21
22
  brief: message`ask questions about git repositories using Claude Code`,
package/src/lib/git.ts CHANGED
@@ -4,7 +4,7 @@ import { mkdir } from 'node:fs/promises';
4
4
  import { dirname } from 'node:path';
5
5
 
6
6
  /**
7
- * executes a git command and returns the result.
7
+ * executes a git command silently, only showing output on failure.
8
8
  * @param args git command arguments
9
9
  * @param cwd working directory
10
10
  * @returns promise that resolves when the command completes
@@ -13,12 +13,19 @@ const git = (args: string[], cwd?: string): Promise<void> =>
13
13
  new Promise((resolve, reject) => {
14
14
  const proc = spawn('git', args, {
15
15
  cwd,
16
- stdio: 'inherit',
16
+ stdio: ['inherit', 'pipe', 'pipe'],
17
+ });
18
+ let stderr = '';
19
+ proc.stderr!.on('data', (data: Buffer) => {
20
+ stderr += data.toString();
17
21
  });
18
22
  proc.on('close', (code) => {
19
23
  if (code === 0) {
20
24
  resolve();
21
25
  } else {
26
+ if (stderr) {
27
+ process.stderr.write(stderr);
28
+ }
22
29
  reject(new Error(`git ${args[0]} failed with code ${code}`));
23
30
  }
24
31
  });
@@ -26,7 +33,7 @@ const git = (args: string[], cwd?: string): Promise<void> =>
26
33
  });
27
34
 
28
35
  /**
29
- * executes a git command and captures stdout.
36
+ * executes a git command and captures stdout, only showing stderr on failure.
30
37
  * @param args git command arguments
31
38
  * @param cwd working directory
32
39
  * @returns promise that resolves with stdout
@@ -35,16 +42,23 @@ const gitOutput = (args: string[], cwd?: string): Promise<string> =>
35
42
  new Promise((resolve, reject) => {
36
43
  const proc = spawn('git', args, {
37
44
  cwd,
38
- stdio: ['inherit', 'pipe', 'inherit'],
45
+ stdio: ['inherit', 'pipe', 'pipe'],
39
46
  });
40
47
  let output = '';
48
+ let stderr = '';
41
49
  proc.stdout!.on('data', (data: Buffer) => {
42
50
  output += data.toString();
43
51
  });
52
+ proc.stderr!.on('data', (data: Buffer) => {
53
+ stderr += data.toString();
54
+ });
44
55
  proc.on('close', (code) => {
45
56
  if (code === 0) {
46
57
  resolve(output.trim());
47
58
  } else {
59
+ if (stderr) {
60
+ process.stderr.write(stderr);
61
+ }
48
62
  reject(new Error(`git ${args[0]} failed with code ${code}`));
49
63
  }
50
64
  });
package/src/lib/paths.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, rm } from 'node:fs/promises';
1
3
  import { homedir } from 'node:os';
2
4
  import { join } from 'node:path';
3
5
 
@@ -90,3 +92,43 @@ export const getRepoCachePath = (remote: string): string | null => {
90
92
  }
91
93
  return join(getReposDir(), parsed.host, parsed.owner, parsed.repo);
92
94
  };
95
+
96
+ /**
97
+ * parses `remote#branch` syntax.
98
+ * @param input the input string, e.g. `github.com/owner/repo#develop`
99
+ * @returns object with remote and optional branch
100
+ */
101
+ export const parseRemoteWithBranch = (input: string): { remote: string; branch?: string } => {
102
+ const hashIndex = input.lastIndexOf('#');
103
+ if (hashIndex === -1) {
104
+ return { remote: input };
105
+ }
106
+ return {
107
+ remote: input.slice(0, hashIndex),
108
+ branch: input.slice(hashIndex + 1),
109
+ };
110
+ };
111
+
112
+ /**
113
+ * returns the sessions directory within the cache.
114
+ * @returns the sessions directory path
115
+ */
116
+ export const getSessionsDir = (): string => join(getCacheDir(), 'sessions');
117
+
118
+ /**
119
+ * creates a new session directory with a random UUID.
120
+ * @returns the path to the created session directory
121
+ */
122
+ export const createSessionDir = async (): Promise<string> => {
123
+ const sessionPath = join(getSessionsDir(), randomUUID());
124
+ await mkdir(sessionPath, { recursive: true });
125
+ return sessionPath;
126
+ };
127
+
128
+ /**
129
+ * removes a session directory.
130
+ * @param sessionPath the session directory path
131
+ */
132
+ export const cleanupSessionDir = async (sessionPath: string): Promise<void> => {
133
+ await rm(sessionPath, { recursive: true, force: true });
134
+ };
@@ -0,0 +1,47 @@
1
+ import { symlink } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ /** parsed remote information */
5
+ export type ParsedRemote = { host: string; owner: string; repo: string };
6
+
7
+ /** repository entry with all metadata needed for symlinking */
8
+ export type RepoEntry = {
9
+ remote: string;
10
+ parsed: ParsedRemote;
11
+ cachePath: string;
12
+ branch?: string;
13
+ };
14
+
15
+ /**
16
+ * builds symlinks in a session directory for all repositories.
17
+ * handles name conflicts by appending `-2`, `-3`, etc.
18
+ * @param sessionPath the session directory path
19
+ * @param repos array of repository entries to symlink
20
+ * @returns map of directory name -> repo entry
21
+ */
22
+ export const buildSymlinkDir = async (
23
+ sessionPath: string,
24
+ repos: RepoEntry[],
25
+ ): Promise<Map<string, RepoEntry>> => {
26
+ const result = new Map<string, RepoEntry>();
27
+ const usedNames = new Set<string>();
28
+
29
+ for (const repo of repos) {
30
+ let name = repo.parsed.repo;
31
+ let suffix = 1;
32
+
33
+ // handle name conflicts
34
+ while (usedNames.has(name)) {
35
+ suffix++;
36
+ name = `${repo.parsed.repo}-${suffix}`;
37
+ }
38
+
39
+ usedNames.add(name);
40
+ result.set(name, repo);
41
+
42
+ const linkPath = join(sessionPath, name);
43
+ await symlink(repo.cachePath, linkPath);
44
+ }
45
+
46
+ return result;
47
+ };