@bobbyg603/mog 0.2.0 → 1.0.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 +1 -0
- package/package.json +4 -1
- package/src/github.ts +14 -6
- package/src/index.ts +30 -35
- package/src/sandbox.ts +27 -20
- package/src/worktree.ts +5 -7
package/README.md
CHANGED
|
@@ -91,6 +91,7 @@ mog sparx-tech/hub-firmware 45
|
|
|
91
91
|
| Environment Variable | Default | Description |
|
|
92
92
|
|---|---|---|
|
|
93
93
|
| `MOG_REPOS_DIR` | `~/mog-repos` | Where repos are cloned and worktrees created (also the sandbox workspace) |
|
|
94
|
+
| `MOG_MAX_CONTINUATIONS` | `5` | Max times Claude is re-prompted if it stops without committing |
|
|
94
95
|
|
|
95
96
|
## Worktree management
|
|
96
97
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bobbyg603/mog",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "One command to go from GitHub issue to pull request, powered by Claude Code in a Docker sandbox",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
"docker",
|
|
23
23
|
"sandbox"
|
|
24
24
|
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"dev:reset": "docker sandbox rm mog && docker rmi mog-template:latest 2>/dev/null; echo 'Sandbox removed. Run mog init to recreate.'"
|
|
27
|
+
},
|
|
25
28
|
"license": "MIT",
|
|
26
29
|
"devDependencies": {
|
|
27
30
|
"@types/bun": "latest"
|
package/src/github.ts
CHANGED
|
@@ -6,7 +6,7 @@ export interface Issue {
|
|
|
6
6
|
labels: string;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
export
|
|
9
|
+
export function fetchIssue(repo: string, issueNum: string): Issue {
|
|
10
10
|
log.info(`Fetching issue #${issueNum} from ${repo}...`);
|
|
11
11
|
|
|
12
12
|
const proc = Bun.spawnSync([
|
|
@@ -28,14 +28,14 @@ export async function fetchIssue(repo: string, issueNum: string): Promise<Issue>
|
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export
|
|
31
|
+
export function pushAndCreatePR(
|
|
32
32
|
repo: string,
|
|
33
33
|
worktreeDir: string,
|
|
34
34
|
branchName: string,
|
|
35
35
|
defaultBranch: string,
|
|
36
36
|
issueNum: string,
|
|
37
37
|
issue: Issue
|
|
38
|
-
) {
|
|
38
|
+
): void {
|
|
39
39
|
// Check for unpushed commits or uncommitted changes
|
|
40
40
|
const unpushed = Bun.spawnSync(["git", "log", `origin/${defaultBranch}..HEAD`, "--oneline"], { cwd: worktreeDir });
|
|
41
41
|
const diffCheck = Bun.spawnSync(["git", "diff", "--quiet"], { cwd: worktreeDir });
|
|
@@ -53,8 +53,15 @@ export async function pushAndCreatePR(
|
|
|
53
53
|
// Stage any unstaged changes Claude might have left
|
|
54
54
|
if (hasUncommitted) {
|
|
55
55
|
log.info("Staging uncommitted changes...");
|
|
56
|
-
Bun.spawnSync(["git", "add", "-A"], { cwd: worktreeDir });
|
|
57
|
-
|
|
56
|
+
const addResult = Bun.spawnSync(["git", "add", "-A"], { cwd: worktreeDir });
|
|
57
|
+
if (addResult.exitCode !== 0) {
|
|
58
|
+
log.die("Failed to stage changes.");
|
|
59
|
+
}
|
|
60
|
+
const prefix = issue.labels.includes("enhancement") || issue.labels.includes("feature") ? "feat" : "fix";
|
|
61
|
+
const commitResult = Bun.spawnSync(["git", "commit", "-m", `${prefix}: address issue #${issueNum} - ${issue.title}`], { cwd: worktreeDir });
|
|
62
|
+
if (commitResult.exitCode !== 0) {
|
|
63
|
+
log.warn("Commit failed — changes may already be committed.");
|
|
64
|
+
}
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
// Push
|
|
@@ -81,7 +88,8 @@ ${issue.body}
|
|
|
81
88
|
---
|
|
82
89
|
*Please review the changes carefully before merging.*`;
|
|
83
90
|
|
|
84
|
-
const
|
|
91
|
+
const prefix = issue.labels.includes("enhancement") || issue.labels.includes("feature") ? "feat" : "fix";
|
|
92
|
+
const prTitle = `${prefix}: ${issue.title} [#${issueNum}]`;
|
|
85
93
|
|
|
86
94
|
const pr = Bun.spawnSync([
|
|
87
95
|
"gh", "pr", "create",
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
import { spawnSync } from "child_process";
|
|
4
3
|
import { fetchIssue } from "./github";
|
|
5
4
|
import { ensureRepo, createWorktree } from "./worktree";
|
|
6
|
-
import {
|
|
5
|
+
import { runClaude } from "./sandbox";
|
|
7
6
|
import { pushAndCreatePR } from "./github";
|
|
8
7
|
import { log } from "./log";
|
|
9
8
|
|
|
@@ -14,7 +13,7 @@ async function init() {
|
|
|
14
13
|
log.info("Initializing mog sandbox...");
|
|
15
14
|
|
|
16
15
|
const reposDir = getReposDir();
|
|
17
|
-
const exists =
|
|
16
|
+
const exists = sandboxExists(SANDBOX_NAME);
|
|
18
17
|
|
|
19
18
|
if (exists) {
|
|
20
19
|
log.warn(`Sandbox '${SANDBOX_NAME}' already exists.`);
|
|
@@ -22,10 +21,10 @@ async function init() {
|
|
|
22
21
|
} else {
|
|
23
22
|
log.info(`Creating persistent sandbox '${SANDBOX_NAME}'...`);
|
|
24
23
|
log.info(`Workspace: ${reposDir}`);
|
|
25
|
-
const createResult = spawnSync("docker",
|
|
26
|
-
stdio: "inherit",
|
|
24
|
+
const createResult = Bun.spawnSync(["docker", "sandbox", "create", "--name", SANDBOX_NAME, "claude", reposDir], {
|
|
25
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
27
26
|
});
|
|
28
|
-
const createExit = createResult.
|
|
27
|
+
const createExit = createResult.exitCode;
|
|
29
28
|
if (createExit !== 0) {
|
|
30
29
|
log.die("Failed to create sandbox.");
|
|
31
30
|
}
|
|
@@ -36,20 +35,20 @@ async function init() {
|
|
|
36
35
|
console.log();
|
|
37
36
|
}
|
|
38
37
|
|
|
39
|
-
const runResult = spawnSync("docker",
|
|
40
|
-
stdio: "inherit",
|
|
38
|
+
const runResult = Bun.spawnSync(["docker", "sandbox", "run", SANDBOX_NAME], {
|
|
39
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
41
40
|
});
|
|
42
|
-
const exitCode = runResult.
|
|
41
|
+
const exitCode = runResult.exitCode;
|
|
43
42
|
if (exitCode !== 0) {
|
|
44
43
|
log.die("Sandbox failed to run. Try 'docker sandbox ls' to check its status.");
|
|
45
44
|
}
|
|
46
45
|
|
|
47
46
|
// Save sandbox as template so it can be restored after Docker restarts
|
|
48
47
|
log.info("Saving sandbox snapshot (preserves auth across Docker restarts)...");
|
|
49
|
-
const saveResult = spawnSync("docker",
|
|
50
|
-
stdio: "inherit",
|
|
48
|
+
const saveResult = Bun.spawnSync(["docker", "sandbox", "save", SANDBOX_NAME, TEMPLATE_TAG], {
|
|
49
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
51
50
|
});
|
|
52
|
-
if (saveResult.
|
|
51
|
+
if (saveResult.exitCode !== 0) {
|
|
53
52
|
log.warn("Failed to save sandbox snapshot. Auth may not persist across Docker restarts.");
|
|
54
53
|
} else {
|
|
55
54
|
log.ok("Snapshot saved.");
|
|
@@ -69,10 +68,12 @@ async function main() {
|
|
|
69
68
|
}
|
|
70
69
|
}
|
|
71
70
|
|
|
71
|
+
const reposDir = getReposDir();
|
|
72
|
+
|
|
72
73
|
// Check docker sandbox is available (may fail if sandbox state is stale after Docker restart)
|
|
73
74
|
const sandboxCheck = Bun.spawnSync(["docker", "sandbox", "ls"]);
|
|
74
75
|
if (sandboxCheck.exitCode !== 0) {
|
|
75
|
-
const recovered = tryRecoverSandbox(
|
|
76
|
+
const recovered = tryRecoverSandbox(reposDir);
|
|
76
77
|
if (!recovered) {
|
|
77
78
|
log.die("Docker sandbox not available. Make sure Docker Desktop is running and up to date.");
|
|
78
79
|
}
|
|
@@ -96,6 +97,11 @@ async function main() {
|
|
|
96
97
|
|
|
97
98
|
const repo = args[0];
|
|
98
99
|
const issueNum = args[1];
|
|
100
|
+
|
|
101
|
+
if (!/^\d+$/.test(issueNum)) {
|
|
102
|
+
log.die(`Invalid issue number: '${issueNum}'. Must be a positive integer.`);
|
|
103
|
+
}
|
|
104
|
+
|
|
99
105
|
const [owner, repoName] = repo.split("/");
|
|
100
106
|
|
|
101
107
|
if (!owner || !repoName) {
|
|
@@ -103,12 +109,11 @@ async function main() {
|
|
|
103
109
|
}
|
|
104
110
|
|
|
105
111
|
// Verify sandbox exists, try to restore from template if missing
|
|
106
|
-
if (!
|
|
112
|
+
if (!sandboxExists(SANDBOX_NAME)) {
|
|
107
113
|
if (!templateExists()) {
|
|
108
114
|
log.die(`Sandbox '${SANDBOX_NAME}' not found. Run 'mog init' first.`);
|
|
109
115
|
}
|
|
110
116
|
log.info("Sandbox missing — restoring from saved snapshot...");
|
|
111
|
-
const reposDir = getReposDir();
|
|
112
117
|
const restored = restoreSandboxFromTemplate(SANDBOX_NAME, reposDir);
|
|
113
118
|
if (!restored) {
|
|
114
119
|
log.die("Failed to restore sandbox from snapshot. Run 'mog init' to recreate.");
|
|
@@ -117,15 +122,14 @@ async function main() {
|
|
|
117
122
|
}
|
|
118
123
|
|
|
119
124
|
// Fetch issue
|
|
120
|
-
const issue =
|
|
125
|
+
const issue = fetchIssue(repo, issueNum);
|
|
121
126
|
log.ok(`Issue: ${issue.title}`);
|
|
122
127
|
|
|
123
128
|
// Ensure repo & worktree
|
|
124
|
-
const
|
|
125
|
-
const { defaultBranch } = await ensureRepo(repo, owner, repoName, reposDir);
|
|
129
|
+
const { defaultBranch } = ensureRepo(repo, owner, repoName, reposDir);
|
|
126
130
|
log.info(`Default branch: ${defaultBranch}`);
|
|
127
131
|
|
|
128
|
-
const { worktreeDir, branchName } =
|
|
132
|
+
const { worktreeDir, branchName } = createWorktree(
|
|
129
133
|
reposDir, owner, repoName, defaultBranch, issueNum, issue.title
|
|
130
134
|
);
|
|
131
135
|
|
|
@@ -141,17 +145,16 @@ async function main() {
|
|
|
141
145
|
await runClaude(SANDBOX_NAME, worktreeDir, prompt);
|
|
142
146
|
|
|
143
147
|
// Push and create PR
|
|
144
|
-
|
|
148
|
+
pushAndCreatePR(repo, worktreeDir, branchName, defaultBranch, issueNum, issue);
|
|
145
149
|
}
|
|
146
150
|
|
|
147
151
|
function getReposDir(): string {
|
|
148
152
|
return process.env.MOG_REPOS_DIR || `${process.env.HOME}/mog-repos`;
|
|
149
153
|
}
|
|
150
154
|
|
|
151
|
-
|
|
155
|
+
function sandboxExists(name: string): boolean {
|
|
152
156
|
const result = Bun.spawnSync(["docker", "sandbox", "ls"]);
|
|
153
|
-
|
|
154
|
-
return output.includes(name);
|
|
157
|
+
return result.stdout.toString().split("\n").some(line => line.split(/\s+/)[0] === name);
|
|
155
158
|
}
|
|
156
159
|
|
|
157
160
|
function templateExists(): boolean {
|
|
@@ -160,17 +163,17 @@ function templateExists(): boolean {
|
|
|
160
163
|
}
|
|
161
164
|
|
|
162
165
|
function restoreSandboxFromTemplate(name: string, reposDir: string): boolean {
|
|
163
|
-
const create = spawnSync("docker",
|
|
164
|
-
stdio: "inherit",
|
|
166
|
+
const create = Bun.spawnSync(["docker", "sandbox", "create", "--template", TEMPLATE_TAG, "--name", name, "claude", reposDir], {
|
|
167
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
165
168
|
});
|
|
166
|
-
return create.
|
|
169
|
+
return create.exitCode === 0;
|
|
167
170
|
}
|
|
168
171
|
|
|
169
172
|
function tryRecoverSandbox(reposDir: string): boolean {
|
|
170
173
|
log.warn("Docker sandbox state is stale — attempting recovery...");
|
|
171
174
|
|
|
172
175
|
// Clean up stale sandbox
|
|
173
|
-
spawnSync("docker",
|
|
176
|
+
Bun.spawnSync(["docker", "sandbox", "rm", SANDBOX_NAME], { stdio: ["ignore", "ignore", "ignore"] });
|
|
174
177
|
|
|
175
178
|
// Check if docker sandbox ls works now
|
|
176
179
|
const check = Bun.spawnSync(["docker", "sandbox", "ls"]);
|
|
@@ -194,14 +197,6 @@ function tryRecoverSandbox(reposDir: string): boolean {
|
|
|
194
197
|
return true;
|
|
195
198
|
}
|
|
196
199
|
|
|
197
|
-
async function run(cmd: string[]): Promise<string> {
|
|
198
|
-
const proc = Bun.spawnSync(cmd);
|
|
199
|
-
if (proc.exitCode !== 0) {
|
|
200
|
-
throw new Error(`Command failed: ${cmd.join(" ")}\n${proc.stderr.toString()}`);
|
|
201
|
-
}
|
|
202
|
-
return proc.stdout.toString().trim();
|
|
203
|
-
}
|
|
204
|
-
|
|
205
200
|
function buildPrompt(repo: string, issueNum: string, issue: { title: string; body: string; labels: string }): string {
|
|
206
201
|
return `You are working on GitHub issue #${issueNum} for the repository ${repo}.
|
|
207
202
|
|
package/src/sandbox.ts
CHANGED
|
@@ -15,39 +15,46 @@ interface StreamEvent {
|
|
|
15
15
|
is_error?: boolean;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
const MAX_CONTINUATIONS = parseInt(process.env.MOG_MAX_CONTINUATIONS || "5", 10);
|
|
19
|
+
const CONTINUE_PROMPT = `You stopped before finishing. The task is not done yet — there are no commits.
|
|
20
|
+
Continue where you left off. Do NOT re-plan. Execute the implementation now and commit when done.`;
|
|
21
|
+
|
|
22
|
+
export async function runClaude(sandboxName: string, worktreeDir: string, prompt: string): Promise<void> {
|
|
23
|
+
// Initial run
|
|
24
|
+
await execClaude(sandboxName, worktreeDir, ["-p", prompt]);
|
|
23
25
|
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (inspect.exitCode === 0) {
|
|
29
|
-
log.info(`Restoring sandbox '${name}' from saved snapshot...`);
|
|
30
|
-
createArgs.push("--template", templateTag);
|
|
26
|
+
// Continue loop: if no commits were made, nudge Claude to keep going
|
|
27
|
+
for (let i = 0; i < MAX_CONTINUATIONS; i++) {
|
|
28
|
+
if (hasNewCommits(sandboxName, worktreeDir)) {
|
|
29
|
+
return;
|
|
31
30
|
}
|
|
31
|
+
log.warn(`No commits yet — continuing Claude (attempt ${i + 2}/${MAX_CONTINUATIONS + 1})...`);
|
|
32
|
+
await execClaude(sandboxName, worktreeDir, ["--continue", "-p", CONTINUE_PROMPT]);
|
|
32
33
|
}
|
|
33
|
-
createArgs.push("--name", name, "claude", reposDir);
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (create.exitCode !== 0) {
|
|
38
|
-
log.die(`Failed to create sandbox: ${create.stderr.toString()}`);
|
|
35
|
+
if (!hasNewCommits(sandboxName, worktreeDir)) {
|
|
36
|
+
log.warn("Claude did not produce any commits after all attempts.");
|
|
39
37
|
}
|
|
40
|
-
log.ok("Sandbox created.");
|
|
41
38
|
}
|
|
42
39
|
|
|
43
|
-
|
|
40
|
+
function hasNewCommits(sandboxName: string, worktreeDir: string): boolean {
|
|
41
|
+
const result = Bun.spawnSync([
|
|
42
|
+
"docker", "sandbox", "exec",
|
|
43
|
+
"-w", worktreeDir,
|
|
44
|
+
sandboxName,
|
|
45
|
+
"git", "log", "--oneline", "HEAD", "--not", "--remotes", "-1",
|
|
46
|
+
]);
|
|
47
|
+
return result.exitCode === 0 && result.stdout.toString().trim().length > 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function execClaude(sandboxName: string, worktreeDir: string, claudeArgs: string[]): Promise<void> {
|
|
44
51
|
const proc = Bun.spawn([
|
|
45
52
|
"docker", "sandbox", "exec",
|
|
46
53
|
"-w", worktreeDir,
|
|
47
54
|
sandboxName,
|
|
48
55
|
"claude", "--dangerously-skip-permissions",
|
|
49
56
|
"--verbose", "--output-format", "stream-json",
|
|
50
|
-
|
|
57
|
+
...claudeArgs,
|
|
51
58
|
], {
|
|
52
59
|
stdout: "pipe",
|
|
53
60
|
stderr: "pipe",
|
package/src/worktree.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
+
import fs from "fs";
|
|
1
2
|
import { log } from "./log";
|
|
2
3
|
|
|
3
|
-
export
|
|
4
|
+
export function ensureRepo(
|
|
4
5
|
repo: string,
|
|
5
6
|
owner: string,
|
|
6
7
|
repoName: string,
|
|
7
8
|
reposDir: string
|
|
8
|
-
):
|
|
9
|
+
): { defaultBranch: string } {
|
|
9
10
|
const repoDir = `${reposDir}/${owner}/${repoName}`;
|
|
10
|
-
const fs = await import("fs");
|
|
11
11
|
|
|
12
12
|
if (!fs.existsSync(repoDir)) {
|
|
13
13
|
log.info(`Cloning ${repo} into ${repoDir}...`);
|
|
@@ -37,14 +37,14 @@ export async function ensureRepo(
|
|
|
37
37
|
return { defaultBranch: branchProc.stdout.toString().trim() };
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export
|
|
40
|
+
export function createWorktree(
|
|
41
41
|
reposDir: string,
|
|
42
42
|
owner: string,
|
|
43
43
|
repoName: string,
|
|
44
44
|
defaultBranch: string,
|
|
45
45
|
issueNum: string,
|
|
46
46
|
issueTitle: string
|
|
47
|
-
):
|
|
47
|
+
): { worktreeDir: string; branchName: string } {
|
|
48
48
|
const safeTitle = issueTitle
|
|
49
49
|
.toLowerCase()
|
|
50
50
|
.replace(/[^a-z0-9]/g, "-")
|
|
@@ -56,8 +56,6 @@ export async function createWorktree(
|
|
|
56
56
|
const repoDir = `${reposDir}/${owner}/${repoName}`;
|
|
57
57
|
const worktreeDir = `${reposDir}/${owner}/${repoName}-worktrees/${branchName}`;
|
|
58
58
|
|
|
59
|
-
const fs = await import("fs");
|
|
60
|
-
|
|
61
59
|
if (fs.existsSync(worktreeDir)) {
|
|
62
60
|
log.warn(`Worktree already exists at ${worktreeDir}, reusing.`);
|
|
63
61
|
return { worktreeDir, branchName };
|