@oomfware/cgr 0.1.7 → 0.2.0
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 +8 -1
- package/dist/assets/system-prompt.md +5 -1
- package/dist/index.mjs +121 -45
- package/package.json +3 -2
- package/src/assets/system-prompt.md +5 -1
- package/src/commands/ask.ts +46 -23
- package/src/commands/clean.ts +4 -20
- package/src/lib/git.ts +56 -9
- package/src/lib/paths.ts +63 -2
package/README.md
CHANGED
|
@@ -42,10 +42,16 @@ cgr clean --all
|
|
|
42
42
|
## commands
|
|
43
43
|
|
|
44
44
|
```
|
|
45
|
-
cgr ask [-m opus|sonnet|haiku] [-w repo[#branch] ...] <repo>[#branch] <question>
|
|
45
|
+
cgr ask [-m opus|sonnet|haiku] [-d] [-w repo[#branch] ...] <repo>[#branch] <question>
|
|
46
46
|
cgr clean [--all | <repo>]
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
+
| option | description |
|
|
50
|
+
| ------------- | ------------------------------------------------- |
|
|
51
|
+
| `-m, --model` | model to use: opus, sonnet, haiku (default haiku) |
|
|
52
|
+
| `-d, --deep` | clone full history (enables git log/blame/show) |
|
|
53
|
+
| `-w, --with` | additional repository to include (repeatable) |
|
|
54
|
+
|
|
49
55
|
| command | description |
|
|
50
56
|
| ------- | ----------------------------------------------------------------- |
|
|
51
57
|
| `ask` | clone/update a repository and ask Claude Code a question about it |
|
|
@@ -82,6 +88,7 @@ You can use `@oomfware/cgr` to ask questions about external repositories.
|
|
|
82
88
|
|
|
83
89
|
options:
|
|
84
90
|
-m, --model <model> model to use: opus, sonnet, haiku (default: haiku)
|
|
91
|
+
-d, --deep clone full history (enables git log/blame/show)
|
|
85
92
|
-w, --with <repo> additional repository to include, supports #branch (repeatable)
|
|
86
93
|
|
|
87
94
|
Useful repositories:
|
|
@@ -111,6 +111,10 @@ where they can look—so they can ask informed follow-ups.
|
|
|
111
111
|
**Compare implementations**: When examining multiple repositories, highlight differences in
|
|
112
112
|
approach. Tables work well for summarizing tradeoffs.
|
|
113
113
|
|
|
114
|
-
**Use history**: When relevant, use git log/blame/show to understand how code evolved
|
|
114
|
+
**Use history**: When relevant, use git log/blame/show to understand how code evolved—why code
|
|
115
|
+
exists, when behavior changed, who authored a component. If the repository is a shallow clone and
|
|
116
|
+
the question would benefit from git history, suggest re-running with `--deep`.
|
|
115
117
|
|
|
116
118
|
**Admit uncertainty**: If you're unsure about something, say so and explain what you did find.
|
|
119
|
+
|
|
120
|
+
---
|
package/dist/index.mjs
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { argument, choice, command, constant, message, object, option, or, string } from "@optique/core";
|
|
2
|
+
import { argument, choice, command, constant, flag, message, object, option, or, string } from "@optique/core";
|
|
3
3
|
import { run } from "@optique/run";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { basename, dirname, join, relative } from "node:path";
|
|
6
6
|
import { multiple, optional, withDefault } from "@optique/core/modifiers";
|
|
7
7
|
import { existsSync } from "node:fs";
|
|
8
|
-
import { mkdir, readdir, rm, stat, symlink } from "node:fs/promises";
|
|
8
|
+
import { mkdir, readFile, readdir, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
9
9
|
import { randomUUID } from "node:crypto";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
|
+
import * as v from "valibot";
|
|
11
12
|
import checkbox from "@inquirer/checkbox";
|
|
12
13
|
import confirm from "@inquirer/confirm";
|
|
13
14
|
|
|
@@ -102,14 +103,24 @@ const gitOutput = (args, cwd) => new Promise((resolve, reject) => {
|
|
|
102
103
|
proc.on("error", reject);
|
|
103
104
|
});
|
|
104
105
|
/**
|
|
106
|
+
* checks if a repository has full history.
|
|
107
|
+
* @param cachePath the local repository path
|
|
108
|
+
* @returns true if the repository has full history (not shallow)
|
|
109
|
+
*/
|
|
110
|
+
const isDeepRepo = async (cachePath) => {
|
|
111
|
+
return await gitOutput(["rev-parse", "--is-shallow-repository"], cachePath) !== "true";
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
105
114
|
* clones a repository to the cache path.
|
|
106
115
|
* @param remote the remote URL
|
|
107
116
|
* @param cachePath the local cache path
|
|
108
117
|
* @param branch optional branch to checkout
|
|
118
|
+
* @param deep if true, clones full history; otherwise shallow clone with depth 1
|
|
109
119
|
*/
|
|
110
|
-
const cloneRepo = async (remote, cachePath, branch) => {
|
|
120
|
+
const cloneRepo = async (remote, cachePath, branch, deep) => {
|
|
111
121
|
await mkdir(dirname(cachePath), { recursive: true });
|
|
112
122
|
const args = ["clone"];
|
|
123
|
+
if (!deep) args.push("--depth", "1");
|
|
113
124
|
if (branch) args.push("--branch", branch);
|
|
114
125
|
args.push(remote, cachePath);
|
|
115
126
|
await git(args);
|
|
@@ -119,9 +130,23 @@ const cloneRepo = async (remote, cachePath, branch) => {
|
|
|
119
130
|
* discards any local modifications, staged changes, and untracked files.
|
|
120
131
|
* @param cachePath the local cache path
|
|
121
132
|
* @param branch optional branch to checkout (uses default branch if not specified)
|
|
133
|
+
* @param deep if true, unshallows if needed; if false, keeps shallow clone
|
|
122
134
|
*/
|
|
123
|
-
const updateRepo = async (cachePath, branch) => {
|
|
124
|
-
await
|
|
135
|
+
const updateRepo = async (cachePath, branch, deep) => {
|
|
136
|
+
const currentlyDeep = await isDeepRepo(cachePath);
|
|
137
|
+
if (deep && !currentlyDeep) await git([
|
|
138
|
+
"fetch",
|
|
139
|
+
"--unshallow",
|
|
140
|
+
"origin"
|
|
141
|
+
], cachePath);
|
|
142
|
+
else if (!deep && currentlyDeep) console.error(" note: repository is already a full clone, use `cgr clean` to re-clone as shallow");
|
|
143
|
+
if (!deep && !currentlyDeep) await git([
|
|
144
|
+
"fetch",
|
|
145
|
+
"--depth",
|
|
146
|
+
"1",
|
|
147
|
+
"origin"
|
|
148
|
+
], cachePath);
|
|
149
|
+
else await git(["fetch", "origin"], cachePath);
|
|
125
150
|
let targetBranch = branch;
|
|
126
151
|
if (!targetBranch) targetBranch = (await gitOutput(["symbolic-ref", "refs/remotes/origin/HEAD"], cachePath)).replace("refs/remotes/origin/", "");
|
|
127
152
|
await git([
|
|
@@ -143,13 +168,12 @@ const updateRepo = async (cachePath, branch) => {
|
|
|
143
168
|
};
|
|
144
169
|
/**
|
|
145
170
|
* ensures a repository is cloned and up-to-date.
|
|
146
|
-
* @param
|
|
147
|
-
* @param cachePath the local cache path
|
|
148
|
-
* @param branch optional branch to checkout
|
|
171
|
+
* @param options repository options
|
|
149
172
|
*/
|
|
150
|
-
const ensureRepo = async (
|
|
151
|
-
|
|
152
|
-
|
|
173
|
+
const ensureRepo = async (options) => {
|
|
174
|
+
const { remote, cachePath, branch, deep } = options;
|
|
175
|
+
if (existsSync(cachePath)) await updateRepo(cachePath, branch, deep);
|
|
176
|
+
else await cloneRepo(remote, cachePath, branch, deep);
|
|
153
177
|
};
|
|
154
178
|
|
|
155
179
|
//#endregion
|
|
@@ -255,13 +279,16 @@ const parseRemoteWithBranch = (input) => {
|
|
|
255
279
|
* @returns the sessions directory path
|
|
256
280
|
*/
|
|
257
281
|
const getSessionsDir = () => join(getCacheDir(), "sessions");
|
|
282
|
+
const PID_FILE = ".pid";
|
|
283
|
+
const PidSchema = v.pipe(v.string(), v.trim(), v.toNumber(), v.integer(), v.minValue(1));
|
|
258
284
|
/**
|
|
259
|
-
* creates a new session directory with a random UUID.
|
|
285
|
+
* creates a new session directory with a random UUID and writes a PID lockfile.
|
|
260
286
|
* @returns the path to the created session directory
|
|
261
287
|
*/
|
|
262
288
|
const createSessionDir = async () => {
|
|
263
289
|
const sessionPath = join(getSessionsDir(), randomUUID());
|
|
264
290
|
await mkdir(sessionPath, { recursive: true });
|
|
291
|
+
await writeFile(join(sessionPath, PID_FILE), process.pid.toString());
|
|
265
292
|
return sessionPath;
|
|
266
293
|
};
|
|
267
294
|
/**
|
|
@@ -274,6 +301,51 @@ const cleanupSessionDir = async (sessionPath) => {
|
|
|
274
301
|
force: true
|
|
275
302
|
});
|
|
276
303
|
};
|
|
304
|
+
/**
|
|
305
|
+
* checks if a process with the given PID is running.
|
|
306
|
+
* @param pid the process ID to check
|
|
307
|
+
* @returns true if the process is running
|
|
308
|
+
*/
|
|
309
|
+
const isProcessRunning = (pid) => {
|
|
310
|
+
try {
|
|
311
|
+
process.kill(pid, 0);
|
|
312
|
+
return true;
|
|
313
|
+
} catch {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
/**
|
|
318
|
+
* garbage collects orphaned session directories.
|
|
319
|
+
* a session is orphaned if its PID file is missing or the process is no longer running.
|
|
320
|
+
* this function is meant to be called fire-and-forget (errors are silently ignored).
|
|
321
|
+
*/
|
|
322
|
+
const gcSessions = async () => {
|
|
323
|
+
const sessionsDir = getSessionsDir();
|
|
324
|
+
let entries;
|
|
325
|
+
try {
|
|
326
|
+
entries = await readdir(sessionsDir, { withFileTypes: true });
|
|
327
|
+
} catch {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
for (const entry of entries) {
|
|
331
|
+
if (!entry.isDirectory()) continue;
|
|
332
|
+
const sessionPath = join(sessionsDir, entry.name);
|
|
333
|
+
const pidPath = join(sessionPath, PID_FILE);
|
|
334
|
+
try {
|
|
335
|
+
const pidContent = await readFile(pidPath, "utf-8");
|
|
336
|
+
const result$1 = v.safeParse(PidSchema, pidContent);
|
|
337
|
+
if (!result$1.success || !isProcessRunning(result$1.output)) await rm(sessionPath, {
|
|
338
|
+
recursive: true,
|
|
339
|
+
force: true
|
|
340
|
+
});
|
|
341
|
+
} catch {
|
|
342
|
+
await rm(sessionPath, {
|
|
343
|
+
recursive: true,
|
|
344
|
+
force: true
|
|
345
|
+
}).catch(() => {});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
};
|
|
277
349
|
|
|
278
350
|
//#endregion
|
|
279
351
|
//#region src/lib/symlink.ts
|
|
@@ -315,9 +387,9 @@ const schema$1 = object({
|
|
|
315
387
|
"sonnet",
|
|
316
388
|
"haiku"
|
|
317
389
|
]), { description: message`model to use for analysis` }), "haiku"),
|
|
318
|
-
|
|
390
|
+
deep: flag("-d", "--deep", { description: message`clone full history (enables git log/blame/show)` }),
|
|
319
391
|
with: withDefault(multiple(option("-w", "--with", string(), { description: message`additional repository to include` })), []),
|
|
320
|
-
remote: argument(string({ metavar: "REPO" }), { description: message`git remote URL (
|
|
392
|
+
remote: argument(string({ metavar: "REPO" }), { description: message`git remote URL (http/https/ssh), optionally with #branch` }),
|
|
321
393
|
question: argument(string({ metavar: "QUESTION" }), { description: message`question to ask about the repository` })
|
|
322
394
|
});
|
|
323
395
|
/**
|
|
@@ -357,18 +429,20 @@ const parseRepoInput = (input) => {
|
|
|
357
429
|
/**
|
|
358
430
|
* builds a context prompt for a single repository.
|
|
359
431
|
* @param repo the repo entry
|
|
432
|
+
* @param deep whether the clone has full history
|
|
360
433
|
* @returns context prompt string
|
|
361
434
|
*/
|
|
362
|
-
const buildSingleRepoContext = (repo) => {
|
|
363
|
-
return `You are examining ${`${repo.parsed.host}/${repo.parsed.path}`} (checked out on ${repo.branch ?? "default branch"}).`;
|
|
435
|
+
const buildSingleRepoContext = (repo, deep) => {
|
|
436
|
+
return `You are examining ${`${repo.parsed.host}/${repo.parsed.path}`} (checked out on ${repo.branch ?? "default branch"}, ${deep ? "full clone" : "shallow clone"}).`;
|
|
364
437
|
};
|
|
365
438
|
/**
|
|
366
439
|
* builds a context prompt for multiple repositories.
|
|
367
440
|
* @param dirMap map of directory name -> repo entry
|
|
441
|
+
* @param deep whether the clones have full history
|
|
368
442
|
* @returns context prompt string
|
|
369
443
|
*/
|
|
370
|
-
const buildMultiRepoContext = (dirMap) => {
|
|
371
|
-
const lines = [
|
|
444
|
+
const buildMultiRepoContext = (dirMap, deep) => {
|
|
445
|
+
const lines = [`You are examining multiple repositories (${deep ? "full clone" : "shallow clone"}):`, ""];
|
|
372
446
|
for (const [dirName, repo] of dirMap) {
|
|
373
447
|
const repoDisplay = `${repo.parsed.host}/${repo.parsed.path}`;
|
|
374
448
|
const branchDisplay = repo.branch ?? "default branch";
|
|
@@ -405,7 +479,7 @@ const spawnClaude = (cwd, contextPrompt, args) => new Promise((resolve, reject)
|
|
|
405
479
|
resolve(code ?? 0);
|
|
406
480
|
});
|
|
407
481
|
claude.on("error", (err) => {
|
|
408
|
-
reject(/* @__PURE__ */ new Error(`failed to
|
|
482
|
+
reject(/* @__PURE__ */ new Error(`failed to summon claude: ${err}`));
|
|
409
483
|
});
|
|
410
484
|
});
|
|
411
485
|
/**
|
|
@@ -414,18 +488,23 @@ const spawnClaude = (cwd, contextPrompt, args) => new Promise((resolve, reject)
|
|
|
414
488
|
* @param args parsed command arguments
|
|
415
489
|
*/
|
|
416
490
|
const handler$1 = async (args) => {
|
|
491
|
+
gcSessions();
|
|
417
492
|
const mainRepo = parseRepoInput(args.remote);
|
|
418
|
-
if (!mainRepo.branch && args.branch) mainRepo.branch = args.branch;
|
|
419
493
|
if (args.with.length === 0) {
|
|
420
494
|
const remoteUrl = normalizeRemote(mainRepo.remote);
|
|
421
495
|
console.error(`preparing repository: ${mainRepo.parsed.host}/${mainRepo.parsed.path}`);
|
|
422
496
|
try {
|
|
423
|
-
await ensureRepo(
|
|
497
|
+
await ensureRepo({
|
|
498
|
+
remote: remoteUrl,
|
|
499
|
+
cachePath: mainRepo.cachePath,
|
|
500
|
+
branch: mainRepo.branch,
|
|
501
|
+
deep: args.deep
|
|
502
|
+
});
|
|
424
503
|
} catch (err) {
|
|
425
504
|
console.error(`error: failed to prepare repository: ${err}`);
|
|
426
505
|
process.exit(1);
|
|
427
506
|
}
|
|
428
|
-
const contextPrompt = buildSingleRepoContext(mainRepo);
|
|
507
|
+
const contextPrompt = buildSingleRepoContext(mainRepo, args.deep);
|
|
429
508
|
const exitCode$1 = await spawnClaude(mainRepo.cachePath, contextPrompt, args);
|
|
430
509
|
process.exit(exitCode$1);
|
|
431
510
|
}
|
|
@@ -435,7 +514,12 @@ const handler$1 = async (args) => {
|
|
|
435
514
|
const remoteUrl = normalizeRemote(repo.remote);
|
|
436
515
|
const display = `${repo.parsed.host}/${repo.parsed.path}`;
|
|
437
516
|
console.error(` preparing: ${display}`);
|
|
438
|
-
await ensureRepo(
|
|
517
|
+
await ensureRepo({
|
|
518
|
+
remote: remoteUrl,
|
|
519
|
+
cachePath: repo.cachePath,
|
|
520
|
+
branch: repo.branch,
|
|
521
|
+
deep: args.deep
|
|
522
|
+
});
|
|
439
523
|
return repo;
|
|
440
524
|
}));
|
|
441
525
|
const failures = [];
|
|
@@ -455,7 +539,7 @@ const handler$1 = async (args) => {
|
|
|
455
539
|
const sessionPath = await createSessionDir();
|
|
456
540
|
let exitCode = 1;
|
|
457
541
|
try {
|
|
458
|
-
exitCode = await spawnClaude(sessionPath, buildMultiRepoContext(await buildSymlinkDir(sessionPath, allRepos)), args);
|
|
542
|
+
exitCode = await spawnClaude(sessionPath, buildMultiRepoContext(await buildSymlinkDir(sessionPath, allRepos), args.deep), args);
|
|
459
543
|
} finally {
|
|
460
544
|
await cleanupSessionDir(sessionPath);
|
|
461
545
|
}
|
|
@@ -554,14 +638,14 @@ const handler = async (args) => {
|
|
|
554
638
|
const sessionsDir = getSessionsDir();
|
|
555
639
|
if (args.all) {
|
|
556
640
|
const reposExist = await exists(reposDir);
|
|
557
|
-
const sessionsExist
|
|
558
|
-
if (!reposExist && !sessionsExist
|
|
641
|
+
const sessionsExist = await exists(sessionsDir);
|
|
642
|
+
if (!reposExist && !sessionsExist) {
|
|
559
643
|
console.log("no cached data found");
|
|
560
644
|
return;
|
|
561
645
|
}
|
|
562
646
|
let totalSize$1 = 0;
|
|
563
647
|
if (reposExist) totalSize$1 += await getDirSize(reposDir);
|
|
564
|
-
if (sessionsExist
|
|
648
|
+
if (sessionsExist) totalSize$1 += await getDirSize(sessionsDir);
|
|
565
649
|
if (!args.yes) {
|
|
566
650
|
if (!await confirm({
|
|
567
651
|
message: `remove all cached data? (${formatSize(totalSize$1)})`,
|
|
@@ -569,7 +653,7 @@ const handler = async (args) => {
|
|
|
569
653
|
})) return;
|
|
570
654
|
}
|
|
571
655
|
if (reposExist) await rm(reposDir, { recursive: true });
|
|
572
|
-
if (sessionsExist
|
|
656
|
+
if (sessionsExist) await rm(sessionsDir, { recursive: true });
|
|
573
657
|
console.log("done");
|
|
574
658
|
return;
|
|
575
659
|
}
|
|
@@ -595,30 +679,22 @@ const handler = async (args) => {
|
|
|
595
679
|
return;
|
|
596
680
|
}
|
|
597
681
|
const repos = await listCachedRepos();
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
if (repos.length === 0 && !sessionsExist) {
|
|
601
|
-
console.log("no cached data found");
|
|
682
|
+
if (repos.length === 0) {
|
|
683
|
+
console.log("no cached repositories found");
|
|
602
684
|
return;
|
|
603
685
|
}
|
|
604
|
-
const maxNameLen = Math.max(...repos.map((r) => r.displayPath.length)
|
|
605
|
-
const maxSizeLen = Math.max(...repos.map((r) => formatSize(r.size).length)
|
|
686
|
+
const maxNameLen = Math.max(...repos.map((r) => r.displayPath.length));
|
|
687
|
+
const maxSizeLen = Math.max(...repos.map((r) => formatSize(r.size).length));
|
|
606
688
|
const termWidth = process.stdout.columns ?? 80;
|
|
607
689
|
const usePadding = maxNameLen + 2 + maxSizeLen + 6 <= termWidth;
|
|
608
690
|
const formatChoice = (name, size) => usePadding ? `${name.padEnd(maxNameLen)} ${formatSize(size)}` : `${name} (${formatSize(size)})`;
|
|
609
|
-
const choices = repos.map((repo) => ({
|
|
610
|
-
name: formatChoice(repo.displayPath, repo.size),
|
|
611
|
-
value: repo.path,
|
|
612
|
-
short: repo.displayPath
|
|
613
|
-
}));
|
|
614
|
-
if (sessionsExist && sessionsSize > 0) choices.push({
|
|
615
|
-
name: formatChoice("(sessions)", sessionsSize),
|
|
616
|
-
value: sessionsDir,
|
|
617
|
-
short: "(sessions)"
|
|
618
|
-
});
|
|
619
691
|
const selected = await checkbox({
|
|
620
692
|
message: "select items to remove",
|
|
621
|
-
choices
|
|
693
|
+
choices: repos.map((repo) => ({
|
|
694
|
+
name: formatChoice(repo.displayPath, repo.size),
|
|
695
|
+
value: repo.path,
|
|
696
|
+
short: repo.displayPath
|
|
697
|
+
})),
|
|
622
698
|
pageSize: 20
|
|
623
699
|
});
|
|
624
700
|
if (selected.length === 0) return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oomfware/cgr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "ask questions about git repositories using Claude Code",
|
|
5
5
|
"license": "0BSD",
|
|
6
6
|
"repository": {
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"@inquirer/checkbox": "5.0.4",
|
|
25
25
|
"@inquirer/confirm": "6.0.4",
|
|
26
26
|
"@optique/core": "^0.9.1",
|
|
27
|
-
"@optique/run": "^0.9.1"
|
|
27
|
+
"@optique/run": "^0.9.1",
|
|
28
|
+
"valibot": "^1.2.0"
|
|
28
29
|
},
|
|
29
30
|
"devDependencies": {
|
|
30
31
|
"@types/node": "^25.0.10",
|
|
@@ -111,6 +111,10 @@ where they can look—so they can ask informed follow-ups.
|
|
|
111
111
|
**Compare implementations**: When examining multiple repositories, highlight differences in
|
|
112
112
|
approach. Tables work well for summarizing tradeoffs.
|
|
113
113
|
|
|
114
|
-
**Use history**: When relevant, use git log/blame/show to understand how code evolved
|
|
114
|
+
**Use history**: When relevant, use git log/blame/show to understand how code evolved—why code
|
|
115
|
+
exists, when behavior changed, who authored a component. If the repository is a shallow clone and
|
|
116
|
+
the question would benefit from git history, suggest re-running with `--deep`.
|
|
115
117
|
|
|
116
118
|
**Admit uncertainty**: If you're unsure about something, say so and explain what you did find.
|
|
119
|
+
|
|
120
|
+
---
|
package/src/commands/ask.ts
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
argument,
|
|
6
|
+
choice,
|
|
7
|
+
constant,
|
|
8
|
+
flag,
|
|
9
|
+
type InferValue,
|
|
10
|
+
message,
|
|
11
|
+
object,
|
|
12
|
+
option,
|
|
13
|
+
string,
|
|
14
|
+
} from '@optique/core';
|
|
15
|
+
import { multiple, withDefault } from '@optique/core/modifiers';
|
|
6
16
|
|
|
7
17
|
import { ensureRepo } from '../lib/git.ts';
|
|
8
18
|
import {
|
|
9
19
|
cleanupSessionDir,
|
|
10
20
|
createSessionDir,
|
|
21
|
+
gcSessions,
|
|
11
22
|
getRepoCachePath,
|
|
12
23
|
normalizeRemote,
|
|
13
24
|
parseRemote,
|
|
@@ -28,12 +39,9 @@ export const schema = object({
|
|
|
28
39
|
}),
|
|
29
40
|
'haiku',
|
|
30
41
|
),
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
description: message`branch to checkout (deprecated: use repo#branch instead)`,
|
|
35
|
-
}),
|
|
36
|
-
),
|
|
42
|
+
deep: flag('-d', '--deep', {
|
|
43
|
+
description: message`clone full history (enables git log/blame/show)`,
|
|
44
|
+
}),
|
|
37
45
|
with: withDefault(
|
|
38
46
|
multiple(
|
|
39
47
|
option('-w', '--with', string(), {
|
|
@@ -43,7 +51,7 @@ export const schema = object({
|
|
|
43
51
|
[],
|
|
44
52
|
),
|
|
45
53
|
remote: argument(string({ metavar: 'REPO' }), {
|
|
46
|
-
description: message`git remote URL (
|
|
54
|
+
description: message`git remote URL (http/https/ssh), optionally with #branch`,
|
|
47
55
|
}),
|
|
48
56
|
question: argument(string({ metavar: 'QUESTION' }), {
|
|
49
57
|
description: message`question to ask about the repository`,
|
|
@@ -88,26 +96,33 @@ const parseRepoInput = (input: string): RepoEntry => {
|
|
|
88
96
|
/**
|
|
89
97
|
* builds a context prompt for a single repository.
|
|
90
98
|
* @param repo the repo entry
|
|
99
|
+
* @param deep whether the clone has full history
|
|
91
100
|
* @returns context prompt string
|
|
92
101
|
*/
|
|
93
|
-
const buildSingleRepoContext = (repo: RepoEntry): string => {
|
|
102
|
+
const buildSingleRepoContext = (repo: RepoEntry, deep: boolean): string => {
|
|
94
103
|
const repoDisplay = `${repo.parsed.host}/${repo.parsed.path}`;
|
|
95
104
|
const branchDisplay = repo.branch ?? 'default branch';
|
|
96
|
-
|
|
105
|
+
const cloneType = deep ? 'full clone' : 'shallow clone';
|
|
106
|
+
|
|
107
|
+
return `You are examining ${repoDisplay} (checked out on ${branchDisplay}, ${cloneType}).`;
|
|
97
108
|
};
|
|
98
109
|
|
|
99
110
|
/**
|
|
100
111
|
* builds a context prompt for multiple repositories.
|
|
101
112
|
* @param dirMap map of directory name -> repo entry
|
|
113
|
+
* @param deep whether the clones have full history
|
|
102
114
|
* @returns context prompt string
|
|
103
115
|
*/
|
|
104
|
-
const buildMultiRepoContext = (dirMap: Map<string, RepoEntry
|
|
105
|
-
const
|
|
116
|
+
const buildMultiRepoContext = (dirMap: Map<string, RepoEntry>, deep: boolean): string => {
|
|
117
|
+
const cloneType = deep ? 'full clone' : 'shallow clone';
|
|
118
|
+
const lines = [`You are examining multiple repositories (${cloneType}):`, ''];
|
|
119
|
+
|
|
106
120
|
for (const [dirName, repo] of dirMap) {
|
|
107
121
|
const repoDisplay = `${repo.parsed.host}/${repo.parsed.path}`;
|
|
108
122
|
const branchDisplay = repo.branch ?? 'default branch';
|
|
109
123
|
lines.push(`- ${dirName}/ -> ${repoDisplay} (checked out on ${branchDisplay})`);
|
|
110
124
|
}
|
|
125
|
+
|
|
111
126
|
return lines.join('\n');
|
|
112
127
|
};
|
|
113
128
|
|
|
@@ -144,7 +159,7 @@ const spawnClaude = (cwd: string, contextPrompt: string, args: Args): Promise<nu
|
|
|
144
159
|
});
|
|
145
160
|
|
|
146
161
|
claude.on('error', (err) => {
|
|
147
|
-
reject(new Error(`failed to
|
|
162
|
+
reject(new Error(`failed to summon claude: ${err}`));
|
|
148
163
|
});
|
|
149
164
|
});
|
|
150
165
|
|
|
@@ -154,27 +169,30 @@ const spawnClaude = (cwd: string, contextPrompt: string, args: Args): Promise<nu
|
|
|
154
169
|
* @param args parsed command arguments
|
|
155
170
|
*/
|
|
156
171
|
export const handler = async (args: Args): Promise<void> => {
|
|
172
|
+
// fire-and-forget cleanup of orphaned sessions
|
|
173
|
+
gcSessions();
|
|
174
|
+
|
|
157
175
|
// parse main remote (with optional #branch)
|
|
158
176
|
const mainRepo = parseRepoInput(args.remote);
|
|
159
177
|
|
|
160
|
-
// #branch takes precedence over -b flag
|
|
161
|
-
if (!mainRepo.branch && args.branch) {
|
|
162
|
-
mainRepo.branch = args.branch;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
178
|
// #region single repo mode
|
|
166
179
|
if (args.with.length === 0) {
|
|
167
180
|
// clone or update repository
|
|
168
181
|
const remoteUrl = normalizeRemote(mainRepo.remote);
|
|
169
182
|
console.error(`preparing repository: ${mainRepo.parsed.host}/${mainRepo.parsed.path}`);
|
|
170
183
|
try {
|
|
171
|
-
await ensureRepo(
|
|
184
|
+
await ensureRepo({
|
|
185
|
+
remote: remoteUrl,
|
|
186
|
+
cachePath: mainRepo.cachePath,
|
|
187
|
+
branch: mainRepo.branch,
|
|
188
|
+
deep: args.deep,
|
|
189
|
+
});
|
|
172
190
|
} catch (err) {
|
|
173
191
|
console.error(`error: failed to prepare repository: ${err}`);
|
|
174
192
|
process.exit(1);
|
|
175
193
|
}
|
|
176
194
|
|
|
177
|
-
const contextPrompt = buildSingleRepoContext(mainRepo);
|
|
195
|
+
const contextPrompt = buildSingleRepoContext(mainRepo, args.deep);
|
|
178
196
|
const exitCode = await spawnClaude(mainRepo.cachePath, contextPrompt, args);
|
|
179
197
|
process.exit(exitCode);
|
|
180
198
|
}
|
|
@@ -192,7 +210,12 @@ export const handler = async (args: Args): Promise<void> => {
|
|
|
192
210
|
const remoteUrl = normalizeRemote(repo.remote);
|
|
193
211
|
const display = `${repo.parsed.host}/${repo.parsed.path}`;
|
|
194
212
|
console.error(` preparing: ${display}`);
|
|
195
|
-
await ensureRepo(
|
|
213
|
+
await ensureRepo({
|
|
214
|
+
remote: remoteUrl,
|
|
215
|
+
cachePath: repo.cachePath,
|
|
216
|
+
branch: repo.branch,
|
|
217
|
+
deep: args.deep,
|
|
218
|
+
});
|
|
196
219
|
return repo;
|
|
197
220
|
}),
|
|
198
221
|
);
|
|
@@ -222,7 +245,7 @@ export const handler = async (args: Args): Promise<void> => {
|
|
|
222
245
|
|
|
223
246
|
try {
|
|
224
247
|
const dirMap = await buildSymlinkDir(sessionPath, allRepos);
|
|
225
|
-
const contextPrompt = buildMultiRepoContext(dirMap);
|
|
248
|
+
const contextPrompt = buildMultiRepoContext(dirMap, args.deep);
|
|
226
249
|
exitCode = await spawnClaude(sessionPath, contextPrompt, args);
|
|
227
250
|
} finally {
|
|
228
251
|
// always clean up session directory
|
package/src/commands/clean.ts
CHANGED
|
@@ -206,23 +206,15 @@ export const handler = async (args: Args): Promise<void> => {
|
|
|
206
206
|
|
|
207
207
|
// #region interactive selection
|
|
208
208
|
const repos = await listCachedRepos();
|
|
209
|
-
const sessionsExist = await exists(sessionsDir);
|
|
210
|
-
const sessionsSize = sessionsExist ? await getDirSize(sessionsDir) : 0;
|
|
211
209
|
|
|
212
|
-
if (repos.length === 0
|
|
213
|
-
console.log('no cached
|
|
210
|
+
if (repos.length === 0) {
|
|
211
|
+
console.log('no cached repositories found');
|
|
214
212
|
return;
|
|
215
213
|
}
|
|
216
214
|
|
|
217
215
|
// build choices for checkbox
|
|
218
|
-
const maxNameLen = Math.max(
|
|
219
|
-
|
|
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
|
-
);
|
|
216
|
+
const maxNameLen = Math.max(...repos.map((r) => r.displayPath.length));
|
|
217
|
+
const maxSizeLen = Math.max(...repos.map((r) => formatSize(r.size).length));
|
|
226
218
|
|
|
227
219
|
// checkbox prefix is ~4 chars, leave some margin
|
|
228
220
|
const termWidth = process.stdout.columns ?? 80;
|
|
@@ -237,14 +229,6 @@ export const handler = async (args: Args): Promise<void> => {
|
|
|
237
229
|
short: repo.displayPath,
|
|
238
230
|
}));
|
|
239
231
|
|
|
240
|
-
if (sessionsExist && sessionsSize > 0) {
|
|
241
|
-
choices.push({
|
|
242
|
-
name: formatChoice('(sessions)', sessionsSize),
|
|
243
|
-
value: sessionsDir,
|
|
244
|
-
short: '(sessions)',
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
|
|
248
232
|
const selected = await checkbox({
|
|
249
233
|
message: 'select items to remove',
|
|
250
234
|
choices,
|
package/src/lib/git.ts
CHANGED
|
@@ -85,15 +85,34 @@ const gitOutput = (args: string[], cwd?: string): Promise<string> =>
|
|
|
85
85
|
proc.on('error', reject);
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* checks if a repository has full history.
|
|
90
|
+
* @param cachePath the local repository path
|
|
91
|
+
* @returns true if the repository has full history (not shallow)
|
|
92
|
+
*/
|
|
93
|
+
const isDeepRepo = async (cachePath: string): Promise<boolean> => {
|
|
94
|
+
const result = await gitOutput(['rev-parse', '--is-shallow-repository'], cachePath);
|
|
95
|
+
return result !== 'true';
|
|
96
|
+
};
|
|
97
|
+
|
|
88
98
|
/**
|
|
89
99
|
* clones a repository to the cache path.
|
|
90
100
|
* @param remote the remote URL
|
|
91
101
|
* @param cachePath the local cache path
|
|
92
102
|
* @param branch optional branch to checkout
|
|
103
|
+
* @param deep if true, clones full history; otherwise shallow clone with depth 1
|
|
93
104
|
*/
|
|
94
|
-
|
|
105
|
+
const cloneRepo = async (
|
|
106
|
+
remote: string,
|
|
107
|
+
cachePath: string,
|
|
108
|
+
branch: string | undefined,
|
|
109
|
+
deep: boolean,
|
|
110
|
+
): Promise<void> => {
|
|
95
111
|
await mkdir(dirname(cachePath), { recursive: true });
|
|
96
112
|
const args = ['clone'];
|
|
113
|
+
if (!deep) {
|
|
114
|
+
args.push('--depth', '1');
|
|
115
|
+
}
|
|
97
116
|
if (branch) {
|
|
98
117
|
args.push('--branch', branch);
|
|
99
118
|
}
|
|
@@ -106,9 +125,27 @@ export const cloneRepo = async (remote: string, cachePath: string, branch?: stri
|
|
|
106
125
|
* discards any local modifications, staged changes, and untracked files.
|
|
107
126
|
* @param cachePath the local cache path
|
|
108
127
|
* @param branch optional branch to checkout (uses default branch if not specified)
|
|
128
|
+
* @param deep if true, unshallows if needed; if false, keeps shallow clone
|
|
109
129
|
*/
|
|
110
|
-
|
|
111
|
-
await
|
|
130
|
+
const updateRepo = async (cachePath: string, branch: string | undefined, deep: boolean): Promise<void> => {
|
|
131
|
+
const currentlyDeep = await isDeepRepo(cachePath);
|
|
132
|
+
|
|
133
|
+
// handle depth state transitions
|
|
134
|
+
if (deep && !currentlyDeep) {
|
|
135
|
+
// want full history but have shallow - unshallow first
|
|
136
|
+
await git(['fetch', '--unshallow', 'origin'], cachePath);
|
|
137
|
+
} else if (!deep && currentlyDeep) {
|
|
138
|
+
// want shallow but have full - just use the full clone as-is
|
|
139
|
+
console.error(' note: repository is already a full clone, use `cgr clean` to re-clone as shallow');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// fetch updates (use depth 1 for shallow repos that stay shallow)
|
|
143
|
+
if (!deep && !currentlyDeep) {
|
|
144
|
+
await git(['fetch', '--depth', '1', 'origin'], cachePath);
|
|
145
|
+
} else {
|
|
146
|
+
// either was unshallowed above, or is/was full
|
|
147
|
+
await git(['fetch', 'origin'], cachePath);
|
|
148
|
+
}
|
|
112
149
|
|
|
113
150
|
// determine the branch to use
|
|
114
151
|
let targetBranch = branch;
|
|
@@ -125,16 +162,26 @@ export const updateRepo = async (cachePath: string, branch?: string): Promise<vo
|
|
|
125
162
|
await git(['reset', '--hard', `origin/${targetBranch}`], cachePath);
|
|
126
163
|
};
|
|
127
164
|
|
|
165
|
+
export interface EnsureRepoOptions {
|
|
166
|
+
/** the remote URL */
|
|
167
|
+
remote: string;
|
|
168
|
+
/** the local cache path */
|
|
169
|
+
cachePath: string;
|
|
170
|
+
/** branch to checkout */
|
|
171
|
+
branch: string | undefined;
|
|
172
|
+
/** if true, clones full history; otherwise uses shallow clone with depth 1 */
|
|
173
|
+
deep: boolean;
|
|
174
|
+
}
|
|
175
|
+
|
|
128
176
|
/**
|
|
129
177
|
* ensures a repository is cloned and up-to-date.
|
|
130
|
-
* @param
|
|
131
|
-
* @param cachePath the local cache path
|
|
132
|
-
* @param branch optional branch to checkout
|
|
178
|
+
* @param options repository options
|
|
133
179
|
*/
|
|
134
|
-
export const ensureRepo = async (
|
|
180
|
+
export const ensureRepo = async (options: EnsureRepoOptions): Promise<void> => {
|
|
181
|
+
const { remote, cachePath, branch, deep } = options;
|
|
135
182
|
if (existsSync(cachePath)) {
|
|
136
|
-
await updateRepo(cachePath, branch);
|
|
183
|
+
await updateRepo(cachePath, branch, deep);
|
|
137
184
|
} else {
|
|
138
|
-
await cloneRepo(remote, cachePath, branch);
|
|
185
|
+
await cloneRepo(remote, cachePath, branch, deep);
|
|
139
186
|
}
|
|
140
187
|
};
|
package/src/lib/paths.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import type { Dirent } from 'node:fs';
|
|
3
|
+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
6
|
|
|
7
|
+
import * as v from 'valibot';
|
|
8
|
+
|
|
6
9
|
/**
|
|
7
10
|
* returns the cache directory for cgr.
|
|
8
11
|
* uses `$XDG_CACHE_HOME/cgr` if set, otherwise falls back to:
|
|
@@ -157,13 +160,19 @@ export const parseRemoteWithBranch = (input: string): { remote: string; branch?:
|
|
|
157
160
|
*/
|
|
158
161
|
export const getSessionsDir = (): string => join(getCacheDir(), 'sessions');
|
|
159
162
|
|
|
163
|
+
const PID_FILE = '.pid';
|
|
164
|
+
|
|
165
|
+
// linux PID limits: 1 to 2^22 (4194304) by default, configurable up to 2^22
|
|
166
|
+
const PidSchema = v.pipe(v.string(), v.trim(), v.toNumber(), v.integer(), v.minValue(1));
|
|
167
|
+
|
|
160
168
|
/**
|
|
161
|
-
* creates a new session directory with a random UUID.
|
|
169
|
+
* creates a new session directory with a random UUID and writes a PID lockfile.
|
|
162
170
|
* @returns the path to the created session directory
|
|
163
171
|
*/
|
|
164
172
|
export const createSessionDir = async (): Promise<string> => {
|
|
165
173
|
const sessionPath = join(getSessionsDir(), randomUUID());
|
|
166
174
|
await mkdir(sessionPath, { recursive: true });
|
|
175
|
+
await writeFile(join(sessionPath, PID_FILE), process.pid.toString());
|
|
167
176
|
return sessionPath;
|
|
168
177
|
};
|
|
169
178
|
|
|
@@ -174,3 +183,55 @@ export const createSessionDir = async (): Promise<string> => {
|
|
|
174
183
|
export const cleanupSessionDir = async (sessionPath: string): Promise<void> => {
|
|
175
184
|
await rm(sessionPath, { recursive: true, force: true });
|
|
176
185
|
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* checks if a process with the given PID is running.
|
|
189
|
+
* @param pid the process ID to check
|
|
190
|
+
* @returns true if the process is running
|
|
191
|
+
*/
|
|
192
|
+
const isProcessRunning = (pid: number): boolean => {
|
|
193
|
+
try {
|
|
194
|
+
// signal 0 doesn't send a signal but checks if process exists
|
|
195
|
+
process.kill(pid, 0);
|
|
196
|
+
return true;
|
|
197
|
+
} catch {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* garbage collects orphaned session directories.
|
|
204
|
+
* a session is orphaned if its PID file is missing or the process is no longer running.
|
|
205
|
+
* this function is meant to be called fire-and-forget (errors are silently ignored).
|
|
206
|
+
*/
|
|
207
|
+
export const gcSessions = async (): Promise<void> => {
|
|
208
|
+
const sessionsDir = getSessionsDir();
|
|
209
|
+
|
|
210
|
+
let entries: Dirent[];
|
|
211
|
+
try {
|
|
212
|
+
entries = await readdir(sessionsDir, { withFileTypes: true });
|
|
213
|
+
} catch {
|
|
214
|
+
// sessions dir doesn't exist or can't be read
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (const entry of entries) {
|
|
219
|
+
if (!entry.isDirectory()) {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const sessionPath = join(sessionsDir, entry.name);
|
|
223
|
+
const pidPath = join(sessionPath, PID_FILE);
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const pidContent = await readFile(pidPath, 'utf-8');
|
|
227
|
+
const result = v.safeParse(PidSchema, pidContent);
|
|
228
|
+
|
|
229
|
+
if (!result.success || !isProcessRunning(result.output)) {
|
|
230
|
+
await rm(sessionPath, { recursive: true, force: true });
|
|
231
|
+
}
|
|
232
|
+
} catch {
|
|
233
|
+
// no PID file or can't read it - orphaned session
|
|
234
|
+
await rm(sessionPath, { recursive: true, force: true }).catch(() => {});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
};
|