@bobbyg603/mog 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/README.md +146 -0
- package/package.json +32 -0
- package/src/github.ts +107 -0
- package/src/index.ts +158 -0
- package/src/log.ts +18 -0
- package/src/sandbox.ts +131 -0
- package/src/worktree.ts +91 -0
package/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
<img width="300" alt="claude moggin" src="https://github.com/user-attachments/assets/089db43c-7381-4e62-87bc-af2e7cd0129f" />
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
# mog — Sandboxed Claude Issue Mogging
|
|
5
|
+
|
|
6
|
+
One command to go from GitHub issue to pull request, powered by Claude Code running in a Docker sandbox.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
mog workingdevshero/automate-it 123
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
That's it. `mog` will:
|
|
13
|
+
|
|
14
|
+
1. Fetch the issue title, description, and labels via `gh` CLI
|
|
15
|
+
2. Create a git worktree on a clean branch (`123-fix-broken-login`)
|
|
16
|
+
3. Run Claude Code inside a persistent Docker sandbox (microVM) with `--dangerously-skip-permissions`
|
|
17
|
+
4. Push the branch and open a PR that `Closes #123`
|
|
18
|
+
|
|
19
|
+
## Prerequisites
|
|
20
|
+
|
|
21
|
+
- **macOS or Windows** (Docker sandbox microVMs require Docker Desktop)
|
|
22
|
+
- **Docker Desktop** — running and up to date (must support `docker sandbox`)
|
|
23
|
+
- **Bun** — install from [bun.sh](https://bun.sh)
|
|
24
|
+
- **GitHub CLI** (`gh`) — authenticated via `gh auth login`
|
|
25
|
+
- **Git** with push access to your target repos
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bun install -g @bobbyg603/mog
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
# 1. One-time setup: create sandbox & authenticate
|
|
37
|
+
mog init
|
|
38
|
+
# This launches Claude Code — use /login to authenticate with your Max subscription
|
|
39
|
+
# Once logged in, type /exit to return
|
|
40
|
+
|
|
41
|
+
# 2. Start mogging issues
|
|
42
|
+
mog workingdevshero/automate-it 123
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## How authentication works
|
|
46
|
+
|
|
47
|
+
`mog init` creates a **persistent** Docker sandbox named `mog`. When it launches, you authenticate once using `/login` inside the Claude Code session. Your auth persists in the sandbox across all future `mog` runs — you never need to login again.
|
|
48
|
+
|
|
49
|
+
If your session ever expires, just run `mog init` again to re-authenticate.
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# One-time setup
|
|
55
|
+
mog init
|
|
56
|
+
|
|
57
|
+
# Basic usage
|
|
58
|
+
mog owner/repo issue_number
|
|
59
|
+
|
|
60
|
+
# Examples
|
|
61
|
+
mog workingdevshero/automate-it 123
|
|
62
|
+
mog sparx-tech/hub-firmware 45
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## How it works
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
┌──────────────────────────────────────────────────────────┐
|
|
69
|
+
│ Host machine │
|
|
70
|
+
│ │
|
|
71
|
+
│ 1. gh issue view #123 → fetch title, body, labels │
|
|
72
|
+
│ 2. git worktree add → clean branch from default branch │
|
|
73
|
+
│ │
|
|
74
|
+
│ ┌────────────────────────────────────────────────────┐ │
|
|
75
|
+
│ │ Docker sandbox "mog" (persistent microVM) │ │
|
|
76
|
+
│ │ │ │
|
|
77
|
+
│ │ • ~/mog-repos mounted as workspace │ │
|
|
78
|
+
│ │ • Auth persists across runs (login once) │ │
|
|
79
|
+
│ │ • Isolated from host (own Docker daemon) │ │
|
|
80
|
+
│ │ • claude --dangerously-skip-permissions -p "..." │ │
|
|
81
|
+
│ │ • Reads code, implements fix, commits │ │
|
|
82
|
+
│ └────────────────────────────────────────────────────┘ │
|
|
83
|
+
│ │
|
|
84
|
+
│ 3. git push origin branch │
|
|
85
|
+
│ 4. gh pr create --body "Closes #123" │
|
|
86
|
+
└──────────────────────────────────────────────────────────┘
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Configuration
|
|
90
|
+
|
|
91
|
+
| Environment Variable | Default | Description |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| `MOG_REPOS_DIR` | `~/mog-repos` | Where repos are cloned and worktrees created (also the sandbox workspace) |
|
|
94
|
+
|
|
95
|
+
## Worktree management
|
|
96
|
+
|
|
97
|
+
`mog` uses bare clones and git worktrees so you can run multiple issues concurrently without conflicts:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
~/mog-repos/
|
|
101
|
+
owner/
|
|
102
|
+
repo/ ← bare clone (or full clone)
|
|
103
|
+
repo-worktrees/
|
|
104
|
+
123-fix-broken-login/ ← worktree for issue #123
|
|
105
|
+
456-add-dark-mode/ ← worktree for issue #456
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Clean up when done:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
cd ~/mog-repos/owner/repo
|
|
112
|
+
git worktree remove ../repo-worktrees/123-fix-broken-login
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Security notes
|
|
116
|
+
|
|
117
|
+
- Claude Code runs inside a **microVM** via Docker sandbox — it has its own Docker daemon and cannot access your host system, terminal, or files outside `~/mog-repos`.
|
|
118
|
+
- `--dangerously-skip-permissions` is safe here because the sandbox provides the isolation boundary.
|
|
119
|
+
- `gh` credentials stay on your host — the sandbox has **no access** to your GitHub token. Pushing and PR creation happen on the host after Claude finishes.
|
|
120
|
+
- The sandbox has network access (required for the Anthropic API).
|
|
121
|
+
|
|
122
|
+
## Troubleshooting
|
|
123
|
+
|
|
124
|
+
**"Docker sandbox not available"** — Make sure Docker Desktop is running and up to date.
|
|
125
|
+
|
|
126
|
+
**"Sandbox 'mog' not found"** — Run `mog init` first to create the sandbox and authenticate.
|
|
127
|
+
|
|
128
|
+
**"Failed to fetch issue"** — Check `gh auth status` and verify the repo/issue exist.
|
|
129
|
+
|
|
130
|
+
**"No changes detected"** — Claude may have struggled with the issue. Check the worktree manually, or re-run with a more detailed issue description.
|
|
131
|
+
|
|
132
|
+
**"Failed to push"** — Ensure `gh` is authenticated with push access. Try `gh auth login` and select HTTPS.
|
|
133
|
+
|
|
134
|
+
## Managing the sandbox
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
# List sandboxes
|
|
138
|
+
docker sandbox ls
|
|
139
|
+
|
|
140
|
+
# Stop the sandbox (preserves auth)
|
|
141
|
+
docker sandbox stop mog
|
|
142
|
+
|
|
143
|
+
# Remove and recreate (you'll need to /login again)
|
|
144
|
+
docker sandbox rm mog
|
|
145
|
+
mog init
|
|
146
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bobbyg603/mog",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "One command to go from GitHub issue to pull request, powered by Claude Code in a Docker sandbox",
|
|
5
|
+
"module": "src/index.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mog": "src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/workingdevshero/mog.git"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"claude",
|
|
19
|
+
"claude-code",
|
|
20
|
+
"github",
|
|
21
|
+
"automation",
|
|
22
|
+
"docker",
|
|
23
|
+
"sandbox"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/bun": "latest"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"typescript": "^5"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/github.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { log } from "./log";
|
|
2
|
+
|
|
3
|
+
export interface Issue {
|
|
4
|
+
title: string;
|
|
5
|
+
body: string;
|
|
6
|
+
labels: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function fetchIssue(repo: string, issueNum: string): Promise<Issue> {
|
|
10
|
+
log.info(`Fetching issue #${issueNum} from ${repo}...`);
|
|
11
|
+
|
|
12
|
+
const proc = Bun.spawnSync([
|
|
13
|
+
"gh", "issue", "view", issueNum,
|
|
14
|
+
"--repo", repo,
|
|
15
|
+
"--json", "title,body,labels",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
if (proc.exitCode !== 0) {
|
|
19
|
+
log.die(`Failed to fetch issue #${issueNum}. Check repo name and issue number.`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const json = JSON.parse(proc.stdout.toString());
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
title: json.title,
|
|
26
|
+
body: json.body || "No description provided.",
|
|
27
|
+
labels: json.labels?.map((l: { name: string }) => l.name).join(", ") || "none",
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function pushAndCreatePR(
|
|
32
|
+
repo: string,
|
|
33
|
+
worktreeDir: string,
|
|
34
|
+
branchName: string,
|
|
35
|
+
defaultBranch: string,
|
|
36
|
+
issueNum: string,
|
|
37
|
+
issue: Issue
|
|
38
|
+
) {
|
|
39
|
+
// Check for unpushed commits or uncommitted changes
|
|
40
|
+
const unpushed = Bun.spawnSync(["git", "log", `origin/${defaultBranch}..HEAD`, "--oneline"], { cwd: worktreeDir });
|
|
41
|
+
const diffCheck = Bun.spawnSync(["git", "diff", "--quiet"], { cwd: worktreeDir });
|
|
42
|
+
const cachedCheck = Bun.spawnSync(["git", "diff", "--cached", "--quiet"], { cwd: worktreeDir });
|
|
43
|
+
|
|
44
|
+
const hasUnpushed = unpushed.stdout.toString().trim().length > 0;
|
|
45
|
+
const hasUncommitted = diffCheck.exitCode !== 0 || cachedCheck.exitCode !== 0;
|
|
46
|
+
|
|
47
|
+
if (!hasUnpushed && !hasUncommitted) {
|
|
48
|
+
log.warn("No changes detected. Claude may not have made any modifications.");
|
|
49
|
+
log.warn(`Worktree: ${worktreeDir}`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Stage any unstaged changes Claude might have left
|
|
54
|
+
if (hasUncommitted) {
|
|
55
|
+
log.info("Staging uncommitted changes...");
|
|
56
|
+
Bun.spawnSync(["git", "add", "-A"], { cwd: worktreeDir });
|
|
57
|
+
Bun.spawnSync(["git", "commit", "-m", `fix: address issue #${issueNum} - ${issue.title}`], { cwd: worktreeDir });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Push
|
|
61
|
+
log.info(`Pushing branch '${branchName}' to origin...`);
|
|
62
|
+
const push = Bun.spawnSync(["git", "push", "-u", "origin", branchName], { cwd: worktreeDir });
|
|
63
|
+
if (push.exitCode !== 0) {
|
|
64
|
+
log.die("Failed to push. Check your git credentials.");
|
|
65
|
+
}
|
|
66
|
+
log.ok("Branch pushed.");
|
|
67
|
+
|
|
68
|
+
// Create PR
|
|
69
|
+
log.info("Opening pull request...");
|
|
70
|
+
|
|
71
|
+
const prBody = `## Summary
|
|
72
|
+
|
|
73
|
+
Closes #${issueNum}
|
|
74
|
+
|
|
75
|
+
This PR was generated by [mog](https://github.com/workingdevshero/mog) using Claude Code in a Docker sandbox.
|
|
76
|
+
|
|
77
|
+
### Issue: ${issue.title}
|
|
78
|
+
|
|
79
|
+
${issue.body}
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
*Please review the changes carefully before merging.*`;
|
|
83
|
+
|
|
84
|
+
const prTitle = `fix: ${issue.title} [#${issueNum}]`;
|
|
85
|
+
|
|
86
|
+
const pr = Bun.spawnSync([
|
|
87
|
+
"gh", "pr", "create",
|
|
88
|
+
"--repo", repo,
|
|
89
|
+
"--base", defaultBranch,
|
|
90
|
+
"--head", branchName,
|
|
91
|
+
"--title", prTitle,
|
|
92
|
+
"--body", prBody,
|
|
93
|
+
], { cwd: worktreeDir });
|
|
94
|
+
|
|
95
|
+
if (pr.exitCode !== 0) {
|
|
96
|
+
log.die("Failed to create PR. You may need to push first or the PR may already exist.");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const prUrl = pr.stdout.toString().trim();
|
|
100
|
+
log.ok("Pull request created!");
|
|
101
|
+
console.log(`\x1b[0;32m${prUrl}\x1b[0m`);
|
|
102
|
+
|
|
103
|
+
console.log();
|
|
104
|
+
log.ok(`All done! Issue #${issueNum} → Branch '${branchName}' → PR opened.`);
|
|
105
|
+
log.info(`Worktree: ${worktreeDir}`);
|
|
106
|
+
log.info(`To clean up the worktree later: git worktree remove ${worktreeDir}`);
|
|
107
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { fetchIssue } from "./github";
|
|
4
|
+
import { ensureRepo, createWorktree } from "./worktree";
|
|
5
|
+
import { ensureSandbox, runClaude } from "./sandbox";
|
|
6
|
+
import { pushAndCreatePR } from "./github";
|
|
7
|
+
import { log } from "./log";
|
|
8
|
+
|
|
9
|
+
const SANDBOX_NAME = "mog";
|
|
10
|
+
|
|
11
|
+
async function init() {
|
|
12
|
+
log.info("Initializing mog sandbox...");
|
|
13
|
+
|
|
14
|
+
const reposDir = getReposDir();
|
|
15
|
+
const exists = await sandboxExists(SANDBOX_NAME);
|
|
16
|
+
|
|
17
|
+
if (exists) {
|
|
18
|
+
log.warn(`Sandbox '${SANDBOX_NAME}' already exists.`);
|
|
19
|
+
log.info("Launching sandbox so you can authenticate with /login...");
|
|
20
|
+
} else {
|
|
21
|
+
log.info(`Creating persistent sandbox '${SANDBOX_NAME}'...`);
|
|
22
|
+
log.info(`Workspace: ${reposDir}`);
|
|
23
|
+
await run(["docker", "sandbox", "create", "--name", SANDBOX_NAME, "claude", reposDir]);
|
|
24
|
+
log.ok("Sandbox created.");
|
|
25
|
+
console.log();
|
|
26
|
+
log.info("Launching sandbox — authenticate with /login to use your Max subscription.");
|
|
27
|
+
log.info("Once logged in, type /exit or Ctrl+C to return.");
|
|
28
|
+
console.log();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const proc = Bun.spawn(["docker", "sandbox", "run", SANDBOX_NAME], {
|
|
32
|
+
stdin: "inherit",
|
|
33
|
+
stdout: "inherit",
|
|
34
|
+
stderr: "inherit",
|
|
35
|
+
});
|
|
36
|
+
await proc.exited;
|
|
37
|
+
|
|
38
|
+
log.ok("mog is ready. Run: mog <owner/repo> <issue_number>");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function main() {
|
|
42
|
+
const args = process.argv.slice(2);
|
|
43
|
+
|
|
44
|
+
// Validate dependencies
|
|
45
|
+
for (const cmd of ["gh", "git", "docker"]) {
|
|
46
|
+
const which = Bun.spawnSync(["which", cmd]);
|
|
47
|
+
if (which.exitCode !== 0) {
|
|
48
|
+
log.die(`Required command not found: ${cmd}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Check docker sandbox is available
|
|
53
|
+
const sandboxCheck = Bun.spawnSync(["docker", "sandbox", "ls"]);
|
|
54
|
+
if (sandboxCheck.exitCode !== 0) {
|
|
55
|
+
log.die("Docker sandbox not available. Make sure Docker Desktop is running and up to date.");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (args[0] === "init") {
|
|
59
|
+
await init();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (args.length < 2) {
|
|
64
|
+
console.log("Usage:");
|
|
65
|
+
console.log(" mog init — one-time setup (create sandbox & login)");
|
|
66
|
+
console.log(" mog <owner/repo> <issue_num> — fetch issue, run Claude, open PR");
|
|
67
|
+
console.log();
|
|
68
|
+
console.log("Example:");
|
|
69
|
+
console.log(" mog init");
|
|
70
|
+
console.log(" mog workingdevshero/automate-it 123");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const repo = args[0];
|
|
75
|
+
const issueNum = args[1];
|
|
76
|
+
const [owner, repoName] = repo.split("/");
|
|
77
|
+
|
|
78
|
+
if (!owner || !repoName) {
|
|
79
|
+
log.die("Invalid repo format. Use: owner/repo");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Verify sandbox exists
|
|
83
|
+
if (!(await sandboxExists(SANDBOX_NAME))) {
|
|
84
|
+
log.die(`Sandbox '${SANDBOX_NAME}' not found. Run 'mog init' first.`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Fetch issue
|
|
88
|
+
const issue = await fetchIssue(repo, issueNum);
|
|
89
|
+
log.ok(`Issue: ${issue.title}`);
|
|
90
|
+
|
|
91
|
+
// Ensure repo & worktree
|
|
92
|
+
const reposDir = getReposDir();
|
|
93
|
+
const { defaultBranch } = await ensureRepo(repo, owner, repoName, reposDir);
|
|
94
|
+
log.info(`Default branch: ${defaultBranch}`);
|
|
95
|
+
|
|
96
|
+
const { worktreeDir, branchName } = await createWorktree(
|
|
97
|
+
reposDir, owner, repoName, defaultBranch, issueNum, issue.title
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Build prompt
|
|
101
|
+
const prompt = buildPrompt(repo, issueNum, issue);
|
|
102
|
+
|
|
103
|
+
// Run Claude in sandbox
|
|
104
|
+
log.info("Launching Claude Code in sandbox...");
|
|
105
|
+
log.info(`Branch: ${branchName}`);
|
|
106
|
+
log.info(`Worktree: ${worktreeDir}`);
|
|
107
|
+
console.log();
|
|
108
|
+
|
|
109
|
+
await runClaude(SANDBOX_NAME, worktreeDir, prompt);
|
|
110
|
+
|
|
111
|
+
// Push and create PR
|
|
112
|
+
await pushAndCreatePR(repo, worktreeDir, branchName, defaultBranch, issueNum, issue);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getReposDir(): string {
|
|
116
|
+
return process.env.MOG_REPOS_DIR || `${process.env.HOME}/mog-repos`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function sandboxExists(name: string): Promise<boolean> {
|
|
120
|
+
const result = Bun.spawnSync(["docker", "sandbox", "ls"]);
|
|
121
|
+
const output = result.stdout.toString();
|
|
122
|
+
return output.includes(name);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function run(cmd: string[]): Promise<string> {
|
|
126
|
+
const proc = Bun.spawnSync(cmd);
|
|
127
|
+
if (proc.exitCode !== 0) {
|
|
128
|
+
throw new Error(`Command failed: ${cmd.join(" ")}\n${proc.stderr.toString()}`);
|
|
129
|
+
}
|
|
130
|
+
return proc.stdout.toString().trim();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function buildPrompt(repo: string, issueNum: string, issue: { title: string; body: string; labels: string }): string {
|
|
134
|
+
return `You are working on GitHub issue #${issueNum} for the repository ${repo}.
|
|
135
|
+
|
|
136
|
+
## Issue: ${issue.title}
|
|
137
|
+
|
|
138
|
+
### Description
|
|
139
|
+
${issue.body}
|
|
140
|
+
|
|
141
|
+
### Labels
|
|
142
|
+
${issue.labels}
|
|
143
|
+
|
|
144
|
+
## Instructions
|
|
145
|
+
1. Read and understand the codebase structure first.
|
|
146
|
+
2. Implement the changes described in the issue above.
|
|
147
|
+
3. Write clean, well-documented code that follows the existing project conventions.
|
|
148
|
+
4. Add or update tests if applicable.
|
|
149
|
+
5. Make sure the code builds/lints without errors if there's a build system.
|
|
150
|
+
6. Commit your changes with a clear commit message referencing issue #${issueNum}.
|
|
151
|
+
|
|
152
|
+
When you are done, make a single commit (or a small, logical set of commits) with
|
|
153
|
+
a message like: "fix: <short description> (#${issueNum})"`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
main().catch((err) => {
|
|
157
|
+
log.die(err.message);
|
|
158
|
+
});
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const RED = "\x1b[0;31m";
|
|
2
|
+
const GREEN = "\x1b[0;32m";
|
|
3
|
+
const YELLOW = "\x1b[1;33m";
|
|
4
|
+
const CYAN = "\x1b[0;36m";
|
|
5
|
+
const NC = "\x1b[0m";
|
|
6
|
+
|
|
7
|
+
export const log = {
|
|
8
|
+
info: (msg: string) => console.log(`${CYAN}[mog]${NC} ${msg}`),
|
|
9
|
+
ok: (msg: string) => console.log(`${GREEN}[mog]${NC} ${msg}`),
|
|
10
|
+
warn: (msg: string) => console.log(`${YELLOW}[mog]${NC} ${msg}`),
|
|
11
|
+
err: (msg: string) => console.error(`${RED}[mog]${NC} ${msg}`),
|
|
12
|
+
die: (msg: string) => {
|
|
13
|
+
log.err(msg);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
},
|
|
16
|
+
tool: (name: string, detail: string) => console.log(`${CYAN}[${name}]${NC} ${detail}`),
|
|
17
|
+
done: (msg: string) => console.log(`\n${GREEN}[done]${NC} ${msg}`),
|
|
18
|
+
};
|
package/src/sandbox.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { log } from "./log";
|
|
2
|
+
|
|
3
|
+
interface StreamEvent {
|
|
4
|
+
type: string;
|
|
5
|
+
subtype?: string;
|
|
6
|
+
message?: {
|
|
7
|
+
content?: Array<{
|
|
8
|
+
type: string;
|
|
9
|
+
text?: string;
|
|
10
|
+
name?: string;
|
|
11
|
+
input?: Record<string, unknown>;
|
|
12
|
+
}>;
|
|
13
|
+
};
|
|
14
|
+
result?: string;
|
|
15
|
+
is_error?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function ensureSandbox(name: string, reposDir: string): Promise<void> {
|
|
19
|
+
const ls = Bun.spawnSync(["docker", "sandbox", "ls"]);
|
|
20
|
+
if (ls.stdout.toString().includes(name)) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
log.info(`Creating persistent sandbox '${name}'...`);
|
|
25
|
+
const create = Bun.spawnSync(["docker", "sandbox", "create", "--name", name, "claude", reposDir]);
|
|
26
|
+
if (create.exitCode !== 0) {
|
|
27
|
+
log.die(`Failed to create sandbox: ${create.stderr.toString()}`);
|
|
28
|
+
}
|
|
29
|
+
log.ok("Sandbox created.");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function runClaude(sandboxName: string, worktreeDir: string, prompt: string): Promise<void> {
|
|
33
|
+
const proc = Bun.spawn([
|
|
34
|
+
"docker", "sandbox", "exec",
|
|
35
|
+
"-w", worktreeDir,
|
|
36
|
+
sandboxName,
|
|
37
|
+
"claude", "--dangerously-skip-permissions",
|
|
38
|
+
"--verbose", "--output-format", "stream-json",
|
|
39
|
+
"-p", prompt,
|
|
40
|
+
], {
|
|
41
|
+
stdout: "pipe",
|
|
42
|
+
stderr: "pipe",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Stream and parse JSON lines from stdout
|
|
46
|
+
const reader = proc.stdout.getReader();
|
|
47
|
+
const decoder = new TextDecoder();
|
|
48
|
+
let buffer = "";
|
|
49
|
+
|
|
50
|
+
while (true) {
|
|
51
|
+
const { done, value } = await reader.read();
|
|
52
|
+
if (done) break;
|
|
53
|
+
|
|
54
|
+
buffer += decoder.decode(value, { stream: true });
|
|
55
|
+
|
|
56
|
+
// Process complete lines
|
|
57
|
+
const lines = buffer.split("\n");
|
|
58
|
+
buffer = lines.pop() || "";
|
|
59
|
+
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
if (!line.trim()) continue;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const event: StreamEvent = JSON.parse(line);
|
|
65
|
+
printEvent(event);
|
|
66
|
+
} catch {
|
|
67
|
+
// Skip malformed JSON lines
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Process any remaining buffer
|
|
73
|
+
if (buffer.trim()) {
|
|
74
|
+
try {
|
|
75
|
+
const event: StreamEvent = JSON.parse(buffer);
|
|
76
|
+
printEvent(event);
|
|
77
|
+
} catch {
|
|
78
|
+
// Skip
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const exitCode = await proc.exited;
|
|
83
|
+
|
|
84
|
+
if (exitCode !== 0) {
|
|
85
|
+
const stderr = await new Response(proc.stderr).text();
|
|
86
|
+
if (stderr.trim()) {
|
|
87
|
+
log.warn(stderr.trim());
|
|
88
|
+
}
|
|
89
|
+
log.warn(`Claude Code exited with code ${exitCode}.`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function printEvent(event: StreamEvent): void {
|
|
94
|
+
if (event.type === "assistant" && event.message?.content) {
|
|
95
|
+
for (const block of event.message.content) {
|
|
96
|
+
if (block.type === "text" && block.text) {
|
|
97
|
+
console.log(block.text);
|
|
98
|
+
} else if (block.type === "tool_use" && block.name) {
|
|
99
|
+
const detail = getToolDetail(block.name, block.input);
|
|
100
|
+
log.tool(block.name, detail);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} else if (event.type === "result") {
|
|
104
|
+
if (event.is_error) {
|
|
105
|
+
log.err(event.result || "Unknown error");
|
|
106
|
+
} else if (event.result) {
|
|
107
|
+
log.done(event.result.slice(0, 200));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getToolDetail(name: string, input?: Record<string, unknown>): string {
|
|
113
|
+
if (!input) return "";
|
|
114
|
+
|
|
115
|
+
switch (name) {
|
|
116
|
+
case "Read":
|
|
117
|
+
return String(input.file_path || "");
|
|
118
|
+
case "Edit":
|
|
119
|
+
return String(input.file_path || "");
|
|
120
|
+
case "Write":
|
|
121
|
+
return String(input.file_path || "");
|
|
122
|
+
case "Bash":
|
|
123
|
+
return String(input.description || input.command || "");
|
|
124
|
+
case "Glob":
|
|
125
|
+
return String(input.pattern || "");
|
|
126
|
+
case "Grep":
|
|
127
|
+
return String(input.pattern || "");
|
|
128
|
+
default:
|
|
129
|
+
return JSON.stringify(input).slice(0, 120);
|
|
130
|
+
}
|
|
131
|
+
}
|
package/src/worktree.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { log } from "./log";
|
|
2
|
+
|
|
3
|
+
export async function ensureRepo(
|
|
4
|
+
repo: string,
|
|
5
|
+
owner: string,
|
|
6
|
+
repoName: string,
|
|
7
|
+
reposDir: string
|
|
8
|
+
): Promise<{ defaultBranch: string }> {
|
|
9
|
+
const repoDir = `${reposDir}/${owner}/${repoName}`;
|
|
10
|
+
const fs = await import("fs");
|
|
11
|
+
|
|
12
|
+
if (!fs.existsSync(repoDir)) {
|
|
13
|
+
log.info(`Cloning ${repo} into ${repoDir}...`);
|
|
14
|
+
fs.mkdirSync(`${reposDir}/${owner}`, { recursive: true });
|
|
15
|
+
|
|
16
|
+
const clone = Bun.spawnSync(["gh", "repo", "clone", repo, repoDir], {
|
|
17
|
+
stdout: "inherit",
|
|
18
|
+
stderr: "inherit",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
if (clone.exitCode !== 0) {
|
|
22
|
+
log.die(`Failed to clone ${repo}.`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Get default branch
|
|
27
|
+
const branchProc = Bun.spawnSync([
|
|
28
|
+
"gh", "repo", "view", repo,
|
|
29
|
+
"--json", "defaultBranchRef",
|
|
30
|
+
"--jq", ".defaultBranchRef.name",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
if (branchProc.exitCode !== 0) {
|
|
34
|
+
log.die("Failed to determine default branch.");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { defaultBranch: branchProc.stdout.toString().trim() };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function createWorktree(
|
|
41
|
+
reposDir: string,
|
|
42
|
+
owner: string,
|
|
43
|
+
repoName: string,
|
|
44
|
+
defaultBranch: string,
|
|
45
|
+
issueNum: string,
|
|
46
|
+
issueTitle: string
|
|
47
|
+
): Promise<{ worktreeDir: string; branchName: string }> {
|
|
48
|
+
const safeTitle = issueTitle
|
|
49
|
+
.toLowerCase()
|
|
50
|
+
.replace(/[^a-z0-9]/g, "-")
|
|
51
|
+
.replace(/-+/g, "-")
|
|
52
|
+
.replace(/^-|-$/g, "")
|
|
53
|
+
.slice(0, 50);
|
|
54
|
+
|
|
55
|
+
const branchName = `${issueNum}-${safeTitle}`;
|
|
56
|
+
const repoDir = `${reposDir}/${owner}/${repoName}`;
|
|
57
|
+
const worktreeDir = `${reposDir}/${owner}/${repoName}-worktrees/${branchName}`;
|
|
58
|
+
|
|
59
|
+
const fs = await import("fs");
|
|
60
|
+
|
|
61
|
+
if (fs.existsSync(worktreeDir)) {
|
|
62
|
+
log.warn(`Worktree already exists at ${worktreeDir}, reusing.`);
|
|
63
|
+
return { worktreeDir, branchName };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
log.info(`Creating worktree for branch '${branchName}'...`);
|
|
67
|
+
|
|
68
|
+
// Fetch latest
|
|
69
|
+
Bun.spawnSync(["git", "fetch", "origin", defaultBranch], { cwd: repoDir });
|
|
70
|
+
|
|
71
|
+
// Try creating branch + worktree
|
|
72
|
+
const result = Bun.spawnSync(
|
|
73
|
+
["git", "worktree", "add", "-b", branchName, worktreeDir, `origin/${defaultBranch}`],
|
|
74
|
+
{ cwd: repoDir }
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
if (result.exitCode !== 0) {
|
|
78
|
+
// Try using existing branch
|
|
79
|
+
const fallback = Bun.spawnSync(
|
|
80
|
+
["git", "worktree", "add", worktreeDir, branchName],
|
|
81
|
+
{ cwd: repoDir }
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
if (fallback.exitCode !== 0) {
|
|
85
|
+
log.die(`Failed to create worktree. Branch '${branchName}' may already exist.`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
log.ok(`Worktree created at ${worktreeDir}`);
|
|
90
|
+
return { worktreeDir, branchName };
|
|
91
|
+
}
|