@oomfware/cgr 0.1.1 → 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 +240 -52
- 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 +51 -14
- 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
|
|
@@ -331,14 +503,20 @@ const confirm = (msg) => new Promise((resolve) => {
|
|
|
331
503
|
*/
|
|
332
504
|
const handler = async (args) => {
|
|
333
505
|
const reposDir = getReposDir();
|
|
506
|
+
const sessionsDir = getSessionsDir();
|
|
334
507
|
if (args.all) {
|
|
335
|
-
|
|
336
|
-
|
|
508
|
+
const reposExist = existsSync(reposDir);
|
|
509
|
+
const sessionsExist$1 = existsSync(sessionsDir);
|
|
510
|
+
if (!reposExist && !sessionsExist$1) {
|
|
511
|
+
console.log("no cached data found");
|
|
337
512
|
return;
|
|
338
513
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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 });
|
|
342
520
|
console.log("done");
|
|
343
521
|
return;
|
|
344
522
|
}
|
|
@@ -359,20 +537,30 @@ const handler = async (args) => {
|
|
|
359
537
|
return;
|
|
360
538
|
}
|
|
361
539
|
const repos = listCachedRepos();
|
|
362
|
-
|
|
363
|
-
|
|
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");
|
|
364
544
|
return;
|
|
365
545
|
}
|
|
366
|
-
console.log("cached repositories:\n");
|
|
367
546
|
let totalSize = 0;
|
|
368
|
-
|
|
369
|
-
console.log(
|
|
370
|
-
|
|
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;
|
|
371
558
|
}
|
|
372
559
|
console.log(`\n total: ${formatSize(totalSize)}`);
|
|
373
560
|
console.log();
|
|
374
|
-
if (await confirm("remove all cached
|
|
375
|
-
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 });
|
|
376
564
|
console.log("done");
|
|
377
565
|
}
|
|
378
566
|
};
|
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'),
|
|
@@ -142,16 +142,33 @@ const confirm = (msg: string): Promise<boolean> =>
|
|
|
142
142
|
*/
|
|
143
143
|
export const handler = async (args: Args): Promise<void> => {
|
|
144
144
|
const reposDir = getReposDir();
|
|
145
|
+
const sessionsDir = getSessionsDir();
|
|
145
146
|
|
|
146
147
|
// clean all
|
|
147
148
|
if (args.all) {
|
|
148
|
-
|
|
149
|
-
|
|
149
|
+
const reposExist = existsSync(reposDir);
|
|
150
|
+
const sessionsExist = existsSync(sessionsDir);
|
|
151
|
+
|
|
152
|
+
if (!reposExist && !sessionsExist) {
|
|
153
|
+
console.log('no cached data found');
|
|
150
154
|
return;
|
|
151
155
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
+
}
|
|
155
172
|
console.log('done');
|
|
156
173
|
return;
|
|
157
174
|
}
|
|
@@ -176,23 +193,43 @@ export const handler = async (args: Args): Promise<void> => {
|
|
|
176
193
|
|
|
177
194
|
// list repos and prompt for confirmation
|
|
178
195
|
const repos = listCachedRepos();
|
|
179
|
-
|
|
180
|
-
|
|
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');
|
|
181
201
|
return;
|
|
182
202
|
}
|
|
183
203
|
|
|
184
|
-
console.log('cached repositories:\n');
|
|
185
204
|
let totalSize = 0;
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
+
}
|
|
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;
|
|
189
220
|
}
|
|
221
|
+
|
|
190
222
|
console.log(`\n total: ${formatSize(totalSize)}`);
|
|
191
223
|
console.log();
|
|
192
224
|
|
|
193
|
-
const confirmed = await confirm('remove all cached
|
|
225
|
+
const confirmed = await confirm('remove all cached data?');
|
|
194
226
|
if (confirmed) {
|
|
195
|
-
|
|
227
|
+
if (repos.length > 0) {
|
|
228
|
+
await rm(reposDir, { recursive: true });
|
|
229
|
+
}
|
|
230
|
+
if (sessionsExist) {
|
|
231
|
+
await rm(sessionsDir, { recursive: true });
|
|
232
|
+
}
|
|
196
233
|
console.log('done');
|
|
197
234
|
}
|
|
198
235
|
};
|
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
|
+
};
|