Skip to content

Commit c58c6b4

Browse files
authored
Add post_create_script config option (#20)
1 parent 4a82796 commit c58c6b4

File tree

6 files changed

+306
-5
lines changed

6 files changed

+306
-5
lines changed

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ KNOWN_ISSUES.md # tracked issues not yet fixed
9797
"base_branch": "main",
9898
"repo_root": "/Users/you/repos/android",
9999
"mobs_dir": "/Users/you/repos/.codemob/android/mobs",
100+
"post_create_script": "",
100101
"mobs": [
101102
{
102103
"name": "fix-auth-bug",

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,30 @@ codemob purge # remove all
203203

204204
No patches, no plugins, no monkey-patching. Just a shell function pretending to be `claude` and skimming a few arguments off the top.
205205

206+
## Hooks
207+
208+
### `post_create_script`
209+
210+
Run a shell script automatically after every `codemob new`. The script runs with `cwd` set to the new worktree - before the agent launches.
211+
212+
Set it in `.codemob/config.json` (absolute or relative to repo root):
213+
214+
```json
215+
{
216+
"post_create_script": "./scripts/mob-setup.sh"
217+
}
218+
```
219+
220+
Example - install dependencies so the agent starts in a working environment:
221+
222+
```sh
223+
#!/bin/sh
224+
npm install --silent
225+
bundle install --quiet
226+
```
227+
228+
If the script exits non-zero, the mob is cleaned up and the agent is not launched.
229+
206230
## Under the hood
207231

208232
【🌕】Each mob is a git worktree under `.codemob/mobs/`. Agents are launched as child processes. When you queue an action from inside an agent (via slash command), codemob detects it, terminates the agent, and launches the next session automatically.

cmd/root.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,24 @@ func createMob(root string, cfg *mob.Config, name, agent string) (string, error)
316316
return worktreePath, nil
317317
}
318318

319+
func runPostCreateScript(cfg *mob.Config, worktreePath string) error {
320+
if cfg.PostCreateScript == "" {
321+
return nil
322+
}
323+
dim := "\033[2m"
324+
reset := "\033[0m"
325+
mobStatus("Running post-create script...")
326+
fmt.Printf(" %s╭─%s\n", dim, reset)
327+
err := mob.RunPostCreateScript(cfg, worktreePath)
328+
fmt.Printf(" %s╰─%s\n", dim, reset)
329+
if err != nil {
330+
mobStatus("Post-create script failed")
331+
return err
332+
}
333+
mobStatus("Post-create script completed")
334+
return nil
335+
}
336+
319337
// removeMob handles the full mob-removal sequence: worktree removal, branch deletion,
320338
// config update, save, and session file cleanup.
321339
func removeMob(root string, cfg *mob.Config, m *mob.Mob, force bool) error {
@@ -378,6 +396,13 @@ func cmdNew(args []string) error {
378396
return err
379397
}
380398

399+
if err := runPostCreateScript(cfg, worktreePath); err != nil {
400+
if m := mob.FindMob(cfg, filepath.Base(worktreePath)); m != nil {
401+
_ = removeMob(root, cfg, m, true)
402+
}
403+
return err
404+
}
405+
381406
if !noLaunch {
382407
return launchAgent(root, agent, worktreePath, false)
383408
}
@@ -772,6 +797,12 @@ func resolveNextAction(root string, next *mob.QueuedAction) (workdir, agent stri
772797
if err != nil {
773798
return "", "", false, err
774799
}
800+
if err := runPostCreateScript(cfg, worktreePath); err != nil {
801+
if m := mob.FindMob(cfg, filepath.Base(worktreePath)); m != nil {
802+
_ = removeMob(root, cfg, m, true)
803+
}
804+
return "", "", false, err
805+
}
775806
return worktreePath, agent, false, nil
776807

777808
case "remove":
@@ -876,6 +907,7 @@ func launchAgent(root, agent, workdir string, resume bool) error {
876907
}
877908
}
878909

910+
fmt.Print("\033[2K")
879911
mobStatus(fmt.Sprintf("Session ended - mob '%s'", filepath.Base(workdir)))
880912

881913
// Always check for queued action, regardless of how the agent exited

internal/mob/init.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,7 @@ func setupRepo(reprompt bool) string {
681681
fullyConfigured := cfg.RepoRoot != ""
682682

683683
if !isNew && !reprompt && fullyConfigured {
684+
_ = SaveConfig(root, cfg)
684685
info(fmt.Sprintf("Repo already initialized at %s", root))
685686
return root
686687
}

internal/mob/integration_test.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,15 @@ func runCoreExpectError(t *testing.T, bin, dir string, args ...string) string {
172172
return string(out)
173173
}
174174

175+
// patchConfig reads the config, applies a mutation, and writes it back.
176+
func patchConfig(t *testing.T, repoPath string, mutate func(map[string]interface{})) {
177+
t.Helper()
178+
cfg := readConfig(t, repoPath)
179+
mutate(cfg)
180+
data, _ := json.MarshalIndent(cfg, "", " ")
181+
os.WriteFile(filepath.Join(repoPath, ".codemob", "config.json"), append(data, '\n'), 0644)
182+
}
183+
175184
// readConfig reads and parses .codemob/config.json from the repo.
176185
func readConfig(t *testing.T, repoPath string) map[string]interface{} {
177186
t.Helper()
@@ -205,6 +214,9 @@ func TestInit(t *testing.T) {
205214
if cfg["base_branch"] != "main" {
206215
t.Errorf("expected base_branch=main, got %v", cfg["base_branch"])
207216
}
217+
if _, ok := cfg["post_create_script"]; !ok {
218+
t.Error("expected post_create_script field to be present in config")
219+
}
208220

209221
// then -> .codemob/mobs/ dir should exist
210222
if _, err := os.Stat(filepath.Join(repoPath, ".codemob", "mobs")); err != nil {
@@ -334,6 +346,30 @@ func TestInitIdempotent(t *testing.T) {
334346
}
335347
}
336348

349+
func TestInitAddsNewFieldsToExistingConfig(t *testing.T) {
350+
bin := buildCore(t)
351+
_, repoPath := setupTestRepo(t)
352+
initRepo(t, bin, repoPath)
353+
354+
// given -> remove post_create_script from config (simulates pre-feature config)
355+
patchConfig(t, repoPath, func(cfg map[string]interface{}) {
356+
delete(cfg, "post_create_script")
357+
})
358+
cfg := readConfig(t, repoPath)
359+
if _, ok := cfg["post_create_script"]; ok {
360+
t.Fatal("precondition: post_create_script should have been removed")
361+
}
362+
363+
// when -> run init again (not reinit)
364+
initRepo(t, bin, repoPath)
365+
366+
// then -> field should be present
367+
cfg = readConfig(t, repoPath)
368+
if _, ok := cfg["post_create_script"]; !ok {
369+
t.Error("expected init to add post_create_script to existing config")
370+
}
371+
}
372+
337373
func TestNewMob(t *testing.T) {
338374
bin := buildCore(t)
339375
_, repoPath := setupTestRepo(t)
@@ -1519,6 +1555,151 @@ func TestPurgeExternalThenListAndNew(t *testing.T) {
15191555
}
15201556
}
15211557

1558+
func TestPostCreateScriptRuns(t *testing.T) {
1559+
bin := buildCore(t)
1560+
_, repoPath := setupTestRepo(t)
1561+
initRepo(t, bin, repoPath)
1562+
1563+
// given -> a post_create_script that creates a marker file
1564+
scriptPath := filepath.Join(repoPath, "setup.sh")
1565+
os.WriteFile(scriptPath, []byte("#!/bin/sh\ntouch .setup-done\n"), 0755)
1566+
patchConfig(t, repoPath, func(cfg map[string]interface{}) {
1567+
cfg["post_create_script"] = scriptPath
1568+
})
1569+
1570+
// when -> create a mob
1571+
runCore(t, bin, repoPath, "new", "script-test", "--no-launch")
1572+
1573+
// then -> marker file should exist in the worktree
1574+
worktreePath := filepath.Join(repoPath, ".codemob", "mobs", "script-test")
1575+
if _, err := os.Stat(filepath.Join(worktreePath, ".setup-done")); err != nil {
1576+
t.Errorf("post_create_script did not run: marker file missing: %v", err)
1577+
}
1578+
}
1579+
1580+
func TestPostCreateScriptRelativePath(t *testing.T) {
1581+
bin := buildCore(t)
1582+
_, repoPath := setupTestRepo(t)
1583+
initRepo(t, bin, repoPath)
1584+
1585+
// given -> a post_create_script with a relative path
1586+
scriptPath := filepath.Join(repoPath, "setup.sh")
1587+
os.WriteFile(scriptPath, []byte("#!/bin/sh\ntouch .setup-done\n"), 0755)
1588+
patchConfig(t, repoPath, func(cfg map[string]interface{}) {
1589+
cfg["post_create_script"] = "setup.sh"
1590+
})
1591+
1592+
// when -> create a mob
1593+
runCore(t, bin, repoPath, "new", "rel-path-test", "--no-launch")
1594+
1595+
// then -> marker file should exist in the worktree
1596+
worktreePath := filepath.Join(repoPath, ".codemob", "mobs", "rel-path-test")
1597+
if _, err := os.Stat(filepath.Join(worktreePath, ".setup-done")); err != nil {
1598+
t.Errorf("post_create_script with relative path did not run: %v", err)
1599+
}
1600+
}
1601+
1602+
func TestPostCreateScriptFailureBlocksMob(t *testing.T) {
1603+
bin := buildCore(t)
1604+
_, repoPath := setupTestRepo(t)
1605+
initRepo(t, bin, repoPath)
1606+
1607+
// given -> a post_create_script that fails
1608+
scriptPath := filepath.Join(repoPath, "bad-setup.sh")
1609+
os.WriteFile(scriptPath, []byte("#!/bin/sh\nexit 1\n"), 0755)
1610+
patchConfig(t, repoPath, func(cfg map[string]interface{}) {
1611+
cfg["post_create_script"] = scriptPath
1612+
})
1613+
1614+
// when -> create a mob
1615+
out := runCoreExpectError(t, bin, repoPath, "new", "fail-test", "--no-launch")
1616+
1617+
// then -> error message should mention the script
1618+
if !strings.Contains(out, "post_create_script failed") {
1619+
t.Errorf("expected error about post_create_script, got: %s", out)
1620+
}
1621+
1622+
// then -> worktree should be cleaned up
1623+
worktreePath := filepath.Join(repoPath, ".codemob", "mobs", "fail-test")
1624+
if _, err := os.Stat(worktreePath); err == nil {
1625+
t.Error("worktree should have been removed after script failure")
1626+
}
1627+
1628+
// then -> mob should not exist in config
1629+
cfg := readConfig(t, repoPath)
1630+
mobs, _ := cfg["mobs"].([]interface{})
1631+
for _, m := range mobs {
1632+
entry, _ := m.(map[string]interface{})
1633+
if entry["name"] == "fail-test" {
1634+
t.Error("mob should have been removed from config after script failure")
1635+
}
1636+
}
1637+
}
1638+
1639+
func TestPostCreateScriptMissingFileError(t *testing.T) {
1640+
bin := buildCore(t)
1641+
_, repoPath := setupTestRepo(t)
1642+
initRepo(t, bin, repoPath)
1643+
1644+
// given -> post_create_script pointing to a nonexistent file
1645+
patchConfig(t, repoPath, func(cfg map[string]interface{}) {
1646+
cfg["post_create_script"] = "/nonexistent/setup.sh"
1647+
})
1648+
1649+
// when -> create a mob
1650+
out := runCoreExpectError(t, bin, repoPath, "new", "missing-script", "--no-launch")
1651+
1652+
// then -> error message should mention "not found"
1653+
if !strings.Contains(out, "not found") {
1654+
t.Errorf("expected 'not found' error, got: %s", out)
1655+
}
1656+
}
1657+
1658+
func TestPostCreateScriptNotExecutableError(t *testing.T) {
1659+
bin := buildCore(t)
1660+
_, repoPath := setupTestRepo(t)
1661+
initRepo(t, bin, repoPath)
1662+
1663+
// given -> a post_create_script without the executable bit
1664+
scriptPath := filepath.Join(repoPath, "setup.sh")
1665+
os.WriteFile(scriptPath, []byte("#!/bin/sh\ntouch .setup-done\n"), 0644)
1666+
patchConfig(t, repoPath, func(cfg map[string]interface{}) {
1667+
cfg["post_create_script"] = scriptPath
1668+
})
1669+
1670+
// when -> create a mob
1671+
out := runCoreExpectError(t, bin, repoPath, "new", "noexec-test", "--no-launch")
1672+
1673+
// then -> error message should mention "not executable"
1674+
if !strings.Contains(out, "not executable") {
1675+
t.Errorf("expected 'not executable' error, got: %s", out)
1676+
}
1677+
}
1678+
1679+
func TestPostCreateScriptEmptyIsNoop(t *testing.T) {
1680+
bin := buildCore(t)
1681+
_, repoPath := setupTestRepo(t)
1682+
initRepo(t, bin, repoPath)
1683+
1684+
// given -> no post_create_script configured (empty string default)
1685+
// when -> create a mob
1686+
runCore(t, bin, repoPath, "new", "no-script-test", "--no-launch")
1687+
1688+
// then -> mob should be created successfully
1689+
cfg := readConfig(t, repoPath)
1690+
mobs, _ := cfg["mobs"].([]interface{})
1691+
found := false
1692+
for _, m := range mobs {
1693+
mob, _ := m.(map[string]interface{})
1694+
if mob["name"] == "no-script-test" {
1695+
found = true
1696+
}
1697+
}
1698+
if !found {
1699+
t.Error("mob should have been created when post_create_script is empty")
1700+
}
1701+
}
1702+
15221703
func TestSlashCommandsCopiedToExternalWorktree(t *testing.T) {
15231704
bin := buildCore(t)
15241705
_, repoPath := setupTestRepo(t)

0 commit comments

Comments
 (0)