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