@bobbyg603/mog 0.1.3 → 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 +86 -27
- package/src/sandbox.ts +29 -11
- 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,19 +1,19 @@
|
|
|
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
|
|
|
10
9
|
const SANDBOX_NAME = "mog";
|
|
10
|
+
const TEMPLATE_TAG = "mog-template:latest";
|
|
11
11
|
|
|
12
12
|
async function init() {
|
|
13
13
|
log.info("Initializing mog sandbox...");
|
|
14
14
|
|
|
15
15
|
const reposDir = getReposDir();
|
|
16
|
-
const exists =
|
|
16
|
+
const exists = sandboxExists(SANDBOX_NAME);
|
|
17
17
|
|
|
18
18
|
if (exists) {
|
|
19
19
|
log.warn(`Sandbox '${SANDBOX_NAME}' already exists.`);
|
|
@@ -21,10 +21,10 @@ async function init() {
|
|
|
21
21
|
} else {
|
|
22
22
|
log.info(`Creating persistent sandbox '${SANDBOX_NAME}'...`);
|
|
23
23
|
log.info(`Workspace: ${reposDir}`);
|
|
24
|
-
const createResult = spawnSync("docker",
|
|
25
|
-
stdio: "inherit",
|
|
24
|
+
const createResult = Bun.spawnSync(["docker", "sandbox", "create", "--name", SANDBOX_NAME, "claude", reposDir], {
|
|
25
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
26
26
|
});
|
|
27
|
-
const createExit = createResult.
|
|
27
|
+
const createExit = createResult.exitCode;
|
|
28
28
|
if (createExit !== 0) {
|
|
29
29
|
log.die("Failed to create sandbox.");
|
|
30
30
|
}
|
|
@@ -35,14 +35,25 @@ async function init() {
|
|
|
35
35
|
console.log();
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
const runResult = spawnSync("docker",
|
|
39
|
-
stdio: "inherit",
|
|
38
|
+
const runResult = Bun.spawnSync(["docker", "sandbox", "run", SANDBOX_NAME], {
|
|
39
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
40
40
|
});
|
|
41
|
-
const exitCode = runResult.
|
|
41
|
+
const exitCode = runResult.exitCode;
|
|
42
42
|
if (exitCode !== 0) {
|
|
43
43
|
log.die("Sandbox failed to run. Try 'docker sandbox ls' to check its status.");
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
// Save sandbox as template so it can be restored after Docker restarts
|
|
47
|
+
log.info("Saving sandbox snapshot (preserves auth across Docker restarts)...");
|
|
48
|
+
const saveResult = Bun.spawnSync(["docker", "sandbox", "save", SANDBOX_NAME, TEMPLATE_TAG], {
|
|
49
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
50
|
+
});
|
|
51
|
+
if (saveResult.exitCode !== 0) {
|
|
52
|
+
log.warn("Failed to save sandbox snapshot. Auth may not persist across Docker restarts.");
|
|
53
|
+
} else {
|
|
54
|
+
log.ok("Snapshot saved.");
|
|
55
|
+
}
|
|
56
|
+
|
|
46
57
|
log.ok("mog is ready. Run: mog <owner/repo> <issue_number>");
|
|
47
58
|
}
|
|
48
59
|
|
|
@@ -57,10 +68,15 @@ async function main() {
|
|
|
57
68
|
}
|
|
58
69
|
}
|
|
59
70
|
|
|
60
|
-
|
|
71
|
+
const reposDir = getReposDir();
|
|
72
|
+
|
|
73
|
+
// Check docker sandbox is available (may fail if sandbox state is stale after Docker restart)
|
|
61
74
|
const sandboxCheck = Bun.spawnSync(["docker", "sandbox", "ls"]);
|
|
62
75
|
if (sandboxCheck.exitCode !== 0) {
|
|
63
|
-
|
|
76
|
+
const recovered = tryRecoverSandbox(reposDir);
|
|
77
|
+
if (!recovered) {
|
|
78
|
+
log.die("Docker sandbox not available. Make sure Docker Desktop is running and up to date.");
|
|
79
|
+
}
|
|
64
80
|
}
|
|
65
81
|
|
|
66
82
|
if (args[0] === "init") {
|
|
@@ -81,27 +97,39 @@ async function main() {
|
|
|
81
97
|
|
|
82
98
|
const repo = args[0];
|
|
83
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
|
+
|
|
84
105
|
const [owner, repoName] = repo.split("/");
|
|
85
106
|
|
|
86
107
|
if (!owner || !repoName) {
|
|
87
108
|
log.die("Invalid repo format. Use: owner/repo");
|
|
88
109
|
}
|
|
89
110
|
|
|
90
|
-
// Verify sandbox exists
|
|
91
|
-
if (!
|
|
92
|
-
|
|
111
|
+
// Verify sandbox exists, try to restore from template if missing
|
|
112
|
+
if (!sandboxExists(SANDBOX_NAME)) {
|
|
113
|
+
if (!templateExists()) {
|
|
114
|
+
log.die(`Sandbox '${SANDBOX_NAME}' not found. Run 'mog init' first.`);
|
|
115
|
+
}
|
|
116
|
+
log.info("Sandbox missing — restoring from saved snapshot...");
|
|
117
|
+
const restored = restoreSandboxFromTemplate(SANDBOX_NAME, reposDir);
|
|
118
|
+
if (!restored) {
|
|
119
|
+
log.die("Failed to restore sandbox from snapshot. Run 'mog init' to recreate.");
|
|
120
|
+
}
|
|
121
|
+
log.ok("Sandbox restored from snapshot (auth preserved).");
|
|
93
122
|
}
|
|
94
123
|
|
|
95
124
|
// Fetch issue
|
|
96
|
-
const issue =
|
|
125
|
+
const issue = fetchIssue(repo, issueNum);
|
|
97
126
|
log.ok(`Issue: ${issue.title}`);
|
|
98
127
|
|
|
99
128
|
// Ensure repo & worktree
|
|
100
|
-
const
|
|
101
|
-
const { defaultBranch } = await ensureRepo(repo, owner, repoName, reposDir);
|
|
129
|
+
const { defaultBranch } = ensureRepo(repo, owner, repoName, reposDir);
|
|
102
130
|
log.info(`Default branch: ${defaultBranch}`);
|
|
103
131
|
|
|
104
|
-
const { worktreeDir, branchName } =
|
|
132
|
+
const { worktreeDir, branchName } = createWorktree(
|
|
105
133
|
reposDir, owner, repoName, defaultBranch, issueNum, issue.title
|
|
106
134
|
);
|
|
107
135
|
|
|
@@ -117,25 +145,56 @@ async function main() {
|
|
|
117
145
|
await runClaude(SANDBOX_NAME, worktreeDir, prompt);
|
|
118
146
|
|
|
119
147
|
// Push and create PR
|
|
120
|
-
|
|
148
|
+
pushAndCreatePR(repo, worktreeDir, branchName, defaultBranch, issueNum, issue);
|
|
121
149
|
}
|
|
122
150
|
|
|
123
151
|
function getReposDir(): string {
|
|
124
152
|
return process.env.MOG_REPOS_DIR || `${process.env.HOME}/mog-repos`;
|
|
125
153
|
}
|
|
126
154
|
|
|
127
|
-
|
|
155
|
+
function sandboxExists(name: string): boolean {
|
|
128
156
|
const result = Bun.spawnSync(["docker", "sandbox", "ls"]);
|
|
129
|
-
|
|
130
|
-
|
|
157
|
+
return result.stdout.toString().split("\n").some(line => line.split(/\s+/)[0] === name);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function templateExists(): boolean {
|
|
161
|
+
const result = Bun.spawnSync(["docker", "image", "inspect", TEMPLATE_TAG]);
|
|
162
|
+
return result.exitCode === 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function restoreSandboxFromTemplate(name: string, reposDir: string): boolean {
|
|
166
|
+
const create = Bun.spawnSync(["docker", "sandbox", "create", "--template", TEMPLATE_TAG, "--name", name, "claude", reposDir], {
|
|
167
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
168
|
+
});
|
|
169
|
+
return create.exitCode === 0;
|
|
131
170
|
}
|
|
132
171
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
172
|
+
function tryRecoverSandbox(reposDir: string): boolean {
|
|
173
|
+
log.warn("Docker sandbox state is stale — attempting recovery...");
|
|
174
|
+
|
|
175
|
+
// Clean up stale sandbox
|
|
176
|
+
Bun.spawnSync(["docker", "sandbox", "rm", SANDBOX_NAME], { stdio: ["ignore", "ignore", "ignore"] });
|
|
177
|
+
|
|
178
|
+
// Check if docker sandbox ls works now
|
|
179
|
+
const check = Bun.spawnSync(["docker", "sandbox", "ls"]);
|
|
180
|
+
if (check.exitCode !== 0) {
|
|
181
|
+
// docker sandbox itself is broken, not just stale state
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// If we have a saved template, restore from it
|
|
186
|
+
if (templateExists()) {
|
|
187
|
+
log.info("Restoring sandbox from saved snapshot...");
|
|
188
|
+
const restored = restoreSandboxFromTemplate(SANDBOX_NAME, reposDir);
|
|
189
|
+
if (restored) {
|
|
190
|
+
log.ok("Sandbox restored from snapshot (auth preserved).");
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
137
193
|
}
|
|
138
|
-
|
|
194
|
+
|
|
195
|
+
// Recovered docker sandbox command but no template — user needs to mog init
|
|
196
|
+
log.warn("No saved snapshot found. Run 'mog init' to set up the sandbox.");
|
|
197
|
+
return true;
|
|
139
198
|
}
|
|
140
199
|
|
|
141
200
|
function buildPrompt(repo: string, issueNum: string, issue: { title: string; body: string; labels: string }): string {
|
package/src/sandbox.ts
CHANGED
|
@@ -15,28 +15,46 @@ interface StreamEvent {
|
|
|
15
15
|
is_error?: boolean;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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]);
|
|
25
|
+
|
|
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;
|
|
30
|
+
}
|
|
31
|
+
log.warn(`No commits yet — continuing Claude (attempt ${i + 2}/${MAX_CONTINUATIONS + 1})...`);
|
|
32
|
+
await execClaude(sandboxName, worktreeDir, ["--continue", "-p", CONTINUE_PROMPT]);
|
|
22
33
|
}
|
|
23
34
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (create.exitCode !== 0) {
|
|
27
|
-
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.");
|
|
28
37
|
}
|
|
29
|
-
log.ok("Sandbox created.");
|
|
30
38
|
}
|
|
31
39
|
|
|
32
|
-
|
|
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> {
|
|
33
51
|
const proc = Bun.spawn([
|
|
34
52
|
"docker", "sandbox", "exec",
|
|
35
53
|
"-w", worktreeDir,
|
|
36
54
|
sandboxName,
|
|
37
55
|
"claude", "--dangerously-skip-permissions",
|
|
38
56
|
"--verbose", "--output-format", "stream-json",
|
|
39
|
-
|
|
57
|
+
...claudeArgs,
|
|
40
58
|
], {
|
|
41
59
|
stdout: "pipe",
|
|
42
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 };
|