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