@oomfware/cgr 0.1.1 → 0.1.3

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/dist/index.mjs CHANGED
@@ -2,53 +2,88 @@
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";
6
- import { optional, withDefault } from "@optique/core/modifiers";
7
- import { existsSync, readdirSync, statSync } from "node:fs";
8
- import { mkdir, rm } from "node:fs/promises";
5
+ import { basename, dirname, join, relative } from "node:path";
6
+ import { multiple, optional, withDefault } from "@optique/core/modifiers";
7
+ import { existsSync } from "node:fs";
8
+ import { mkdir, readdir, rm, stat, symlink } from "node:fs/promises";
9
+ import { randomUUID } from "node:crypto";
9
10
  import { homedir } from "node:os";
10
- import { createInterface } from "node:readline";
11
+ import checkbox from "@inquirer/checkbox";
12
+ import confirm from "@inquirer/confirm";
11
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
12
25
  //#region src/lib/git.ts
13
26
  /**
14
- * executes a git command and returns the result.
27
+ * executes a git command silently, only showing output on failure.
28
+ * when debug is enabled, inherits stdio to show git progress.
15
29
  * @param args git command arguments
16
30
  * @param cwd working directory
17
31
  * @returns promise that resolves when the command completes
18
32
  */
19
33
  const git = (args, cwd) => new Promise((resolve, reject) => {
34
+ debug(`git ${args.join(" ")}${cwd ? ` (in ${cwd})` : ""}`);
20
35
  const proc = spawn("git", args, {
21
36
  cwd,
22
- stdio: "inherit"
37
+ stdio: debugEnabled ? "inherit" : [
38
+ "inherit",
39
+ "pipe",
40
+ "pipe"
41
+ ]
42
+ });
43
+ let stderr = "";
44
+ if (!debugEnabled) proc.stderr.on("data", (data) => {
45
+ stderr += data.toString();
23
46
  });
24
47
  proc.on("close", (code) => {
25
48
  if (code === 0) resolve();
26
- else reject(/* @__PURE__ */ new Error(`git ${args[0]} failed with code ${code}`));
49
+ else {
50
+ if (stderr) process.stderr.write(stderr);
51
+ reject(/* @__PURE__ */ new Error(`git ${args[0]} failed with code ${code}`));
52
+ }
27
53
  });
28
54
  proc.on("error", reject);
29
55
  });
30
56
  /**
31
- * executes a git command and captures stdout.
57
+ * executes a git command and captures stdout, only showing stderr on failure.
58
+ * when debug is enabled, inherits stderr to show git progress.
32
59
  * @param args git command arguments
33
60
  * @param cwd working directory
34
61
  * @returns promise that resolves with stdout
35
62
  */
36
63
  const gitOutput = (args, cwd) => new Promise((resolve, reject) => {
64
+ debug(`git ${args.join(" ")}${cwd ? ` (in ${cwd})` : ""}`);
37
65
  const proc = spawn("git", args, {
38
66
  cwd,
39
67
  stdio: [
40
68
  "inherit",
41
69
  "pipe",
42
- "inherit"
70
+ debugEnabled ? "inherit" : "pipe"
43
71
  ]
44
72
  });
45
73
  let output = "";
74
+ let stderr = "";
46
75
  proc.stdout.on("data", (data) => {
47
76
  output += data.toString();
48
77
  });
78
+ if (!debugEnabled) proc.stderr.on("data", (data) => {
79
+ stderr += data.toString();
80
+ });
49
81
  proc.on("close", (code) => {
50
82
  if (code === 0) resolve(output.trim());
51
- else reject(/* @__PURE__ */ new Error(`git ${args[0]} failed with code ${code}`));
83
+ else {
84
+ if (stderr) process.stderr.write(stderr);
85
+ reject(/* @__PURE__ */ new Error(`git ${args[0]} failed with code ${code}`));
86
+ }
52
87
  });
53
88
  proc.on("error", reject);
54
89
  });
@@ -124,34 +159,52 @@ const getCacheDir = () => {
124
159
  * @returns the repos directory path
125
160
  */
126
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;
127
164
  /**
128
- * 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.
129
177
  * supports HTTP(S), SSH, and bare URLs (assumes HTTPS).
130
178
  * @param remote the remote URL to parse
131
179
  * @returns parsed components or null if invalid
132
180
  */
133
181
  const parseRemote = (remote) => {
134
- const httpMatch = remote.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/);
182
+ const httpMatch = remote.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?$/);
135
183
  if (httpMatch) return {
136
184
  host: httpMatch[1],
137
- owner: httpMatch[2],
138
- repo: httpMatch[3]
185
+ path: httpMatch[2]
139
186
  };
140
- const sshMatch = remote.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/);
187
+ const sshMatch = remote.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
141
188
  if (sshMatch) return {
142
189
  host: sshMatch[1],
143
- owner: sshMatch[2],
144
- repo: sshMatch[3]
190
+ path: sshMatch[2]
145
191
  };
146
- const bareMatch = remote.match(/^([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/);
192
+ const bareMatch = remote.match(/^([^/]+)\/(.+?)(?:\.git)?$/);
147
193
  if (bareMatch) return {
148
194
  host: bareMatch[1],
149
- owner: bareMatch[2],
150
- repo: bareMatch[3]
195
+ path: bareMatch[2]
151
196
  };
152
197
  return null;
153
198
  };
154
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
+ /**
155
208
  * normalizes a remote URL by prepending https:// if no protocol is present.
156
209
  * @param remote the remote URL to normalize
157
210
  * @returns normalized URL with protocol
@@ -168,7 +221,72 @@ const normalizeRemote = (remote) => {
168
221
  const getRepoCachePath = (remote) => {
169
222
  const parsed = parseRemote(remote);
170
223
  if (!parsed) return null;
171
- return join(getReposDir(), parsed.host, parsed.owner, parsed.repo);
224
+ return join(getReposDir(), sanitizeRemotePath(parsed));
225
+ };
226
+ /**
227
+ * parses `remote#branch` syntax.
228
+ * @param input the input string, e.g. `github.com/owner/repo#develop`
229
+ * @returns object with remote and optional branch
230
+ */
231
+ const parseRemoteWithBranch = (input) => {
232
+ const hashIndex = input.lastIndexOf("#");
233
+ if (hashIndex === -1) return { remote: input };
234
+ return {
235
+ remote: input.slice(0, hashIndex),
236
+ branch: input.slice(hashIndex + 1)
237
+ };
238
+ };
239
+ /**
240
+ * returns the sessions directory within the cache.
241
+ * @returns the sessions directory path
242
+ */
243
+ const getSessionsDir = () => join(getCacheDir(), "sessions");
244
+ /**
245
+ * creates a new session directory with a random UUID.
246
+ * @returns the path to the created session directory
247
+ */
248
+ const createSessionDir = async () => {
249
+ const sessionPath = join(getSessionsDir(), randomUUID());
250
+ await mkdir(sessionPath, { recursive: true });
251
+ return sessionPath;
252
+ };
253
+ /**
254
+ * removes a session directory.
255
+ * @param sessionPath the session directory path
256
+ */
257
+ const cleanupSessionDir = async (sessionPath) => {
258
+ await rm(sessionPath, {
259
+ recursive: true,
260
+ force: true
261
+ });
262
+ };
263
+
264
+ //#endregion
265
+ //#region src/lib/symlink.ts
266
+ /**
267
+ * builds symlinks in a session directory for all repositories.
268
+ * handles name conflicts by appending `-2`, `-3`, etc.
269
+ * @param sessionPath the session directory path
270
+ * @param repos array of repository entries to symlink
271
+ * @returns map of directory name -> repo entry
272
+ */
273
+ const buildSymlinkDir = async (sessionPath, repos) => {
274
+ const result$1 = /* @__PURE__ */ new Map();
275
+ const usedNames = /* @__PURE__ */ new Set();
276
+ for (const repo of repos) {
277
+ const repoName = basename(repo.parsed.path);
278
+ let name = repoName;
279
+ let suffix = 1;
280
+ while (usedNames.has(name)) {
281
+ suffix++;
282
+ name = `${repoName}-${suffix}`;
283
+ }
284
+ usedNames.add(name);
285
+ result$1.set(name, repo);
286
+ const linkPath = join(sessionPath, name);
287
+ await symlink(repo.cachePath, linkPath);
288
+ }
289
+ return result$1;
172
290
  };
173
291
 
174
292
  //#endregion
@@ -183,40 +301,76 @@ const schema$1 = object({
183
301
  "sonnet",
184
302
  "haiku"
185
303
  ]), { 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)` }),
304
+ branch: optional(option("-b", "--branch", string(), { description: message`branch to checkout (deprecated: use repo#branch instead)` })),
305
+ with: withDefault(multiple(option("-w", "--with", string(), { description: message`additional repository to include` })), []),
306
+ remote: argument(string({ metavar: "REPO" }), { description: message`git remote URL (HTTP/HTTPS/SSH), optionally with #branch` }),
188
307
  question: argument(string({ metavar: "QUESTION" }), { description: message`question to ask about the repository` })
189
308
  });
190
309
  /**
191
- * handles the ask command.
192
- * clones/updates the repository and spawns Claude Code to answer the question.
193
- * @param args parsed command arguments
310
+ * prints an error message for invalid remote URL and exits.
311
+ * @param remote the invalid remote URL
194
312
  */
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);
313
+ const exitInvalidRemote = (remote) => {
314
+ console.error(`error: invalid remote URL: ${remote}`);
315
+ console.error("expected format: host/path, e.g.:");
316
+ console.error(" github.com/user/repo");
317
+ console.error(" gitlab.com/group/subgroup/repo");
318
+ console.error(" https://github.com/user/repo");
319
+ console.error(" git@github.com:user/repo.git");
320
+ process.exit(1);
321
+ };
322
+ /**
323
+ * validates and parses a remote string (with optional #branch).
324
+ * @param input the remote string, possibly with #branch suffix
325
+ * @returns parsed repo entry or exits on error
326
+ */
327
+ const parseRepoInput = (input) => {
328
+ const { remote, branch } = parseRemoteWithBranch(input);
329
+ const parsed = parseRemote(remote);
330
+ if (!parsed) return exitInvalidRemote(remote);
331
+ const cachePath = getRepoCachePath(remote);
206
332
  if (!cachePath) {
207
- console.error(`error: could not determine cache path for: ${args.remote}`);
333
+ console.error(`error: could not determine cache path for: ${remote}`);
208
334
  process.exit(1);
209
335
  }
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);
336
+ return {
337
+ remote,
338
+ parsed,
339
+ cachePath,
340
+ branch
341
+ };
342
+ };
343
+ /**
344
+ * builds a context prompt for a single repository.
345
+ * @param repo the repo entry
346
+ * @returns context prompt string
347
+ */
348
+ const buildSingleRepoContext = (repo) => {
349
+ return `You are examining ${`${repo.parsed.host}/${repo.parsed.path}`} (checked out on ${repo.branch ?? "default branch"}).`;
350
+ };
351
+ /**
352
+ * builds a context prompt for multiple repositories.
353
+ * @param dirMap map of directory name -> repo entry
354
+ * @returns context prompt string
355
+ */
356
+ const buildMultiRepoContext = (dirMap) => {
357
+ const lines = ["You are examining multiple repositories:", ""];
358
+ for (const [dirName, repo] of dirMap) {
359
+ const repoDisplay = `${repo.parsed.host}/${repo.parsed.path}`;
360
+ const branchDisplay = repo.branch ?? "default branch";
361
+ lines.push(`- ${dirName}/ -> ${repoDisplay} (checked out on ${branchDisplay})`);
217
362
  }
218
- const contextPrompt = `You are examining ${`${parsed.host}/${parsed.owner}/${parsed.repo}`} (checked out on ${args.branch ?? "default branch"}).`;
219
- const claude = spawn("claude", [
363
+ return lines.join("\n");
364
+ };
365
+ /**
366
+ * spawns Claude Code and waits for it to exit.
367
+ * @param cwd working directory
368
+ * @param contextPrompt context prompt for Claude
369
+ * @param args command arguments
370
+ * @returns promise that resolves with exit code
371
+ */
372
+ const spawnClaude = (cwd, contextPrompt, args) => new Promise((resolve, reject) => {
373
+ const claudeArgs = [
220
374
  "-p",
221
375
  args.question,
222
376
  "--model",
@@ -227,17 +381,71 @@ const handler$1 = async (args) => {
227
381
  systemPromptPath,
228
382
  "--append-system-prompt",
229
383
  contextPrompt
230
- ], {
231
- cwd: cachePath,
384
+ ];
385
+ console.error("spawning claude...");
386
+ const claude = spawn("claude", claudeArgs, {
387
+ cwd,
232
388
  stdio: "inherit"
233
389
  });
234
390
  claude.on("close", (code) => {
235
- process.exit(code ?? 0);
391
+ resolve(code ?? 0);
236
392
  });
237
393
  claude.on("error", (err) => {
238
- console.error(`error: failed to spawn claude: ${err}`);
239
- process.exit(1);
394
+ reject(/* @__PURE__ */ new Error(`failed to spawn claude: ${err}`));
240
395
  });
396
+ });
397
+ /**
398
+ * handles the ask command.
399
+ * clones/updates the repository and spawns Claude Code to answer the question.
400
+ * @param args parsed command arguments
401
+ */
402
+ const handler$1 = async (args) => {
403
+ const mainRepo = parseRepoInput(args.remote);
404
+ if (!mainRepo.branch && args.branch) mainRepo.branch = args.branch;
405
+ if (args.with.length === 0) {
406
+ const remoteUrl = normalizeRemote(mainRepo.remote);
407
+ console.error(`preparing repository: ${mainRepo.parsed.host}/${mainRepo.parsed.path}`);
408
+ try {
409
+ await ensureRepo(remoteUrl, mainRepo.cachePath, mainRepo.branch);
410
+ } catch (err) {
411
+ console.error(`error: failed to prepare repository: ${err}`);
412
+ process.exit(1);
413
+ }
414
+ const contextPrompt = buildSingleRepoContext(mainRepo);
415
+ const exitCode$1 = await spawnClaude(mainRepo.cachePath, contextPrompt, args);
416
+ process.exit(exitCode$1);
417
+ }
418
+ const allRepos = [mainRepo, ...args.with.map(parseRepoInput)];
419
+ console.error("preparing repositories...");
420
+ const prepareResults = await Promise.allSettled(allRepos.map(async (repo) => {
421
+ const remoteUrl = normalizeRemote(repo.remote);
422
+ const display = `${repo.parsed.host}/${repo.parsed.path}`;
423
+ console.error(` preparing: ${display}`);
424
+ await ensureRepo(remoteUrl, repo.cachePath, repo.branch);
425
+ return repo;
426
+ }));
427
+ const failures = [];
428
+ for (let i = 0; i < prepareResults.length; i++) {
429
+ const result$1 = prepareResults[i];
430
+ if (result$1.status === "rejected") {
431
+ const repo = allRepos[i];
432
+ const display = `${repo.parsed.host}/${repo.parsed.path}`;
433
+ failures.push(` ${display}: ${result$1.reason}`);
434
+ }
435
+ }
436
+ if (failures.length > 0) {
437
+ console.error("error: failed to prepare repositories:");
438
+ for (const failure of failures) console.error(failure);
439
+ process.exit(1);
440
+ }
441
+ const sessionPath = await createSessionDir();
442
+ let exitCode = 1;
443
+ try {
444
+ exitCode = await spawnClaude(sessionPath, buildMultiRepoContext(await buildSymlinkDir(sessionPath, allRepos)), args);
445
+ } finally {
446
+ await cleanupSessionDir(sessionPath);
447
+ }
448
+ process.exit(exitCode);
241
449
  };
242
450
 
243
451
  //#endregion
@@ -245,9 +453,23 @@ const handler$1 = async (args) => {
245
453
  const schema = object({
246
454
  command: constant("clean"),
247
455
  all: option("--all", { description: message`remove all cached repositories` }),
456
+ yes: option("-y", "--yes", { description: message`skip confirmation prompts` }),
248
457
  remote: optional(argument(string({ metavar: "URL" }), { description: message`specific remote URL to clean` }))
249
458
  });
250
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
+ /**
251
473
  * formats a byte size in human-readable form.
252
474
  * @param bytes size in bytes
253
475
  * @returns formatted string
@@ -263,82 +485,77 @@ const formatSize = (bytes) => {
263
485
  * @param dir directory path
264
486
  * @returns total size in bytes
265
487
  */
266
- const getDirSize = (dir) => {
488
+ const getDirSize = async (dir) => {
267
489
  let size = 0;
268
490
  try {
269
- const entries = readdirSync(dir, { withFileTypes: true });
491
+ const entries = await readdir(dir, { withFileTypes: true });
270
492
  for (const entry of entries) {
271
493
  const fullPath = join(dir, entry.name);
272
- if (entry.isDirectory()) size += getDirSize(fullPath);
273
- 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
+ }
274
499
  }
275
500
  } catch {}
276
501
  return size;
277
502
  };
278
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
+ /**
279
516
  * lists all cached repositories with their sizes.
280
517
  * @returns array of { path, displayPath, size } objects
281
518
  */
282
- const listCachedRepos = () => {
519
+ const listCachedRepos = async () => {
283
520
  const reposDir = getReposDir();
521
+ if (!await exists(reposDir)) return [];
284
522
  const repos = [];
285
- if (!existsSync(reposDir)) return repos;
286
- for (const host of readdirSync(reposDir)) {
287
- const hostPath = join(reposDir, host);
288
- if (!statSync(hostPath).isDirectory()) continue;
289
- for (const owner of readdirSync(hostPath)) {
290
- const ownerPath = join(hostPath, owner);
291
- if (!statSync(ownerPath).isDirectory()) continue;
292
- for (const repo of readdirSync(ownerPath)) {
293
- const repoPath = join(ownerPath, repo);
294
- if (!statSync(repoPath).isDirectory()) continue;
295
- repos.push({
296
- path: repoPath,
297
- displayPath: `${host}/${owner}/${repo}`,
298
- size: getDirSize(repoPath)
299
- });
300
- }
301
- }
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
+ });
302
531
  }
303
532
  return repos;
304
533
  };
305
534
  /**
306
- * prompts the user for confirmation.
307
- * @param msg the prompt message
308
- * @returns promise that resolves to true if confirmed, or exits on interrupt
309
- */
310
- const confirm = (msg) => new Promise((resolve) => {
311
- let answered = false;
312
- const rl = createInterface({
313
- input: process.stdin,
314
- output: process.stdout
315
- });
316
- rl.on("close", () => {
317
- if (!answered) {
318
- console.log();
319
- process.exit(130);
320
- }
321
- });
322
- rl.question(`${msg} [y/N] `, (answer) => {
323
- answered = true;
324
- rl.close();
325
- resolve(answer.toLowerCase() === "y");
326
- });
327
- });
328
- /**
329
535
  * handles the clean command.
330
536
  * @param args parsed command arguments
331
537
  */
332
538
  const handler = async (args) => {
333
539
  const reposDir = getReposDir();
540
+ const sessionsDir = getSessionsDir();
334
541
  if (args.all) {
335
- if (!existsSync(reposDir)) {
336
- console.log("no cached repositories found");
542
+ const reposExist = await exists(reposDir);
543
+ const sessionsExist$1 = await exists(sessionsDir);
544
+ if (!reposExist && !sessionsExist$1) {
545
+ console.log("no cached data found");
337
546
  return;
338
547
  }
339
- const size = getDirSize(reposDir);
340
- console.log(`removing all cached repositories (${formatSize(size)})`);
341
- await rm(reposDir, { recursive: true });
548
+ let totalSize$1 = 0;
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
+ }
557
+ if (reposExist) await rm(reposDir, { recursive: true });
558
+ if (sessionsExist$1) await rm(sessionsDir, { recursive: true });
342
559
  console.log("done");
343
560
  return;
344
561
  }
@@ -348,33 +565,51 @@ const handler = async (args) => {
348
565
  console.error(`error: invalid remote URL: ${args.remote}`);
349
566
  process.exit(1);
350
567
  }
351
- if (!existsSync(cachePath)) {
568
+ if (!await exists(cachePath)) {
352
569
  console.error(`error: repository not cached: ${args.remote}`);
353
570
  process.exit(1);
354
571
  }
355
- const size = getDirSize(cachePath);
356
- 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
+ }
357
579
  await rm(cachePath, { recursive: true });
358
580
  console.log("done");
359
581
  return;
360
582
  }
361
- const repos = listCachedRepos();
362
- if (repos.length === 0) {
363
- console.log("no cached repositories found");
583
+ const repos = await listCachedRepos();
584
+ const sessionsExist = await exists(sessionsDir);
585
+ const sessionsSize = sessionsExist ? await getDirSize(sessionsDir) : 0;
586
+ if (repos.length === 0 && !sessionsExist) {
587
+ console.log("no cached data found");
364
588
  return;
365
589
  }
366
- console.log("cached repositories:\n");
590
+ const choices = repos.map((repo) => ({
591
+ name: `${repo.displayPath.padEnd(50)} ${formatSize(repo.size)}`,
592
+ value: repo.path
593
+ }));
594
+ if (sessionsExist && sessionsSize > 0) choices.push({
595
+ name: `${"(sessions)".padEnd(50)} ${formatSize(sessionsSize)}`,
596
+ value: sessionsDir
597
+ });
598
+ const selected = await checkbox({
599
+ message: "select items to remove",
600
+ choices
601
+ });
602
+ if (selected.length === 0) return;
367
603
  let totalSize = 0;
368
- for (const repo of repos) {
369
- console.log(` ${repo.displayPath.padEnd(50)} ${formatSize(repo.size)}`);
370
- totalSize += repo.size;
371
- }
372
- console.log(`\n total: ${formatSize(totalSize)}`);
373
- console.log();
374
- if (await confirm("remove all cached repositories?")) {
375
- await rm(reposDir, { recursive: true });
376
- console.log("done");
604
+ for (const path of selected) totalSize += await getDirSize(path);
605
+ if (!args.yes) {
606
+ if (!await confirm({
607
+ message: `remove ${selected.length} item(s)? (${formatSize(totalSize)})`,
608
+ default: false
609
+ })) return;
377
610
  }
611
+ for (const path of selected) await rm(path, { recursive: true });
612
+ console.log("done");
378
613
  };
379
614
 
380
615
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oomfware/cgr",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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
  },