@oomfware/cgr 0.1.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/LICENSE ADDED
@@ -0,0 +1,14 @@
1
+ BSD Zero Clause License
2
+
3
+ Copyright (c) 2026 Mary
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted.
7
+
8
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
9
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
10
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
11
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
12
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
13
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
14
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # @oomfware/cgr
2
+
3
+ ask questions about any git repository using Claude Code.
4
+
5
+ ```sh
6
+ pnpm install -g @oomfware/cgr
7
+ ```
8
+
9
+ ## usage
10
+
11
+ ```sh
12
+ # ask a question about a repository
13
+ cgr ask github.com/facebook/react "How do hooks track dependencies to avoid stale closures?"
14
+
15
+ # specify a model (default: haiku)
16
+ cgr ask -m sonnet github.com/shadcn-ui/ui "How does the registry system resolve component dependencies?"
17
+
18
+ # checkout a specific branch
19
+ cgr ask -b canary github.com/vercel/next.js "Where is dynamic route resolution handled in the app router?"
20
+
21
+ # works with any git host
22
+ cgr ask tangled.sh/mary.my.id/atcute "How do I validate AT Protocol lexicon schemas at runtime?"
23
+ ```
24
+
25
+ repositories are cached locally at `~/.cache/cgr/repos/<host>/<owner>/<repo>`. use `cgr clean` to
26
+ manage the cache:
27
+
28
+ ```sh
29
+ # list cached repos with sizes
30
+ cgr clean
31
+
32
+ # remove a specific repo
33
+ cgr clean github.com/facebook/react
34
+
35
+ # remove all cached repos
36
+ cgr clean --all
37
+ ```
38
+
39
+ ## commands
40
+
41
+ ```
42
+ cgr ask [-m opus|sonnet|haiku] [-b branch] <repo> <question>
43
+ cgr clean [--all | <repo>]
44
+ ```
45
+
46
+ | command | description |
47
+ | ------- | ----------------------------------------------------------------- |
48
+ | `ask` | clone/update a repository and ask Claude Code a question about it |
49
+ | `clean` | remove cached repositories |
50
+
51
+ ## configuring CLAUDE.md
52
+
53
+ add this to your `~/.claude/CLAUDE.md` or project's `CLAUDE.md` to let Claude Code know about cgr:
54
+
55
+ ```markdown
56
+ ## cgr
57
+
58
+ Use `npx @oomfware/cgr ask <repo> <question>` to research external repositories. examples:
59
+
60
+ - `npx @oomfware/cgr ask github.com/facebook/react "How do hooks track dependencies to avoid stale closures in useEffect?"`
61
+ - `npx @oomfware/cgr ask -b canary github.com/vercel/next.js "Where is dynamic route resolution handled in the app router?"`
62
+ - `npx @oomfware/cgr ask -m sonnet github.com/shadcn-ui/ui "How do I configure path aliases so components install to the right location?"`
63
+
64
+ This clones the repo locally and runs Claude Code in read-only mode to analyze it. Run
65
+ `npx @oomfware/cgr --help` for more options.
66
+ ```
67
+
68
+ alternatively, a more structured prompt:
69
+
70
+ ```markdown
71
+ # cgr
72
+
73
+ You can use `@oomfware/cgr` to ask questions about external repositories.
74
+
75
+ npx @oomfware/cgr ask [options] <repo> <question>
76
+
77
+ options:
78
+ -m, --model <model> model to use: opus, sonnet, haiku (default: haiku)
79
+ -b, --branch <branch> branch to checkout
80
+
81
+ useful repositories:
82
+
83
+ - `github.com/facebook/react` for React internals, hooks, reconciler
84
+ - `github.com/vercel/next.js` for Next.js app router, server components
85
+ - `github.com/shadcn-ui/ui` for shadcn component patterns, registry system
86
+ - `github.com/tailwindlabs/tailwindcss` for Tailwind internals, plugin API
87
+ - `github.com/bluesky-social/atproto` for AT Protocol, Bluesky API
88
+
89
+ This clones the repo locally and runs Claude Code in read-only mode. Run `npx @oomfware/cgr --help`
90
+ for more options.
91
+ ```
@@ -0,0 +1,48 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Read",
5
+ "Glob",
6
+ "Grep",
7
+ "WebFetch",
8
+ "WebSearch",
9
+
10
+ "Bash(git blame:*)",
11
+ "Bash(git branch:*)",
12
+ "Bash(git checkout:*)",
13
+ "Bash(git diff:*)",
14
+ "Bash(git log:*)",
15
+ "Bash(git show:*)",
16
+ "Bash(git status:*)",
17
+ "Bash(git tag:*)",
18
+
19
+ "Bash(cat:*)",
20
+ "Bash(file:*)",
21
+ "Bash(find:*)",
22
+ "Bash(grep:*)",
23
+ "Bash(head:*)",
24
+ "Bash(ls:*)",
25
+ "Bash(stat:*)",
26
+ "Bash(tail:*)",
27
+ "Bash(tree:*)",
28
+ "Bash(wc:*)",
29
+
30
+ "Bash(awk:*)",
31
+ "Bash(cut:*)",
32
+ "Bash(diff:*)",
33
+ "Bash(jq:*)",
34
+ "Bash(sed:*)",
35
+ "Bash(sort:*)",
36
+ "Bash(tr:*)",
37
+ "Bash(uniq:*)",
38
+ "Bash(xargs:*)",
39
+
40
+ "Bash(basename:*)",
41
+ "Bash(dirname:*)",
42
+ "Bash(realpath:*)",
43
+
44
+ "Bash(du:*)"
45
+ ],
46
+ "deny": ["*"]
47
+ }
48
+ }
@@ -0,0 +1,46 @@
1
+ You are a code research assistant analyzing an external repository. Your goal is to answer the
2
+ user's question accurately and thoroughly by exploring the codebase.
3
+
4
+ ## Available tools
5
+
6
+ **File exploration:**
7
+
8
+ - `Read` - Read file contents
9
+ - `Glob` - Find files by pattern (e.g., `**/*.ts`, `src/**/*.test.js`)
10
+ - `Grep` - Search file contents with regex
11
+
12
+ **Git commands:**
13
+
14
+ - `git log` - View commit history
15
+ - `git show` - Inspect commits, files at specific revisions
16
+ - `git diff` - Compare changes between commits/branches
17
+ - `git blame` - See who changed each line and when
18
+ - `git branch` - List branches
19
+ - `git tag` - List tags
20
+ - `git checkout` - Switch branches, tags, or view files at specific commits
21
+
22
+ **External resources:**
23
+
24
+ - `WebSearch` - Search the web for documentation, issues, discussions
25
+ - `WebFetch` - Fetch specific URLs (docs, GitHub issues, etc.)
26
+
27
+ You also have read-only Bash access for standard Unix tools when needed.
28
+
29
+ ## Approach
30
+
31
+ 1. **Explore before answering** - Don't guess. Use Glob and Grep to find relevant files, then Read
32
+ to understand them.
33
+ 2. **Trace the code** - Follow imports, function calls, and data flow to build a complete picture.
34
+ 3. **Check history when relevant** - Use git log/blame/show to understand why code exists or how it
35
+ evolved.
36
+ 4. **Cite your sources** - Reference specific files and line numbers (e.g.,
37
+ `src/hooks/useState.ts:42`).
38
+ 5. **Use web resources** - If the codebase references external concepts or you need context, search
39
+ for documentation.
40
+
41
+ ## Response style
42
+
43
+ - Be thorough but focused on the question asked
44
+ - Include code snippets when they help explain concepts
45
+ - Explain the "why" not just the "what"
46
+ - If you're uncertain about something, say so and explain what you did find
package/dist/index.mjs ADDED
@@ -0,0 +1,392 @@
1
+ #!/usr/bin/env node
2
+ import { argument, choice, command, constant, message, object, option, or, string } from "@optique/core";
3
+ import { run } from "@optique/run";
4
+ import { spawn } from "node:child_process";
5
+ import { dirname, join } from "node:path";
6
+ import { optional, withDefault } from "@optique/core/modifiers";
7
+ import { existsSync, readdirSync, statSync } from "node:fs";
8
+ import { mkdir, rm } from "node:fs/promises";
9
+ import { homedir } from "node:os";
10
+ import { createInterface } from "node:readline";
11
+
12
+ //#region src/lib/git.ts
13
+ /**
14
+ * executes a git command and returns the result.
15
+ * @param args git command arguments
16
+ * @param cwd working directory
17
+ * @returns promise that resolves when the command completes
18
+ */
19
+ const git = (args, cwd) => new Promise((resolve, reject) => {
20
+ const proc = spawn("git", args, {
21
+ cwd,
22
+ stdio: "inherit"
23
+ });
24
+ proc.on("close", (code) => {
25
+ if (code === 0) resolve();
26
+ else reject(/* @__PURE__ */ new Error(`git ${args[0]} failed with code ${code}`));
27
+ });
28
+ proc.on("error", reject);
29
+ });
30
+ /**
31
+ * executes a git command and captures stdout.
32
+ * @param args git command arguments
33
+ * @param cwd working directory
34
+ * @returns promise that resolves with stdout
35
+ */
36
+ const gitOutput = (args, cwd) => new Promise((resolve, reject) => {
37
+ const proc = spawn("git", args, {
38
+ cwd,
39
+ stdio: [
40
+ "inherit",
41
+ "pipe",
42
+ "inherit"
43
+ ]
44
+ });
45
+ let output = "";
46
+ proc.stdout.on("data", (data) => {
47
+ output += data.toString();
48
+ });
49
+ proc.on("close", (code) => {
50
+ if (code === 0) resolve(output.trim());
51
+ else reject(/* @__PURE__ */ new Error(`git ${args[0]} failed with code ${code}`));
52
+ });
53
+ proc.on("error", reject);
54
+ });
55
+ /**
56
+ * clones a repository to the cache path.
57
+ * @param remote the remote URL
58
+ * @param cachePath the local cache path
59
+ * @param branch optional branch to checkout
60
+ */
61
+ const cloneRepo = async (remote, cachePath, branch) => {
62
+ await mkdir(dirname(cachePath), { recursive: true });
63
+ const args = ["clone"];
64
+ if (branch) args.push("--branch", branch);
65
+ args.push(remote, cachePath);
66
+ await git(args);
67
+ };
68
+ /**
69
+ * updates an existing repository in the cache.
70
+ * discards any local modifications, staged changes, and untracked files.
71
+ * @param cachePath the local cache path
72
+ * @param branch optional branch to checkout (uses default branch if not specified)
73
+ */
74
+ const updateRepo = async (cachePath, branch) => {
75
+ await git(["fetch", "origin"], cachePath);
76
+ let targetBranch = branch;
77
+ if (!targetBranch) targetBranch = (await gitOutput(["symbolic-ref", "refs/remotes/origin/HEAD"], cachePath)).replace("refs/remotes/origin/", "");
78
+ await git([
79
+ "reset",
80
+ "--hard",
81
+ "HEAD"
82
+ ], cachePath);
83
+ await git(["clean", "-fd"], cachePath);
84
+ await git([
85
+ "checkout",
86
+ "-f",
87
+ targetBranch
88
+ ], cachePath);
89
+ await git([
90
+ "reset",
91
+ "--hard",
92
+ `origin/${targetBranch}`
93
+ ], cachePath);
94
+ };
95
+ /**
96
+ * ensures a repository is cloned and up-to-date.
97
+ * @param remote the remote URL
98
+ * @param cachePath the local cache path
99
+ * @param branch optional branch to checkout
100
+ */
101
+ const ensureRepo = async (remote, cachePath, branch) => {
102
+ if (existsSync(cachePath)) await updateRepo(cachePath, branch);
103
+ else await cloneRepo(remote, cachePath, branch);
104
+ };
105
+
106
+ //#endregion
107
+ //#region src/lib/paths.ts
108
+ /**
109
+ * returns the cache directory for cgr.
110
+ * uses `$XDG_CACHE_HOME/cgr` if set, otherwise falls back to:
111
+ * - `~/.cache/cgr` on linux
112
+ * - `~/Library/Caches/cgr` on macos
113
+ * @returns the cache directory path
114
+ */
115
+ const getCacheDir = () => {
116
+ const xdgCache = process.env["XDG_CACHE_HOME"];
117
+ if (xdgCache) return join(xdgCache, "cgr");
118
+ const home = homedir();
119
+ if (process.platform === "darwin") return join(home, "Library", "Caches", "cgr");
120
+ return join(home, ".cache", "cgr");
121
+ };
122
+ /**
123
+ * returns the repos directory within the cache.
124
+ * @returns the repos directory path
125
+ */
126
+ const getReposDir = () => join(getCacheDir(), "repos");
127
+ /**
128
+ * parses a git remote URL and extracts host, owner, and repo.
129
+ * supports HTTP(S), SSH, and bare URLs (assumes HTTPS).
130
+ * @param remote the remote URL to parse
131
+ * @returns parsed components or null if invalid
132
+ */
133
+ const parseRemote = (remote) => {
134
+ const httpMatch = remote.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/);
135
+ if (httpMatch) return {
136
+ host: httpMatch[1],
137
+ owner: httpMatch[2],
138
+ repo: httpMatch[3]
139
+ };
140
+ const sshMatch = remote.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/);
141
+ if (sshMatch) return {
142
+ host: sshMatch[1],
143
+ owner: sshMatch[2],
144
+ repo: sshMatch[3]
145
+ };
146
+ const bareMatch = remote.match(/^([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/);
147
+ if (bareMatch) return {
148
+ host: bareMatch[1],
149
+ owner: bareMatch[2],
150
+ repo: bareMatch[3]
151
+ };
152
+ return null;
153
+ };
154
+ /**
155
+ * normalizes a remote URL by prepending https:// if no protocol is present.
156
+ * @param remote the remote URL to normalize
157
+ * @returns normalized URL with protocol
158
+ */
159
+ const normalizeRemote = (remote) => {
160
+ if (remote.startsWith("https://") || remote.startsWith("http://") || remote.startsWith("git@")) return remote;
161
+ return `https://${remote}`;
162
+ };
163
+ /**
164
+ * returns the cache path for a specific repository.
165
+ * @param remote the remote URL
166
+ * @returns the cache path or null if the remote URL is invalid
167
+ */
168
+ const getRepoCachePath = (remote) => {
169
+ const parsed = parseRemote(remote);
170
+ if (!parsed) return null;
171
+ return join(getReposDir(), parsed.host, parsed.owner, parsed.repo);
172
+ };
173
+
174
+ //#endregion
175
+ //#region src/commands/ask.ts
176
+ const assetsDir = join(import.meta.dirname, "assets");
177
+ const settingsPath = join(assetsDir, "settings.json");
178
+ const systemPromptPath = join(assetsDir, "system-prompt.md");
179
+ const schema$1 = object({
180
+ command: constant("ask"),
181
+ model: withDefault(option("-m", "--model", choice([
182
+ "opus",
183
+ "sonnet",
184
+ "haiku"
185
+ ]), { description: message`model to use for analysis` }), "haiku"),
186
+ branch: optional(option("-b", "--branch", string(), { description: message`branch to checkout` })),
187
+ remote: argument(string({ metavar: "REPO" }), { description: message`git remote URL (HTTP/HTTPS/SSH)` }),
188
+ question: argument(string({ metavar: "QUESTION" }), { description: message`question to ask about the repository` })
189
+ });
190
+ /**
191
+ * handles the ask command.
192
+ * clones/updates the repository and spawns Claude Code to answer the question.
193
+ * @param args parsed command arguments
194
+ */
195
+ const handler$1 = async (args) => {
196
+ const parsed = parseRemote(args.remote);
197
+ if (!parsed) {
198
+ console.error(`error: invalid remote URL: ${args.remote}`);
199
+ console.error("expected format: host/owner/repo, e.g.:");
200
+ console.error(" github.com/user/repo");
201
+ console.error(" https://github.com/user/repo");
202
+ console.error(" git@github.com:user/repo.git");
203
+ process.exit(1);
204
+ }
205
+ const cachePath = getRepoCachePath(args.remote);
206
+ if (!cachePath) {
207
+ console.error(`error: could not determine cache path for: ${args.remote}`);
208
+ process.exit(1);
209
+ }
210
+ const remoteUrl = normalizeRemote(args.remote);
211
+ console.error(`preparing repository: ${parsed.host}/${parsed.owner}/${parsed.repo}`);
212
+ try {
213
+ await ensureRepo(remoteUrl, cachePath, args.branch);
214
+ } catch (err) {
215
+ console.error(`error: failed to prepare repository: ${err}`);
216
+ process.exit(1);
217
+ }
218
+ const contextPrompt = `You are examining ${`${parsed.host}/${parsed.owner}/${parsed.repo}`} (checked out on ${args.branch ?? "default branch"}).`;
219
+ const claude = spawn("claude", [
220
+ "-p",
221
+ args.question,
222
+ "--model",
223
+ args.model,
224
+ "--settings",
225
+ settingsPath,
226
+ "--system-prompt-file",
227
+ systemPromptPath,
228
+ "--append-system-prompt",
229
+ contextPrompt
230
+ ], {
231
+ cwd: cachePath,
232
+ stdio: "inherit"
233
+ });
234
+ claude.on("close", (code) => {
235
+ process.exit(code ?? 0);
236
+ });
237
+ claude.on("error", (err) => {
238
+ console.error(`error: failed to spawn claude: ${err}`);
239
+ process.exit(1);
240
+ });
241
+ };
242
+
243
+ //#endregion
244
+ //#region src/commands/clean.ts
245
+ const schema = object({
246
+ command: constant("clean"),
247
+ all: option("--all", { description: message`remove all cached repositories` }),
248
+ remote: optional(argument(string({ metavar: "URL" }), { description: message`specific remote URL to clean` }))
249
+ });
250
+ /**
251
+ * formats a byte size in human-readable form.
252
+ * @param bytes size in bytes
253
+ * @returns formatted string
254
+ */
255
+ const formatSize = (bytes) => {
256
+ if (bytes < 1024) return `${bytes} B`;
257
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
258
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
259
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
260
+ };
261
+ /**
262
+ * calculates the total size of a directory recursively.
263
+ * @param dir directory path
264
+ * @returns total size in bytes
265
+ */
266
+ const getDirSize = (dir) => {
267
+ let size = 0;
268
+ try {
269
+ const entries = readdirSync(dir, { withFileTypes: true });
270
+ for (const entry of entries) {
271
+ const fullPath = join(dir, entry.name);
272
+ if (entry.isDirectory()) size += getDirSize(fullPath);
273
+ else size += statSync(fullPath).size;
274
+ }
275
+ } catch {}
276
+ return size;
277
+ };
278
+ /**
279
+ * lists all cached repositories with their sizes.
280
+ * @returns array of { path, displayPath, size } objects
281
+ */
282
+ const listCachedRepos = () => {
283
+ const reposDir = getReposDir();
284
+ const repos = [];
285
+ if (!existsSync(reposDir)) return repos;
286
+ for (const host of readdirSync(reposDir)) {
287
+ const hostPath = join(reposDir, host);
288
+ if (!statSync(hostPath).isDirectory()) continue;
289
+ for (const owner of readdirSync(hostPath)) {
290
+ const ownerPath = join(hostPath, owner);
291
+ if (!statSync(ownerPath).isDirectory()) continue;
292
+ for (const repo of readdirSync(ownerPath)) {
293
+ const repoPath = join(ownerPath, repo);
294
+ if (!statSync(repoPath).isDirectory()) continue;
295
+ repos.push({
296
+ path: repoPath,
297
+ displayPath: `${host}/${owner}/${repo}`,
298
+ size: getDirSize(repoPath)
299
+ });
300
+ }
301
+ }
302
+ }
303
+ return repos;
304
+ };
305
+ /**
306
+ * prompts the user for confirmation.
307
+ * @param msg the prompt message
308
+ * @returns promise that resolves to true if confirmed
309
+ */
310
+ const confirm = (msg) => new Promise((resolve) => {
311
+ const rl = createInterface({
312
+ input: process.stdin,
313
+ output: process.stdout
314
+ });
315
+ rl.question(`${msg} [y/N] `, (answer) => {
316
+ rl.close();
317
+ resolve(answer.toLowerCase() === "y");
318
+ });
319
+ });
320
+ /**
321
+ * handles the clean command.
322
+ * @param args parsed command arguments
323
+ */
324
+ const handler = async (args) => {
325
+ const reposDir = getReposDir();
326
+ if (args.all) {
327
+ if (!existsSync(reposDir)) {
328
+ console.log("no cached repositories found");
329
+ return;
330
+ }
331
+ const size = getDirSize(reposDir);
332
+ console.log(`removing all cached repositories (${formatSize(size)})`);
333
+ await rm(reposDir, { recursive: true });
334
+ console.log("done");
335
+ return;
336
+ }
337
+ if (args.remote) {
338
+ const cachePath = getRepoCachePath(args.remote);
339
+ if (!cachePath) {
340
+ console.error(`error: invalid remote URL: ${args.remote}`);
341
+ process.exit(1);
342
+ }
343
+ if (!existsSync(cachePath)) {
344
+ console.error(`error: repository not cached: ${args.remote}`);
345
+ process.exit(1);
346
+ }
347
+ const size = getDirSize(cachePath);
348
+ console.log(`removing ${cachePath} (${formatSize(size)})`);
349
+ await rm(cachePath, { recursive: true });
350
+ console.log("done");
351
+ return;
352
+ }
353
+ const repos = listCachedRepos();
354
+ if (repos.length === 0) {
355
+ console.log("no cached repositories found");
356
+ return;
357
+ }
358
+ console.log("cached repositories:\n");
359
+ let totalSize = 0;
360
+ for (const repo of repos) {
361
+ console.log(` ${repo.displayPath.padEnd(50)} ${formatSize(repo.size)}`);
362
+ totalSize += repo.size;
363
+ }
364
+ console.log(`\n total: ${formatSize(totalSize)}`);
365
+ console.log();
366
+ if (await confirm("remove all cached repositories?")) {
367
+ await rm(reposDir, { recursive: true });
368
+ console.log("done");
369
+ }
370
+ };
371
+
372
+ //#endregion
373
+ //#region src/index.ts
374
+ const result = run(or(command("ask", schema$1, { description: message`ask a question about a repository` }), command("clean", schema, { description: message`remove cached repositories` })), {
375
+ help: "both",
376
+ version: {
377
+ value: "0.1.0",
378
+ mode: "option"
379
+ },
380
+ brief: message`ask questions about git repositories using Claude Code`
381
+ });
382
+ switch (result.command) {
383
+ case "ask":
384
+ await handler$1(result);
385
+ break;
386
+ case "clean":
387
+ await handler(result);
388
+ break;
389
+ }
390
+
391
+ //#endregion
392
+ export { };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@oomfware/cgr",
3
+ "version": "0.1.0",
4
+ "description": "ask questions about git repositories using Claude Code",
5
+ "license": "0BSD",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://codeberg.org/oomfware/cgr"
9
+ },
10
+ "bin": {
11
+ "cgr": "./dist/index.mjs"
12
+ },
13
+ "files": [
14
+ "dist/",
15
+ "src/",
16
+ "!src/**/*.bench.ts",
17
+ "!src/**/*.test.ts"
18
+ ],
19
+ "type": "module",
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "dependencies": {
24
+ "@optique/core": "^0.9.1",
25
+ "@optique/run": "^0.9.1"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^25.0.10",
29
+ "bumpp": "^10.4.0",
30
+ "oxfmt": "^0.26.0",
31
+ "oxlint": "^1.41.0",
32
+ "tsdown": "^0.19.0",
33
+ "typescript": "^5.9.3"
34
+ },
35
+ "scripts": {
36
+ "build": "tsdown",
37
+ "dev": "tsdown --watch",
38
+ "typecheck": "tsc --noEmit",
39
+ "fmt": "oxfmt",
40
+ "lint": "oxlint"
41
+ }
42
+ }
@@ -0,0 +1,48 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Read",
5
+ "Glob",
6
+ "Grep",
7
+ "WebFetch",
8
+ "WebSearch",
9
+
10
+ "Bash(git blame:*)",
11
+ "Bash(git branch:*)",
12
+ "Bash(git checkout:*)",
13
+ "Bash(git diff:*)",
14
+ "Bash(git log:*)",
15
+ "Bash(git show:*)",
16
+ "Bash(git status:*)",
17
+ "Bash(git tag:*)",
18
+
19
+ "Bash(cat:*)",
20
+ "Bash(file:*)",
21
+ "Bash(find:*)",
22
+ "Bash(grep:*)",
23
+ "Bash(head:*)",
24
+ "Bash(ls:*)",
25
+ "Bash(stat:*)",
26
+ "Bash(tail:*)",
27
+ "Bash(tree:*)",
28
+ "Bash(wc:*)",
29
+
30
+ "Bash(awk:*)",
31
+ "Bash(cut:*)",
32
+ "Bash(diff:*)",
33
+ "Bash(jq:*)",
34
+ "Bash(sed:*)",
35
+ "Bash(sort:*)",
36
+ "Bash(tr:*)",
37
+ "Bash(uniq:*)",
38
+ "Bash(xargs:*)",
39
+
40
+ "Bash(basename:*)",
41
+ "Bash(dirname:*)",
42
+ "Bash(realpath:*)",
43
+
44
+ "Bash(du:*)"
45
+ ],
46
+ "deny": ["*"]
47
+ }
48
+ }
@@ -0,0 +1,46 @@
1
+ You are a code research assistant analyzing an external repository. Your goal is to answer the
2
+ user's question accurately and thoroughly by exploring the codebase.
3
+
4
+ ## Available tools
5
+
6
+ **File exploration:**
7
+
8
+ - `Read` - Read file contents
9
+ - `Glob` - Find files by pattern (e.g., `**/*.ts`, `src/**/*.test.js`)
10
+ - `Grep` - Search file contents with regex
11
+
12
+ **Git commands:**
13
+
14
+ - `git log` - View commit history
15
+ - `git show` - Inspect commits, files at specific revisions
16
+ - `git diff` - Compare changes between commits/branches
17
+ - `git blame` - See who changed each line and when
18
+ - `git branch` - List branches
19
+ - `git tag` - List tags
20
+ - `git checkout` - Switch branches, tags, or view files at specific commits
21
+
22
+ **External resources:**
23
+
24
+ - `WebSearch` - Search the web for documentation, issues, discussions
25
+ - `WebFetch` - Fetch specific URLs (docs, GitHub issues, etc.)
26
+
27
+ You also have read-only Bash access for standard Unix tools when needed.
28
+
29
+ ## Approach
30
+
31
+ 1. **Explore before answering** - Don't guess. Use Glob and Grep to find relevant files, then Read
32
+ to understand them.
33
+ 2. **Trace the code** - Follow imports, function calls, and data flow to build a complete picture.
34
+ 3. **Check history when relevant** - Use git log/blame/show to understand why code exists or how it
35
+ evolved.
36
+ 4. **Cite your sources** - Reference specific files and line numbers (e.g.,
37
+ `src/hooks/useState.ts:42`).
38
+ 5. **Use web resources** - If the codebase references external concepts or you need context, search
39
+ for documentation.
40
+
41
+ ## Response style
42
+
43
+ - Be thorough but focused on the question asked
44
+ - Include code snippets when they help explain concepts
45
+ - Explain the "why" not just the "what"
46
+ - If you're uncertain about something, say so and explain what you did find
@@ -0,0 +1,104 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { join } from 'node:path';
3
+
4
+ import { argument, choice, constant, type InferValue, message, object, option, string } from '@optique/core';
5
+ import { optional, withDefault } from '@optique/core/modifiers';
6
+
7
+ import { ensureRepo } from '../lib/git.ts';
8
+ import { getRepoCachePath, normalizeRemote, parseRemote } from '../lib/paths.ts';
9
+
10
+ // resolve asset paths relative to the bundle (dist/index.mjs -> dist/assets/)
11
+ const assetsDir = join(import.meta.dirname, 'assets');
12
+ const settingsPath = join(assetsDir, 'settings.json');
13
+ const systemPromptPath = join(assetsDir, 'system-prompt.md');
14
+
15
+ export const schema = object({
16
+ command: constant('ask'),
17
+ model: withDefault(
18
+ option('-m', '--model', choice(['opus', 'sonnet', 'haiku']), {
19
+ description: message`model to use for analysis`,
20
+ }),
21
+ 'haiku',
22
+ ),
23
+ branch: optional(
24
+ option('-b', '--branch', string(), {
25
+ description: message`branch to checkout`,
26
+ }),
27
+ ),
28
+ remote: argument(string({ metavar: 'REPO' }), {
29
+ description: message`git remote URL (HTTP/HTTPS/SSH)`,
30
+ }),
31
+ question: argument(string({ metavar: 'QUESTION' }), {
32
+ description: message`question to ask about the repository`,
33
+ }),
34
+ });
35
+
36
+ export type Args = InferValue<typeof schema>;
37
+
38
+ /**
39
+ * handles the ask command.
40
+ * clones/updates the repository and spawns Claude Code to answer the question.
41
+ * @param args parsed command arguments
42
+ */
43
+ export const handler = async (args: Args): Promise<void> => {
44
+ // validate remote URL
45
+ const parsed = parseRemote(args.remote);
46
+ if (!parsed) {
47
+ console.error(`error: invalid remote URL: ${args.remote}`);
48
+ console.error('expected format: host/owner/repo, e.g.:');
49
+ console.error(' github.com/user/repo');
50
+ console.error(' https://github.com/user/repo');
51
+ console.error(' git@github.com:user/repo.git');
52
+ process.exit(1);
53
+ }
54
+
55
+ // get cache path
56
+ const cachePath = getRepoCachePath(args.remote);
57
+ if (!cachePath) {
58
+ console.error(`error: could not determine cache path for: ${args.remote}`);
59
+ process.exit(1);
60
+ }
61
+
62
+ // clone or update repository
63
+ const remoteUrl = normalizeRemote(args.remote);
64
+ console.error(`preparing repository: ${parsed.host}/${parsed.owner}/${parsed.repo}`);
65
+ try {
66
+ await ensureRepo(remoteUrl, cachePath, args.branch);
67
+ } catch (err) {
68
+ console.error(`error: failed to prepare repository: ${err}`);
69
+ process.exit(1);
70
+ }
71
+
72
+ // build context for append prompt
73
+ const repoDisplay = `${parsed.host}/${parsed.owner}/${parsed.repo}`;
74
+ const branchDisplay = args.branch ?? 'default branch';
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
+ });
95
+
96
+ claude.on('close', (code) => {
97
+ process.exit(code ?? 0);
98
+ });
99
+
100
+ claude.on('error', (err) => {
101
+ console.error(`error: failed to spawn claude: ${err}`);
102
+ process.exit(1);
103
+ });
104
+ };
@@ -0,0 +1,189 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { rm } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { createInterface } from 'node:readline';
5
+
6
+ import { argument, constant, type InferValue, message, object, option, string } from '@optique/core';
7
+ import { optional } from '@optique/core/modifiers';
8
+
9
+ import { getRepoCachePath, getReposDir } from '../lib/paths.ts';
10
+
11
+ export const schema = object({
12
+ command: constant('clean'),
13
+ all: option('--all', { description: message`remove all cached repositories` }),
14
+ remote: optional(
15
+ argument(string({ metavar: 'URL' }), {
16
+ description: message`specific remote URL to clean`,
17
+ }),
18
+ ),
19
+ });
20
+
21
+ export type Args = InferValue<typeof schema>;
22
+
23
+ /**
24
+ * formats a byte size in human-readable form.
25
+ * @param bytes size in bytes
26
+ * @returns formatted string
27
+ */
28
+ const formatSize = (bytes: number): string => {
29
+ if (bytes < 1024) {
30
+ return `${bytes} B`;
31
+ }
32
+ if (bytes < 1024 * 1024) {
33
+ return `${(bytes / 1024).toFixed(1)} KB`;
34
+ }
35
+ if (bytes < 1024 * 1024 * 1024) {
36
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
37
+ }
38
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
39
+ };
40
+
41
+ /**
42
+ * calculates the total size of a directory recursively.
43
+ * @param dir directory path
44
+ * @returns total size in bytes
45
+ */
46
+ const getDirSize = (dir: string): number => {
47
+ let size = 0;
48
+ try {
49
+ const entries = readdirSync(dir, { withFileTypes: true });
50
+ for (const entry of entries) {
51
+ const fullPath = join(dir, entry.name);
52
+ if (entry.isDirectory()) {
53
+ size += getDirSize(fullPath);
54
+ } else {
55
+ size += statSync(fullPath).size;
56
+ }
57
+ }
58
+ } catch {
59
+ // ignore errors (e.g., permission denied)
60
+ }
61
+ return size;
62
+ };
63
+
64
+ /**
65
+ * lists all cached repositories with their sizes.
66
+ * @returns array of { path, displayPath, size } objects
67
+ */
68
+ const listCachedRepos = (): {
69
+ path: string;
70
+ displayPath: string;
71
+ size: number;
72
+ }[] => {
73
+ const reposDir = getReposDir();
74
+ const repos: { path: string; displayPath: string; size: number }[] = [];
75
+
76
+ if (!existsSync(reposDir)) {
77
+ return repos;
78
+ }
79
+
80
+ // iterate hosts
81
+ for (const host of readdirSync(reposDir)) {
82
+ const hostPath = join(reposDir, host);
83
+ if (!statSync(hostPath).isDirectory()) {
84
+ continue;
85
+ }
86
+
87
+ // iterate owners
88
+ for (const owner of readdirSync(hostPath)) {
89
+ const ownerPath = join(hostPath, owner);
90
+ if (!statSync(ownerPath).isDirectory()) {
91
+ continue;
92
+ }
93
+
94
+ // iterate repos
95
+ for (const repo of readdirSync(ownerPath)) {
96
+ const repoPath = join(ownerPath, repo);
97
+ if (!statSync(repoPath).isDirectory()) {
98
+ continue;
99
+ }
100
+
101
+ repos.push({
102
+ path: repoPath,
103
+ displayPath: `${host}/${owner}/${repo}`,
104
+ size: getDirSize(repoPath),
105
+ });
106
+ }
107
+ }
108
+ }
109
+
110
+ return repos;
111
+ };
112
+
113
+ /**
114
+ * prompts the user for confirmation.
115
+ * @param msg the prompt message
116
+ * @returns promise that resolves to true if confirmed
117
+ */
118
+ const confirm = (msg: string): Promise<boolean> =>
119
+ new Promise((resolve) => {
120
+ const rl = createInterface({
121
+ input: process.stdin,
122
+ output: process.stdout,
123
+ });
124
+ rl.question(`${msg} [y/N] `, (answer) => {
125
+ rl.close();
126
+ resolve(answer.toLowerCase() === 'y');
127
+ });
128
+ });
129
+
130
+ /**
131
+ * handles the clean command.
132
+ * @param args parsed command arguments
133
+ */
134
+ export const handler = async (args: Args): Promise<void> => {
135
+ const reposDir = getReposDir();
136
+
137
+ // clean all
138
+ if (args.all) {
139
+ if (!existsSync(reposDir)) {
140
+ console.log('no cached repositories found');
141
+ return;
142
+ }
143
+ const size = getDirSize(reposDir);
144
+ console.log(`removing all cached repositories (${formatSize(size)})`);
145
+ await rm(reposDir, { recursive: true });
146
+ console.log('done');
147
+ return;
148
+ }
149
+
150
+ // clean specific remote
151
+ if (args.remote) {
152
+ const cachePath = getRepoCachePath(args.remote);
153
+ if (!cachePath) {
154
+ console.error(`error: invalid remote URL: ${args.remote}`);
155
+ process.exit(1);
156
+ }
157
+ if (!existsSync(cachePath)) {
158
+ console.error(`error: repository not cached: ${args.remote}`);
159
+ process.exit(1);
160
+ }
161
+ const size = getDirSize(cachePath);
162
+ console.log(`removing ${cachePath} (${formatSize(size)})`);
163
+ await rm(cachePath, { recursive: true });
164
+ console.log('done');
165
+ return;
166
+ }
167
+
168
+ // list repos and prompt for confirmation
169
+ const repos = listCachedRepos();
170
+ if (repos.length === 0) {
171
+ console.log('no cached repositories found');
172
+ return;
173
+ }
174
+
175
+ console.log('cached repositories:\n');
176
+ let totalSize = 0;
177
+ for (const repo of repos) {
178
+ console.log(` ${repo.displayPath.padEnd(50)} ${formatSize(repo.size)}`);
179
+ totalSize += repo.size;
180
+ }
181
+ console.log(`\n total: ${formatSize(totalSize)}`);
182
+ console.log();
183
+
184
+ const confirmed = await confirm('remove all cached repositories?');
185
+ if (confirmed) {
186
+ await rm(reposDir, { recursive: true });
187
+ console.log('done');
188
+ }
189
+ };
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { command, message, or } from '@optique/core';
4
+ import { run } from '@optique/run';
5
+
6
+ import * as ask from './commands/ask.ts';
7
+ import * as clean from './commands/clean.ts';
8
+
9
+ const parser = or(
10
+ command('ask', ask.schema, {
11
+ description: message`ask a question about a repository`,
12
+ }),
13
+ command('clean', clean.schema, {
14
+ description: message`remove cached repositories`,
15
+ }),
16
+ );
17
+
18
+ const result = run(parser, {
19
+ help: 'both',
20
+ version: { value: '0.1.0', mode: 'option' },
21
+ brief: message`ask questions about git repositories using Claude Code`,
22
+ });
23
+
24
+ switch (result.command) {
25
+ case 'ask':
26
+ await ask.handler(result);
27
+ break;
28
+ case 'clean':
29
+ await clean.handler(result);
30
+ break;
31
+ }
package/src/lib/git.ts ADDED
@@ -0,0 +1,106 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdir } from 'node:fs/promises';
4
+ import { dirname } from 'node:path';
5
+
6
+ /**
7
+ * executes a git command and returns the result.
8
+ * @param args git command arguments
9
+ * @param cwd working directory
10
+ * @returns promise that resolves when the command completes
11
+ */
12
+ const git = (args: string[], cwd?: string): Promise<void> =>
13
+ new Promise((resolve, reject) => {
14
+ const proc = spawn('git', args, {
15
+ cwd,
16
+ stdio: 'inherit',
17
+ });
18
+ proc.on('close', (code) => {
19
+ if (code === 0) {
20
+ resolve();
21
+ } else {
22
+ reject(new Error(`git ${args[0]} failed with code ${code}`));
23
+ }
24
+ });
25
+ proc.on('error', reject);
26
+ });
27
+
28
+ /**
29
+ * executes a git command and captures stdout.
30
+ * @param args git command arguments
31
+ * @param cwd working directory
32
+ * @returns promise that resolves with stdout
33
+ */
34
+ const gitOutput = (args: string[], cwd?: string): Promise<string> =>
35
+ new Promise((resolve, reject) => {
36
+ const proc = spawn('git', args, {
37
+ cwd,
38
+ stdio: ['inherit', 'pipe', 'inherit'],
39
+ });
40
+ let output = '';
41
+ proc.stdout!.on('data', (data: Buffer) => {
42
+ output += data.toString();
43
+ });
44
+ proc.on('close', (code) => {
45
+ if (code === 0) {
46
+ resolve(output.trim());
47
+ } else {
48
+ reject(new Error(`git ${args[0]} failed with code ${code}`));
49
+ }
50
+ });
51
+ proc.on('error', reject);
52
+ });
53
+
54
+ /**
55
+ * clones a repository to the cache path.
56
+ * @param remote the remote URL
57
+ * @param cachePath the local cache path
58
+ * @param branch optional branch to checkout
59
+ */
60
+ export const cloneRepo = async (remote: string, cachePath: string, branch?: string): Promise<void> => {
61
+ await mkdir(dirname(cachePath), { recursive: true });
62
+ const args = ['clone'];
63
+ if (branch) {
64
+ args.push('--branch', branch);
65
+ }
66
+ args.push(remote, cachePath);
67
+ await git(args);
68
+ };
69
+
70
+ /**
71
+ * updates an existing repository in the cache.
72
+ * discards any local modifications, staged changes, and untracked files.
73
+ * @param cachePath the local cache path
74
+ * @param branch optional branch to checkout (uses default branch if not specified)
75
+ */
76
+ export const updateRepo = async (cachePath: string, branch?: string): Promise<void> => {
77
+ await git(['fetch', 'origin'], cachePath);
78
+
79
+ // determine the branch to use
80
+ let targetBranch = branch;
81
+ if (!targetBranch) {
82
+ // get the default branch from origin
83
+ const defaultRef = await gitOutput(['symbolic-ref', 'refs/remotes/origin/HEAD'], cachePath);
84
+ targetBranch = defaultRef.replace('refs/remotes/origin/', '');
85
+ }
86
+
87
+ // discard all local changes before checkout to avoid conflicts
88
+ await git(['reset', '--hard', 'HEAD'], cachePath);
89
+ await git(['clean', '-fd'], cachePath);
90
+ await git(['checkout', '-f', targetBranch], cachePath);
91
+ await git(['reset', '--hard', `origin/${targetBranch}`], cachePath);
92
+ };
93
+
94
+ /**
95
+ * ensures a repository is cloned and up-to-date.
96
+ * @param remote the remote URL
97
+ * @param cachePath the local cache path
98
+ * @param branch optional branch to checkout
99
+ */
100
+ export const ensureRepo = async (remote: string, cachePath: string, branch?: string): Promise<void> => {
101
+ if (existsSync(cachePath)) {
102
+ await updateRepo(cachePath, branch);
103
+ } else {
104
+ await cloneRepo(remote, cachePath, branch);
105
+ }
106
+ };
@@ -0,0 +1,92 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * returns the cache directory for cgr.
6
+ * uses `$XDG_CACHE_HOME/cgr` if set, otherwise falls back to:
7
+ * - `~/.cache/cgr` on linux
8
+ * - `~/Library/Caches/cgr` on macos
9
+ * @returns the cache directory path
10
+ */
11
+ export const getCacheDir = (): string => {
12
+ const xdgCache = process.env['XDG_CACHE_HOME'];
13
+ if (xdgCache) {
14
+ return join(xdgCache, 'cgr');
15
+ }
16
+ const home = homedir();
17
+ if (process.platform === 'darwin') {
18
+ return join(home, 'Library', 'Caches', 'cgr');
19
+ }
20
+ return join(home, '.cache', 'cgr');
21
+ };
22
+
23
+ /**
24
+ * returns the repos directory within the cache.
25
+ * @returns the repos directory path
26
+ */
27
+ export const getReposDir = (): string => join(getCacheDir(), 'repos');
28
+
29
+ /**
30
+ * parses a git remote URL and extracts host, owner, and repo.
31
+ * supports HTTP(S), SSH, and bare URLs (assumes HTTPS).
32
+ * @param remote the remote URL to parse
33
+ * @returns parsed components or null if invalid
34
+ */
35
+ export const parseRemote = (remote: string): { host: string; owner: string; repo: string } | null => {
36
+ // HTTP(S): https://github.com/user/repo or https://github.com/user/repo.git
37
+ const httpMatch = remote.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/);
38
+ if (httpMatch) {
39
+ return {
40
+ host: httpMatch[1]!,
41
+ owner: httpMatch[2]!,
42
+ repo: httpMatch[3]!,
43
+ };
44
+ }
45
+
46
+ // SSH: git@github.com:user/repo.git or git@github.com:user/repo
47
+ const sshMatch = remote.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/);
48
+ if (sshMatch) {
49
+ return {
50
+ host: sshMatch[1]!,
51
+ owner: sshMatch[2]!,
52
+ repo: sshMatch[3]!,
53
+ };
54
+ }
55
+
56
+ // bare URL: github.com/user/repo (assumes HTTPS)
57
+ const bareMatch = remote.match(/^([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?$/);
58
+ if (bareMatch) {
59
+ return {
60
+ host: bareMatch[1]!,
61
+ owner: bareMatch[2]!,
62
+ repo: bareMatch[3]!,
63
+ };
64
+ }
65
+
66
+ return null;
67
+ };
68
+
69
+ /**
70
+ * normalizes a remote URL by prepending https:// if no protocol is present.
71
+ * @param remote the remote URL to normalize
72
+ * @returns normalized URL with protocol
73
+ */
74
+ export const normalizeRemote = (remote: string): string => {
75
+ if (remote.startsWith('https://') || remote.startsWith('http://') || remote.startsWith('git@')) {
76
+ return remote;
77
+ }
78
+ return `https://${remote}`;
79
+ };
80
+
81
+ /**
82
+ * returns the cache path for a specific repository.
83
+ * @param remote the remote URL
84
+ * @returns the cache path or null if the remote URL is invalid
85
+ */
86
+ export const getRepoCachePath = (remote: string): string | null => {
87
+ const parsed = parseRemote(remote);
88
+ if (!parsed) {
89
+ return null;
90
+ }
91
+ return join(getReposDir(), parsed.host, parsed.owner, parsed.repo);
92
+ };