@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 +14 -0
- package/README.md +91 -0
- package/dist/assets/settings.json +48 -0
- package/dist/assets/system-prompt.md +46 -0
- package/dist/index.mjs +392 -0
- package/package.json +42 -0
- package/src/assets/settings.json +48 -0
- package/src/assets/system-prompt.md +46 -0
- package/src/commands/ask.ts +104 -0
- package/src/commands/clean.ts +189 -0
- package/src/index.ts +31 -0
- package/src/lib/git.ts +106 -0
- package/src/lib/paths.ts +92 -0
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
|
+
};
|
package/src/lib/paths.ts
ADDED
|
@@ -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
|
+
};
|