@bobbyg603/mog 1.1.0 → 1.3.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 CHANGED
@@ -1,4 +1,4 @@
1
- <img width="300" alt="claude moggin" src="https://github.com/user-attachments/assets/bed005f3-12c3-47ee-8b6e-6974ed4e0a79" />
1
+ <img height="320" alt="claude moggin issues" src="https://github.com/user-attachments/assets/72b347d2-b128-47b5-8dcd-e15248350fe0" />
2
2
 
3
3
 
4
4
  # mog — Sandboxed Claude Issue Mogging
@@ -19,7 +19,7 @@ That's it. `mog` will:
19
19
  ## Prerequisites
20
20
 
21
21
  - **macOS or Windows** (Docker sandbox microVMs require Docker Desktop)
22
- - **Docker Desktop** — running and up to date (must support `docker sandbox`)
22
+ - **Docker Desktop 4.40+** — running and up to date. Docker sandbox support (required by mog) was introduced in Docker Desktop 4.40. Verify with `docker sandbox ls`.
23
23
  - **Bun** — install from [bun.sh](https://bun.sh)
24
24
  - **GitHub CLI** (`gh`) — authenticated via `gh auth login`
25
25
  - **Git** with push access to your target repos
@@ -33,6 +33,9 @@ bun install -g @bobbyg603/mog
33
33
  ## Quick start
34
34
 
35
35
  ```bash
36
+ # 0. Verify Docker sandbox support is available
37
+ docker sandbox ls
38
+
36
39
  # 1. One-time setup: create sandbox & authenticate
37
40
  mog init
38
41
  # This launches Claude Code — use /login to authenticate with your Max subscription
@@ -131,6 +134,10 @@ git worktree remove ../repo-worktrees/123-fix-broken-login
131
134
 
132
135
  **"No changes detected"** — Claude may have struggled with the issue. Check the worktree manually, or re-run with a more detailed issue description.
133
136
 
137
+ **"Docker sandbox state is stale"** — Restart Docker Desktop, or remove and recreate the sandbox: `docker sandbox rm mog && mog init`.
138
+
139
+ **"docker: 'sandbox' is not a docker command"** — Your Docker Desktop version doesn't support sandboxes. Update Docker Desktop to **4.40 or later**, then verify with `docker sandbox ls`.
140
+
134
141
  **"Failed to push"** — Ensure `gh` is authenticated with push access. Try `gh auth login` and select HTTPS.
135
142
 
136
143
  ## Managing the sandbox
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobbyg603/mog",
3
- "version": "1.1.0",
3
+ "version": "1.3.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",
package/src/github.ts CHANGED
@@ -28,13 +28,56 @@ export function fetchIssue(repo: string, issueNum: string): Issue {
28
28
  };
29
29
  }
30
30
 
31
+ export function listIssues(repo: string, verbose: boolean): void {
32
+ log.info(`Fetching open issues for ${repo}...`);
33
+
34
+ const fields = verbose
35
+ ? "number,title,body,labels,assignees"
36
+ : "number,title";
37
+
38
+ const proc = Bun.spawnSync([
39
+ "gh", "issue", "list",
40
+ "--repo", repo,
41
+ "--state", "open",
42
+ "--json", fields,
43
+ ]);
44
+
45
+ if (proc.exitCode !== 0) {
46
+ log.die(`Failed to fetch issues for ${repo}. Check the repo name.`);
47
+ }
48
+
49
+ const issues = JSON.parse(proc.stdout.toString());
50
+
51
+ if (issues.length === 0) {
52
+ log.info("No open issues found.");
53
+ return;
54
+ }
55
+
56
+ log.ok(`${issues.length} open issue(s):\n`);
57
+
58
+ for (const issue of issues) {
59
+ if (verbose) {
60
+ const labels = issue.labels?.map((l: { name: string }) => l.name).join(", ") || "none";
61
+ const assignees = issue.assignees?.map((a: { login: string }) => a.login).join(", ") || "unassigned";
62
+ console.log(` #${issue.number} ${issue.title}`);
63
+ console.log(` Labels: ${labels}`);
64
+ console.log(` Assignees: ${assignees}`);
65
+ console.log(` ${(issue.body || "No description.").split("\n")[0]}`);
66
+ console.log();
67
+ } else {
68
+ console.log(` #${issue.number} ${issue.title}`);
69
+ }
70
+ }
71
+ }
72
+
31
73
  export function pushAndCreatePR(
32
74
  repo: string,
33
75
  worktreeDir: string,
34
76
  branchName: string,
35
77
  defaultBranch: string,
36
78
  issueNum: string,
37
- issue: Issue
79
+ issue: Issue,
80
+ summary?: string
38
81
  ): void {
39
82
  // Check for unpushed commits or uncommitted changes
40
83
  const unpushed = Bun.spawnSync(["git", "log", `origin/${defaultBranch}..HEAD`, "--oneline"], { cwd: worktreeDir });
@@ -75,11 +118,15 @@ export function pushAndCreatePR(
75
118
  // Create PR
76
119
  log.info("Opening pull request...");
77
120
 
121
+ const summarySection = summary
122
+ ? `### What was done\n\n${summary}\n\n`
123
+ : "";
124
+
78
125
  const prBody = `## Summary
79
126
 
80
127
  Closes #${issueNum}
81
128
 
82
- This PR was generated by [mog](https://github.com/bobbyg603/mog) using Claude Code in a Docker sandbox.
129
+ ${summarySection}This PR was generated by [mog](https://github.com/bobbyg603/mog) using Claude Code in a Docker sandbox.
83
130
 
84
131
  ### Issue: ${issue.title}
85
132
 
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { fetchIssue } from "./github";
4
- import { ensureRepo, createWorktree } from "./worktree";
3
+ import { fetchIssue, listIssues } from "./github";
4
+ import { detectRepo, ensureRepo, createWorktree } from "./worktree";
5
5
  import { runClaude } from "./sandbox";
6
6
  import { pushAndCreatePR } from "./github";
7
7
  import { log } from "./log";
@@ -54,7 +54,7 @@ async function init() {
54
54
  log.ok("Snapshot saved.");
55
55
  }
56
56
 
57
- log.ok("mog is ready. Run: mog <owner/repo> <issue_number>");
57
+ log.ok("mog is ready. Run: mog <issue_number> (from a git repo) or mog <owner/repo> <issue_number>");
58
58
  }
59
59
 
60
60
  async function main() {
@@ -84,25 +84,80 @@ async function main() {
84
84
  return;
85
85
  }
86
86
 
87
- if (args.length < 2) {
87
+ // mog list [--verbose] or mog <owner/repo> list [--verbose]
88
+ if (args[0] === "list" || args[1] === "list") {
89
+ let repo: string;
90
+ const verbose = args.includes("--verbose");
91
+
92
+ if (args[0] === "list") {
93
+ const detected = detectRepo();
94
+ if (!detected) {
95
+ log.die("Could not detect repo from git remote. Run from inside a git repo or use: mog <owner/repo> list");
96
+ }
97
+ repo = detected;
98
+ } else {
99
+ repo = args[0];
100
+ }
101
+
102
+ listIssues(repo, verbose);
103
+ return;
104
+ }
105
+
106
+ if (args.length < 1) {
88
107
  console.log("Usage:");
89
108
  console.log(" mog init — one-time setup (create sandbox & login)");
109
+ console.log(" mog <issue_num> — auto-detect repo from git remote");
90
110
  console.log(" mog <owner/repo> <issue_num> — fetch issue, run Claude, open PR");
111
+ console.log(" mog list [--verbose] — list open issues (auto-detect repo)");
112
+ console.log(" mog <owner/repo> list [--verbose] — list open issues for a repo");
91
113
  console.log();
92
114
  console.log("Example:");
93
115
  console.log(" mog init");
116
+ console.log(" mog 123");
94
117
  console.log(" mog workingdevshero/automate-it 123");
118
+ console.log(" mog list");
119
+ console.log(" mog list --verbose");
95
120
  return;
96
121
  }
97
122
 
98
- const repo = args[0];
99
- const issueNum = args[1];
123
+ let repo: string;
124
+ let issueNum: string;
100
125
 
101
- if (!/^\d+$/.test(issueNum)) {
102
- log.die(`Invalid issue number: '${issueNum}'. Must be a positive integer.`);
126
+ if (/^\d+$/.test(args[0])) {
127
+ // mog <issue_number> auto-detect repo
128
+ const detected = detectRepo();
129
+ if (!detected) {
130
+ log.die("Could not detect repo from git remote. Run from inside a git repo or use: mog <owner/repo> <issue_num>");
131
+ }
132
+ repo = detected;
133
+ issueNum = args[0];
134
+ } else if (args.length >= 2) {
135
+ // mog <owner/repo> <issue_number>
136
+ repo = args[0];
137
+ issueNum = args[1];
138
+ if (!/^\d+$/.test(issueNum)) {
139
+ log.die(`Invalid issue number: '${issueNum}'. Must be a positive integer.`);
140
+ }
141
+ } else {
142
+ console.log("Usage:");
143
+ console.log(" mog init — one-time setup (create sandbox & login)");
144
+ console.log(" mog <issue_num> — auto-detect repo from git remote");
145
+ console.log(" mog <owner/repo> <issue_num> — fetch issue, run Claude, open PR");
146
+ console.log(" mog list [--verbose] — list open issues (auto-detect repo)");
147
+ console.log(" mog <owner/repo> list [--verbose] — list open issues for a repo");
148
+ console.log();
149
+ console.log("Example:");
150
+ console.log(" mog init");
151
+ console.log(" mog 123");
152
+ console.log(" mog workingdevshero/automate-it 123");
153
+ console.log(" mog list");
154
+ console.log(" mog list --verbose");
155
+ return;
103
156
  }
104
157
 
105
- const [owner, repoName] = repo.split("/");
158
+ const parts = repo.split("/");
159
+ const owner = parts[0] as string;
160
+ const repoName = parts[1] as string;
106
161
 
107
162
  if (!owner || !repoName) {
108
163
  log.die("Invalid repo format. Use: owner/repo");
@@ -144,10 +199,10 @@ async function main() {
144
199
  log.info(`Worktree: ${worktreeDir}`);
145
200
  console.log();
146
201
 
147
- await runClaude(SANDBOX_NAME, worktreeDir, planningPrompt, buildingPromptFn);
202
+ const summary = await runClaude(SANDBOX_NAME, worktreeDir, planningPrompt, buildingPromptFn);
148
203
 
149
204
  // Push and create PR
150
- pushAndCreatePR(repo, worktreeDir, branchName, defaultBranch, issueNum, issue);
205
+ pushAndCreatePR(repo, worktreeDir, branchName, defaultBranch, issueNum, issue, summary);
151
206
  }
152
207
 
153
208
  function getReposDir(): string {
package/src/sandbox.ts CHANGED
@@ -76,7 +76,9 @@ export async function runClaude(
76
76
  worktreeDir: string,
77
77
  planningPrompt: string,
78
78
  buildingPromptFn: (remainingItems: string[], planContent: string) => string,
79
- ): Promise<void> {
79
+ ): Promise<string> {
80
+ let lastResult = "";
81
+
80
82
  // Phase 1 — Planning
81
83
  log.info("Phase 1: Creating implementation plan...");
82
84
  await execClaude(sandboxName, worktreeDir, ["-p", planningPrompt]);
@@ -88,21 +90,23 @@ export async function runClaude(
88
90
  if (!planContent || unchecked.length === 0) {
89
91
  log.warn("No implementation plan created — falling back to single-shot mode.");
90
92
  const fallbackPrompt = buildingPromptFn([], "");
91
- await execClaude(sandboxName, worktreeDir, ["-p", fallbackPrompt]);
93
+ const fallbackResult = await execClaude(sandboxName, worktreeDir, ["-p", fallbackPrompt]);
94
+ if (fallbackResult) lastResult = fallbackResult;
92
95
 
93
96
  for (let i = 0; i < MAX_ITERATIONS; i++) {
94
- if (getCommitCount(sandboxName, worktreeDir) > 0) return;
97
+ if (getCommitCount(sandboxName, worktreeDir) > 0) return lastResult;
95
98
  log.warn(`No commits yet — continuing Claude (attempt ${i + 2}/${MAX_ITERATIONS + 1})...`);
96
- await execClaude(sandboxName, worktreeDir, [
99
+ const contResult = await execClaude(sandboxName, worktreeDir, [
97
100
  "--continue", "-p",
98
101
  "You stopped before finishing. The task is not done yet — there are no commits. Continue where you left off. Do NOT re-plan. Execute the implementation now and commit when done.",
99
102
  ]);
103
+ if (contResult) lastResult = contResult;
100
104
  }
101
105
 
102
106
  if (getCommitCount(sandboxName, worktreeDir) === 0) {
103
107
  log.warn("Claude did not produce any commits after all attempts.");
104
108
  }
105
- return;
109
+ return lastResult;
106
110
  }
107
111
 
108
112
  log.ok(`Implementation plan created with ${unchecked.length} task(s).`);
@@ -126,10 +130,11 @@ export async function runClaude(
126
130
  const commitsBefore = getCommitCount(sandboxName, worktreeDir);
127
131
  const uncheckedBefore = remaining.length;
128
132
 
129
- log.info(`Iteration ${i + 1}/${MAX_ITERATIONS}: ${remaining[0].replace("- [ ] ", "")}`);
133
+ log.info(`Iteration ${i + 1}/${MAX_ITERATIONS}: ${remaining[0]!.replace("- [ ] ", "")}`);
130
134
  log.info(`${remaining.length} task(s) remaining.`);
131
135
 
132
- await execClaude(sandboxName, worktreeDir, ["-p", buildingPromptFn(remaining, currentPlan)]);
136
+ const buildResult = await execClaude(sandboxName, worktreeDir, ["-p", buildingPromptFn(remaining, currentPlan)]);
137
+ if (buildResult) lastResult = buildResult;
133
138
 
134
139
  const planAfter = readPlanFile(worktreeDir);
135
140
  const uncheckedAfter = planAfter ? getUncheckedItems(planAfter).length : 0;
@@ -159,9 +164,11 @@ export async function runClaude(
159
164
  } else {
160
165
  log.ok("Plan file cleaned up.");
161
166
  }
167
+
168
+ return lastResult;
162
169
  }
163
170
 
164
- async function execClaude(sandboxName: string, worktreeDir: string, claudeArgs: string[]): Promise<void> {
171
+ async function execClaude(sandboxName: string, worktreeDir: string, claudeArgs: string[]): Promise<string> {
165
172
  const proc = Bun.spawn([
166
173
  "docker", "sandbox", "exec",
167
174
  "-w", worktreeDir,
@@ -178,6 +185,7 @@ async function execClaude(sandboxName: string, worktreeDir: string, claudeArgs:
178
185
  const reader = proc.stdout.getReader();
179
186
  const decoder = new TextDecoder();
180
187
  let buffer = "";
188
+ let resultText = "";
181
189
 
182
190
  while (true) {
183
191
  const { done, value } = await reader.read();
@@ -194,7 +202,8 @@ async function execClaude(sandboxName: string, worktreeDir: string, claudeArgs:
194
202
 
195
203
  try {
196
204
  const event: StreamEvent = JSON.parse(line);
197
- printEvent(event);
205
+ const r = printEvent(event);
206
+ if (r) resultText = r;
198
207
  } catch {
199
208
  // Skip malformed JSON lines
200
209
  }
@@ -205,7 +214,8 @@ async function execClaude(sandboxName: string, worktreeDir: string, claudeArgs:
205
214
  if (buffer.trim()) {
206
215
  try {
207
216
  const event: StreamEvent = JSON.parse(buffer);
208
- printEvent(event);
217
+ const r = printEvent(event);
218
+ if (r) resultText = r;
209
219
  } catch {
210
220
  // Skip
211
221
  }
@@ -220,9 +230,11 @@ async function execClaude(sandboxName: string, worktreeDir: string, claudeArgs:
220
230
  }
221
231
  log.warn(`Claude Code exited with code ${exitCode}.`);
222
232
  }
233
+
234
+ return resultText;
223
235
  }
224
236
 
225
- function printEvent(event: StreamEvent): void {
237
+ function printEvent(event: StreamEvent): string | null {
226
238
  if (event.type === "assistant" && event.message?.content) {
227
239
  for (const block of event.message.content) {
228
240
  if (block.type === "text" && block.text) {
@@ -237,8 +249,10 @@ function printEvent(event: StreamEvent): void {
237
249
  log.err(event.result || "Unknown error");
238
250
  } else if (event.result) {
239
251
  log.done(event.result.slice(0, 200));
252
+ return event.result;
240
253
  }
241
254
  }
255
+ return null;
242
256
  }
243
257
 
244
258
  function getToolDetail(name: string, input?: Record<string, unknown>): string {
package/src/worktree.ts CHANGED
@@ -1,6 +1,29 @@
1
1
  import fs from "fs";
2
2
  import { log } from "./log";
3
3
 
4
+ export function detectRepo(): string | null {
5
+ const result = Bun.spawnSync(["git", "remote", "get-url", "origin"]);
6
+ if (result.exitCode !== 0) {
7
+ return null;
8
+ }
9
+
10
+ const url = result.stdout.toString().trim();
11
+
12
+ // SSH: git@github.com:owner/repo.git
13
+ const sshMatch = url.match(/^git@[^:]+:([^/]+)\/([^/]+?)(?:\.git)?$/);
14
+ if (sshMatch) {
15
+ return `${sshMatch[1]}/${sshMatch[2]}`;
16
+ }
17
+
18
+ // HTTPS: https://github.com/owner/repo.git
19
+ const httpsMatch = url.match(/^https?:\/\/[^/]+\/([^/]+)\/([^/]+?)(?:\.git)?$/);
20
+ if (httpsMatch) {
21
+ return `${httpsMatch[1]}/${httpsMatch[2]}`;
22
+ }
23
+
24
+ return null;
25
+ }
26
+
4
27
  export function ensureRepo(
5
28
  repo: string,
6
29
  owner: string,
@@ -36,11 +59,9 @@ export function ensureRepo(
36
59
 
37
60
  const defaultBranch = branchProc.stdout.toString().trim();
38
61
 
39
- // Update the main repo to latest before creating worktrees
40
- log.info(`Updating ${repoDir} (${defaultBranch})...`);
62
+ // Fetch latest remote refs (no checkout/pull avoids conflicts with existing worktrees)
63
+ log.info(`Fetching latest from origin (${defaultBranch})...`);
41
64
  Bun.spawnSync(["git", "fetch", "origin", defaultBranch], { cwd: repoDir });
42
- Bun.spawnSync(["git", "checkout", defaultBranch], { cwd: repoDir, stdout: "ignore", stderr: "ignore" });
43
- Bun.spawnSync(["git", "pull", "origin", defaultBranch], { cwd: repoDir });
44
65
 
45
66
  return { defaultBranch };
46
67
  }
@@ -81,14 +102,17 @@ export function createWorktree(
81
102
  );
82
103
 
83
104
  if (result.exitCode !== 0) {
84
- // Try using existing branch
85
- const fallback = Bun.spawnSync(
86
- ["git", "worktree", "add", worktreeDir, branchName],
105
+ // Branch likely exists from a previous run — delete it and retry from origin
106
+ log.info(`Branch '${branchName}' already exists, recreating from origin/${defaultBranch}...`);
107
+ Bun.spawnSync(["git", "branch", "-D", branchName], { cwd: repoDir });
108
+
109
+ const retry = Bun.spawnSync(
110
+ ["git", "worktree", "add", "-b", branchName, worktreeDir, `origin/${defaultBranch}`],
87
111
  { cwd: repoDir }
88
112
  );
89
113
 
90
- if (fallback.exitCode !== 0) {
91
- log.die(`Failed to create worktree. Branch '${branchName}' may already exist.`);
114
+ if (retry.exitCode !== 0) {
115
+ log.die(`Failed to create worktree. Branch '${branchName}' may be in use by another worktree.`);
92
116
  }
93
117
  }
94
118