@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/README.md +19 -11
- package/dist/assets/system-prompt.md +60 -17
- package/dist/index.mjs +354 -119
- package/package.json +3 -1
- package/src/assets/system-prompt.md +60 -17
- package/src/commands/ask.ts +185 -55
- package/src/commands/clean.ts +152 -96
- package/src/lib/debug.ts +11 -0
- package/src/lib/git.ts +28 -4
- package/src/lib/paths.ts +98 -14
- package/src/lib/symlink.ts +49 -0
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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?:\/\/([^/]+)\/(
|
|
182
|
+
const httpMatch = remote.match(/^https?:\/\/([^/]+)\/(.+?)(?:\.git)?$/);
|
|
135
183
|
if (httpMatch) return {
|
|
136
184
|
host: httpMatch[1],
|
|
137
|
-
|
|
138
|
-
repo: httpMatch[3]
|
|
185
|
+
path: httpMatch[2]
|
|
139
186
|
};
|
|
140
|
-
const sshMatch = remote.match(/^git@([^:]+):(
|
|
187
|
+
const sshMatch = remote.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
|
|
141
188
|
if (sshMatch) return {
|
|
142
189
|
host: sshMatch[1],
|
|
143
|
-
|
|
144
|
-
repo: sshMatch[3]
|
|
190
|
+
path: sshMatch[2]
|
|
145
191
|
};
|
|
146
|
-
const bareMatch = remote.match(/^([^/]+)\/(
|
|
192
|
+
const bareMatch = remote.match(/^([^/]+)\/(.+?)(?:\.git)?$/);
|
|
147
193
|
if (bareMatch) return {
|
|
148
194
|
host: bareMatch[1],
|
|
149
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
192
|
-
*
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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: ${
|
|
333
|
+
console.error(`error: could not determine cache path for: ${remote}`);
|
|
208
334
|
process.exit(1);
|
|
209
335
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
391
|
+
resolve(code ?? 0);
|
|
236
392
|
});
|
|
237
393
|
claude.on("error", (err) => {
|
|
238
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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.
|
|
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
|
},
|