@oomfware/cgr 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/assets/system-prompt.md +57 -16
- package/dist/index.mjs +157 -102
- package/package.json +3 -1
- package/src/assets/system-prompt.md +57 -16
- package/src/commands/ask.ts +8 -9
- package/src/commands/clean.ts +154 -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
|
|
|
@@ -28,23 +28,64 @@ You also have read-only Bash access for standard Unix tools when needed.
|
|
|
28
28
|
|
|
29
29
|
## Guidelines
|
|
30
30
|
|
|
31
|
+
- **Be direct** - Answer the question, don't narrate your process. Skip preamble like "Perfect!",
|
|
32
|
+
"Now I understand...", or "Let me explain..."
|
|
31
33
|
- **Explore first** - Don't guess. Use Glob and Grep to find relevant files, then Read to understand
|
|
32
34
|
them. Trace imports, function calls, and data flow.
|
|
33
|
-
- **Cite your sources** -
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
- **Cite your sources** - Back up claims with evidence:
|
|
36
|
+
1. Add footnotes referencing where a statement is sourced:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
The cache is invalidated whenever a user updates their profile. [^1]
|
|
40
|
+
|
|
41
|
+
[^1]: **`src/services/user.ts:89`** - updateProfile() calls cache.invalidate()
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
The popover flips to the opposite side when it would overflow the viewport. [^2]
|
|
46
|
+
|
|
47
|
+
[^2]: **`src/utils/useAnchorPositioning.ts:215-220`** - flip middleware from Floating UI
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
2. Reference file paths and line numbers directly in prose:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
As shown in `src/config/database.ts:12`, the connection pool defaults to 10.
|
|
54
|
+
```
|
|
39
55
|
|
|
40
|
-
|
|
56
|
+
```
|
|
57
|
+
The `useSignal` hook in `packages/react/src/index.ts:53` returns a stable reference.
|
|
58
|
+
```
|
|
41
59
|
|
|
42
|
-
|
|
60
|
+
3. Include code snippets when they help illustrate the point:
|
|
43
61
|
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
return 'actual code from the file';
|
|
47
|
-
}
|
|
48
|
-
```
|
|
62
|
+
```
|
|
63
|
+
Signals track dependencies automatically when accessed inside an effect:
|
|
49
64
|
|
|
50
|
-
|
|
65
|
+
**`packages/core/src/index.ts:152-158`**
|
|
66
|
+
|
|
67
|
+
if (evalContext !== undefined) {
|
|
68
|
+
let node = evalContext._sources;
|
|
69
|
+
// Subscribe to the signal
|
|
70
|
+
node._source._subscribe(node);
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
Errors are wrapped with context before being rethrown:
|
|
76
|
+
|
|
77
|
+
**`src/utils/errors.ts:22-26`**
|
|
78
|
+
|
|
79
|
+
catch (err) {
|
|
80
|
+
throw new AppError(`Failed to ${operation}`, { cause: err });
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
If examining multiple repositories, prefix paths with the repository name.
|
|
85
|
+
|
|
86
|
+
- **Explain the why** - Don't just describe what code does; explain why it exists and how it fits
|
|
87
|
+
into the larger picture.
|
|
88
|
+
- **Compare implementations** - When examining multiple repositories, highlight differences in
|
|
89
|
+
approach. Tables work well for summarizing tradeoffs.
|
|
90
|
+
- **Use history** - When relevant, use git log/blame/show to understand how code evolved.
|
|
91
|
+
- **Admit uncertainty** - If you're unsure about something, say so and explain what you did find.
|
package/dist/index.mjs
CHANGED
|
@@ -2,32 +2,46 @@
|
|
|
2
2
|
import { argument, choice, command, constant, message, object, option, or, string } from "@optique/core";
|
|
3
3
|
import { run } from "@optique/run";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
|
-
import { dirname, join } from "node:path";
|
|
5
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
6
6
|
import { multiple, optional, withDefault } from "@optique/core/modifiers";
|
|
7
|
-
import { existsSync
|
|
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
|
}
|
|
@@ -346,7 +382,7 @@ const spawnClaude = (cwd, contextPrompt, args) => new Promise((resolve, reject)
|
|
|
346
382
|
"--append-system-prompt",
|
|
347
383
|
contextPrompt
|
|
348
384
|
];
|
|
349
|
-
console.error("
|
|
385
|
+
console.error("summoning claude...");
|
|
350
386
|
const claude = spawn("claude", claudeArgs, {
|
|
351
387
|
cwd,
|
|
352
388
|
stdio: "inherit"
|
|
@@ -368,7 +404,7 @@ const handler$1 = async (args) => {
|
|
|
368
404
|
if (!mainRepo.branch && args.branch) mainRepo.branch = args.branch;
|
|
369
405
|
if (args.with.length === 0) {
|
|
370
406
|
const remoteUrl = normalizeRemote(mainRepo.remote);
|
|
371
|
-
console.error(`preparing repository: ${mainRepo.parsed.host}/${mainRepo.parsed.
|
|
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,59 @@ 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 maxNameLen = Math.max(...repos.map((r) => r.displayPath.length), sessionsExist ? 10 : 0);
|
|
591
|
+
const maxSizeLen = Math.max(...repos.map((r) => formatSize(r.size).length), sessionsExist ? formatSize(sessionsSize).length : 0);
|
|
592
|
+
const termWidth = process.stdout.columns ?? 80;
|
|
593
|
+
const usePadding = maxNameLen + 2 + maxSizeLen + 6 <= termWidth;
|
|
594
|
+
const formatChoice = (name, size) => usePadding ? `${name.padEnd(maxNameLen)} ${formatSize(size)}` : `${name} (${formatSize(size)})`;
|
|
595
|
+
const choices = repos.map((repo) => ({
|
|
596
|
+
name: formatChoice(repo.displayPath, repo.size),
|
|
597
|
+
value: repo.path,
|
|
598
|
+
short: repo.displayPath
|
|
599
|
+
}));
|
|
600
|
+
if (sessionsExist && sessionsSize > 0) choices.push({
|
|
601
|
+
name: formatChoice("(sessions)", sessionsSize),
|
|
602
|
+
value: sessionsDir,
|
|
603
|
+
short: "(sessions)"
|
|
604
|
+
});
|
|
605
|
+
const selected = await checkbox({
|
|
606
|
+
message: "select items to remove",
|
|
607
|
+
choices,
|
|
608
|
+
pageSize: 20
|
|
609
|
+
});
|
|
610
|
+
if (selected.length === 0) return;
|
|
546
611
|
let totalSize = 0;
|
|
547
|
-
|
|
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");
|
|
612
|
+
for (const path of selected) totalSize += await getDirSize(path);
|
|
613
|
+
if (!args.yes) {
|
|
614
|
+
if (!await confirm({
|
|
615
|
+
message: `remove ${selected.length} item(s)? (${formatSize(totalSize)})`,
|
|
616
|
+
default: false
|
|
617
|
+
})) return;
|
|
565
618
|
}
|
|
619
|
+
for (const path of selected) await rm(path, { recursive: true });
|
|
620
|
+
console.log("done");
|
|
566
621
|
};
|
|
567
622
|
|
|
568
623
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oomfware/cgr",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "ask questions about git repositories using Claude Code",
|
|
5
5
|
"license": "0BSD",
|
|
6
6
|
"repository": {
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
"access": "public"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
+
"@inquirer/checkbox": "5.0.4",
|
|
25
|
+
"@inquirer/confirm": "6.0.4",
|
|
24
26
|
"@optique/core": "^0.9.1",
|
|
25
27
|
"@optique/run": "^0.9.1"
|
|
26
28
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
You are a code research assistant
|
|
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
|
|
|
@@ -28,23 +28,64 @@ You also have read-only Bash access for standard Unix tools when needed.
|
|
|
28
28
|
|
|
29
29
|
## Guidelines
|
|
30
30
|
|
|
31
|
+
- **Be direct** - Answer the question, don't narrate your process. Skip preamble like "Perfect!",
|
|
32
|
+
"Now I understand...", or "Let me explain..."
|
|
31
33
|
- **Explore first** - Don't guess. Use Glob and Grep to find relevant files, then Read to understand
|
|
32
34
|
them. Trace imports, function calls, and data flow.
|
|
33
|
-
- **Cite your sources** -
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
- **Cite your sources** - Back up claims with evidence:
|
|
36
|
+
1. Add footnotes referencing where a statement is sourced:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
The cache is invalidated whenever a user updates their profile. [^1]
|
|
40
|
+
|
|
41
|
+
[^1]: **`src/services/user.ts:89`** - updateProfile() calls cache.invalidate()
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
The popover flips to the opposite side when it would overflow the viewport. [^2]
|
|
46
|
+
|
|
47
|
+
[^2]: **`src/utils/useAnchorPositioning.ts:215-220`** - flip middleware from Floating UI
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
2. Reference file paths and line numbers directly in prose:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
As shown in `src/config/database.ts:12`, the connection pool defaults to 10.
|
|
54
|
+
```
|
|
39
55
|
|
|
40
|
-
|
|
56
|
+
```
|
|
57
|
+
The `useSignal` hook in `packages/react/src/index.ts:53` returns a stable reference.
|
|
58
|
+
```
|
|
41
59
|
|
|
42
|
-
|
|
60
|
+
3. Include code snippets when they help illustrate the point:
|
|
43
61
|
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
return 'actual code from the file';
|
|
47
|
-
}
|
|
48
|
-
```
|
|
62
|
+
```
|
|
63
|
+
Signals track dependencies automatically when accessed inside an effect:
|
|
49
64
|
|
|
50
|
-
|
|
65
|
+
**`packages/core/src/index.ts:152-158`**
|
|
66
|
+
|
|
67
|
+
if (evalContext !== undefined) {
|
|
68
|
+
let node = evalContext._sources;
|
|
69
|
+
// Subscribe to the signal
|
|
70
|
+
node._source._subscribe(node);
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
Errors are wrapped with context before being rethrown:
|
|
76
|
+
|
|
77
|
+
**`src/utils/errors.ts:22-26`**
|
|
78
|
+
|
|
79
|
+
catch (err) {
|
|
80
|
+
throw new AppError(`Failed to ${operation}`, { cause: err });
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
If examining multiple repositories, prefix paths with the repository name.
|
|
85
|
+
|
|
86
|
+
- **Explain the why** - Don't just describe what code does; explain why it exists and how it fits
|
|
87
|
+
into the larger picture.
|
|
88
|
+
- **Compare implementations** - When examining multiple repositories, highlight differences in
|
|
89
|
+
approach. Tables work well for summarizing tradeoffs.
|
|
90
|
+
- **Use history** - When relevant, use git log/blame/show to understand how code evolved.
|
|
91
|
+
- **Admit uncertainty** - If you're unsure about something, say so and explain what you did find.
|
package/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
|
}
|
|
@@ -132,7 +133,7 @@ const spawnClaude = (cwd: string, contextPrompt: string, args: Args): Promise<nu
|
|
|
132
133
|
contextPrompt,
|
|
133
134
|
];
|
|
134
135
|
|
|
135
|
-
console.error('
|
|
136
|
+
console.error('summoning claude...');
|
|
136
137
|
const claude = spawn('claude', claudeArgs, {
|
|
137
138
|
cwd,
|
|
138
139
|
stdio: 'inherit',
|
|
@@ -165,9 +166,7 @@ export const handler = async (args: Args): Promise<void> => {
|
|
|
165
166
|
if (args.with.length === 0) {
|
|
166
167
|
// clone or update repository
|
|
167
168
|
const remoteUrl = normalizeRemote(mainRepo.remote);
|
|
168
|
-
console.error(
|
|
169
|
-
`preparing repository: ${mainRepo.parsed.host}/${mainRepo.parsed.owner}/${mainRepo.parsed.repo}`,
|
|
170
|
-
);
|
|
169
|
+
console.error(`preparing repository: ${mainRepo.parsed.host}/${mainRepo.parsed.path}`);
|
|
171
170
|
try {
|
|
172
171
|
await ensureRepo(remoteUrl, mainRepo.cachePath, mainRepo.branch);
|
|
173
172
|
} catch (err) {
|
|
@@ -191,7 +190,7 @@ export const handler = async (args: Args): Promise<void> => {
|
|
|
191
190
|
const prepareResults = await Promise.allSettled(
|
|
192
191
|
allRepos.map(async (repo) => {
|
|
193
192
|
const remoteUrl = normalizeRemote(repo.remote);
|
|
194
|
-
const display = `${repo.parsed.host}/${repo.parsed.
|
|
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,26 @@ export const schema = object({
|
|
|
20
21
|
|
|
21
22
|
export type Args = InferValue<typeof schema>;
|
|
22
23
|
|
|
24
|
+
type Choice = {
|
|
25
|
+
name: string;
|
|
26
|
+
value: string;
|
|
27
|
+
short: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* checks if a path exists.
|
|
32
|
+
* @param path the path to check
|
|
33
|
+
* @returns true if the path exists
|
|
34
|
+
*/
|
|
35
|
+
const exists = async (path: string): Promise<boolean> => {
|
|
36
|
+
try {
|
|
37
|
+
await stat(path);
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
23
44
|
/**
|
|
24
45
|
* formats a byte size in human-readable form.
|
|
25
46
|
* @param bytes size in bytes
|
|
@@ -43,16 +64,17 @@ const formatSize = (bytes: number): string => {
|
|
|
43
64
|
* @param dir directory path
|
|
44
65
|
* @returns total size in bytes
|
|
45
66
|
*/
|
|
46
|
-
const getDirSize = (dir: string): number => {
|
|
67
|
+
const getDirSize = async (dir: string): Promise<number> => {
|
|
47
68
|
let size = 0;
|
|
48
69
|
try {
|
|
49
|
-
const entries =
|
|
70
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
50
71
|
for (const entry of entries) {
|
|
51
72
|
const fullPath = join(dir, entry.name);
|
|
52
73
|
if (entry.isDirectory()) {
|
|
53
|
-
size += getDirSize(fullPath);
|
|
74
|
+
size += await getDirSize(fullPath);
|
|
54
75
|
} else {
|
|
55
|
-
|
|
76
|
+
const s = await stat(fullPath);
|
|
77
|
+
size += s.size;
|
|
56
78
|
}
|
|
57
79
|
}
|
|
58
80
|
} catch {
|
|
@@ -61,81 +83,50 @@ const getDirSize = (dir: string): number => {
|
|
|
61
83
|
return size;
|
|
62
84
|
};
|
|
63
85
|
|
|
86
|
+
/**
|
|
87
|
+
* recursively finds git repositories within a directory.
|
|
88
|
+
* yields directories that contain a .git subdirectory.
|
|
89
|
+
* @param dir directory to search
|
|
90
|
+
*/
|
|
91
|
+
async function* findRepos(dir: string): AsyncGenerator<string> {
|
|
92
|
+
try {
|
|
93
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
94
|
+
const hasGit = entries.some((e) => e.name === '.git' && e.isDirectory());
|
|
95
|
+
|
|
96
|
+
if (hasGit) {
|
|
97
|
+
yield dir;
|
|
98
|
+
} else {
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
if (entry.isDirectory()) {
|
|
101
|
+
yield* findRepos(join(dir, entry.name));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// ignore errors (e.g., permission denied)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
64
110
|
/**
|
|
65
111
|
* lists all cached repositories with their sizes.
|
|
66
112
|
* @returns array of { path, displayPath, size } objects
|
|
67
113
|
*/
|
|
68
|
-
const listCachedRepos = (): {
|
|
69
|
-
path: string;
|
|
70
|
-
displayPath: string;
|
|
71
|
-
size: number;
|
|
72
|
-
}[] => {
|
|
114
|
+
const listCachedRepos = async (): Promise<{ path: string; displayPath: string; size: number }[]> => {
|
|
73
115
|
const reposDir = getReposDir();
|
|
74
|
-
const repos: { path: string; displayPath: string; size: number }[] = [];
|
|
75
116
|
|
|
76
|
-
if (!
|
|
77
|
-
return
|
|
117
|
+
if (!(await exists(reposDir))) {
|
|
118
|
+
return [];
|
|
78
119
|
}
|
|
79
120
|
|
|
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
|
-
}
|
|
121
|
+
const repos: { path: string; displayPath: string; size: number }[] = [];
|
|
122
|
+
for await (const repoPath of findRepos(reposDir)) {
|
|
123
|
+
const displayPath = relative(reposDir, repoPath);
|
|
124
|
+
const size = await getDirSize(repoPath);
|
|
125
|
+
repos.push({ path: repoPath, displayPath, size });
|
|
108
126
|
}
|
|
109
|
-
|
|
110
127
|
return repos;
|
|
111
128
|
};
|
|
112
129
|
|
|
113
|
-
/**
|
|
114
|
-
* prompts the user for confirmation.
|
|
115
|
-
* @param msg the prompt message
|
|
116
|
-
* @returns promise that resolves to true if confirmed, or exits on interrupt
|
|
117
|
-
*/
|
|
118
|
-
const confirm = (msg: string): Promise<boolean> =>
|
|
119
|
-
new Promise((resolve) => {
|
|
120
|
-
let answered = false;
|
|
121
|
-
const rl = createInterface({
|
|
122
|
-
input: process.stdin,
|
|
123
|
-
output: process.stdout,
|
|
124
|
-
});
|
|
125
|
-
rl.on('close', () => {
|
|
126
|
-
if (!answered) {
|
|
127
|
-
// handle Ctrl+C or stream close
|
|
128
|
-
console.log();
|
|
129
|
-
process.exit(130);
|
|
130
|
-
}
|
|
131
|
-
});
|
|
132
|
-
rl.question(`${msg} [y/N] `, (answer) => {
|
|
133
|
-
answered = true;
|
|
134
|
-
rl.close();
|
|
135
|
-
resolve(answer.toLowerCase() === 'y');
|
|
136
|
-
});
|
|
137
|
-
});
|
|
138
|
-
|
|
139
130
|
/**
|
|
140
131
|
* handles the clean command.
|
|
141
132
|
* @param args parsed command arguments
|
|
@@ -144,10 +135,10 @@ export const handler = async (args: Args): Promise<void> => {
|
|
|
144
135
|
const reposDir = getReposDir();
|
|
145
136
|
const sessionsDir = getSessionsDir();
|
|
146
137
|
|
|
147
|
-
// clean all
|
|
138
|
+
// #region clean all
|
|
148
139
|
if (args.all) {
|
|
149
|
-
const reposExist =
|
|
150
|
-
const sessionsExist =
|
|
140
|
+
const reposExist = await exists(reposDir);
|
|
141
|
+
const sessionsExist = await exists(sessionsDir);
|
|
151
142
|
|
|
152
143
|
if (!reposExist && !sessionsExist) {
|
|
153
144
|
console.log('no cached data found');
|
|
@@ -156,13 +147,22 @@ export const handler = async (args: Args): Promise<void> => {
|
|
|
156
147
|
|
|
157
148
|
let totalSize = 0;
|
|
158
149
|
if (reposExist) {
|
|
159
|
-
totalSize += getDirSize(reposDir);
|
|
150
|
+
totalSize += await getDirSize(reposDir);
|
|
160
151
|
}
|
|
161
152
|
if (sessionsExist) {
|
|
162
|
-
totalSize += getDirSize(sessionsDir);
|
|
153
|
+
totalSize += await getDirSize(sessionsDir);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!args.yes) {
|
|
157
|
+
const confirmed = await confirm({
|
|
158
|
+
message: `remove all cached data? (${formatSize(totalSize)})`,
|
|
159
|
+
default: false,
|
|
160
|
+
});
|
|
161
|
+
if (!confirmed) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
163
164
|
}
|
|
164
165
|
|
|
165
|
-
console.log(`removing all cached data (${formatSize(totalSize)})`);
|
|
166
166
|
if (reposExist) {
|
|
167
167
|
await rm(reposDir, { recursive: true });
|
|
168
168
|
}
|
|
@@ -172,64 +172,110 @@ export const handler = async (args: Args): Promise<void> => {
|
|
|
172
172
|
console.log('done');
|
|
173
173
|
return;
|
|
174
174
|
}
|
|
175
|
+
// #endregion
|
|
175
176
|
|
|
176
|
-
// clean specific remote
|
|
177
|
+
// #region clean specific remote
|
|
177
178
|
if (args.remote) {
|
|
178
179
|
const cachePath = getRepoCachePath(args.remote);
|
|
179
180
|
if (!cachePath) {
|
|
180
181
|
console.error(`error: invalid remote URL: ${args.remote}`);
|
|
181
182
|
process.exit(1);
|
|
182
183
|
}
|
|
183
|
-
if (!
|
|
184
|
+
if (!(await exists(cachePath))) {
|
|
184
185
|
console.error(`error: repository not cached: ${args.remote}`);
|
|
185
186
|
process.exit(1);
|
|
186
187
|
}
|
|
187
|
-
|
|
188
|
-
|
|
188
|
+
|
|
189
|
+
const size = await getDirSize(cachePath);
|
|
190
|
+
|
|
191
|
+
if (!args.yes) {
|
|
192
|
+
const confirmed = await confirm({
|
|
193
|
+
message: `remove ${args.remote}? (${formatSize(size)})`,
|
|
194
|
+
default: false,
|
|
195
|
+
});
|
|
196
|
+
if (!confirmed) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
189
201
|
await rm(cachePath, { recursive: true });
|
|
190
202
|
console.log('done');
|
|
191
203
|
return;
|
|
192
204
|
}
|
|
205
|
+
// #endregion
|
|
193
206
|
|
|
194
|
-
//
|
|
195
|
-
const repos = listCachedRepos();
|
|
196
|
-
const sessionsExist =
|
|
197
|
-
const sessionsSize = sessionsExist ? getDirSize(sessionsDir) : 0;
|
|
207
|
+
// #region interactive selection
|
|
208
|
+
const repos = await listCachedRepos();
|
|
209
|
+
const sessionsExist = await exists(sessionsDir);
|
|
210
|
+
const sessionsSize = sessionsExist ? await getDirSize(sessionsDir) : 0;
|
|
198
211
|
|
|
199
212
|
if (repos.length === 0 && !sessionsExist) {
|
|
200
213
|
console.log('no cached data found');
|
|
201
214
|
return;
|
|
202
215
|
}
|
|
203
216
|
|
|
204
|
-
|
|
217
|
+
// build choices for checkbox
|
|
218
|
+
const maxNameLen = Math.max(
|
|
219
|
+
...repos.map((r) => r.displayPath.length),
|
|
220
|
+
sessionsExist ? '(sessions)'.length : 0,
|
|
221
|
+
);
|
|
222
|
+
const maxSizeLen = Math.max(
|
|
223
|
+
...repos.map((r) => formatSize(r.size).length),
|
|
224
|
+
sessionsExist ? formatSize(sessionsSize).length : 0,
|
|
225
|
+
);
|
|
205
226
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
227
|
+
// checkbox prefix is ~4 chars, leave some margin
|
|
228
|
+
const termWidth = process.stdout.columns ?? 80;
|
|
229
|
+
const usePadding = maxNameLen + 2 + maxSizeLen + 6 <= termWidth;
|
|
230
|
+
|
|
231
|
+
const formatChoice = (name: string, size: number): string =>
|
|
232
|
+
usePadding
|
|
233
|
+
? `${name.padEnd(maxNameLen)} ${formatSize(size)}`
|
|
234
|
+
: `${name} (${formatSize(size)})`;
|
|
235
|
+
|
|
236
|
+
const choices: Choice[] = repos.map((repo) => ({
|
|
237
|
+
name: formatChoice(repo.displayPath, repo.size),
|
|
238
|
+
value: repo.path,
|
|
239
|
+
short: repo.displayPath,
|
|
240
|
+
}));
|
|
213
241
|
|
|
214
242
|
if (sessionsExist && sessionsSize > 0) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
243
|
+
choices.push({
|
|
244
|
+
name: formatChoice('(sessions)', sessionsSize),
|
|
245
|
+
value: sessionsDir,
|
|
246
|
+
short: '(sessions)',
|
|
247
|
+
});
|
|
220
248
|
}
|
|
221
249
|
|
|
222
|
-
|
|
223
|
-
|
|
250
|
+
const selected = await checkbox({
|
|
251
|
+
message: 'select items to remove',
|
|
252
|
+
choices,
|
|
253
|
+
pageSize: 20,
|
|
254
|
+
});
|
|
224
255
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
256
|
+
if (selected.length === 0) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// calculate total size of selected items
|
|
261
|
+
let totalSize = 0;
|
|
262
|
+
for (const path of selected) {
|
|
263
|
+
totalSize += await getDirSize(path);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!args.yes) {
|
|
267
|
+
const confirmed = await confirm({
|
|
268
|
+
message: `remove ${selected.length} item(s)? (${formatSize(totalSize)})`,
|
|
269
|
+
default: false,
|
|
270
|
+
});
|
|
271
|
+
if (!confirmed) {
|
|
272
|
+
return;
|
|
232
273
|
}
|
|
233
|
-
console.log('done');
|
|
234
274
|
}
|
|
275
|
+
|
|
276
|
+
for (const path of selected) {
|
|
277
|
+
await rm(path, { recursive: true });
|
|
278
|
+
}
|
|
279
|
+
console.log('done');
|
|
280
|
+
// #endregion
|
|
235
281
|
};
|
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);
|