Skip to content

Commit 4afa26d

Browse files
committed
feat(sandbox): add Herdr setup command
1 parent 19e3451 commit 4afa26d

3 files changed

Lines changed: 482 additions & 1 deletion

File tree

cmd/sandbox/herdr.go

Lines changed: 388 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,388 @@
1+
package sandbox
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"sort"
10+
"strconv"
11+
"strings"
12+
13+
"github.com/NodeOps-app/createos-cli/internal/api"
14+
15+
"github.com/urfave/cli/v2"
16+
)
17+
18+
// Herdr is a terminal workspace manager, not a coding agent, so this setup
19+
// inverts the Orca one. Orca runs a recipe per workspace and the CLI is the
20+
// machine half; Herdr has no such hook, and its own CLI is the whole plugin
21+
// API. So `createos sandbox setup herdr` performs every install step itself:
22+
// it registers the plugin, writes its config, and binds the keys.
23+
//
24+
// The plugin it installs puts a coding agent *inside* a sandbox and attaches
25+
// that agent's PTY to a Herdr pane.
26+
27+
const (
28+
herdrPluginID = "createos.sandbox"
29+
herdrPluginRepo = "NodeOps-app/createos-plugins"
30+
herdrPluginPkgPath = "packages/herdr-plugin"
31+
// Plugin panes and `pane split --env` both landed in 0.7.5.
32+
herdrMinVersion = "0.7.5"
33+
herdrDefaultAgent = "claude-code"
34+
herdrDefaultShape = "s-2vcpu-4gb"
35+
herdrDefaultPause = "30m"
36+
herdrDefaultRemote = "/workspace"
37+
)
38+
39+
// Agents the plugin can install inside a sandbox. Kept in step with
40+
// packages/herdr-plugin/src/agents.ts; the plugin owns the install commands,
41+
// this list only validates --agent before writing it to the plugin config.
42+
var herdrAgents = map[string]string{
43+
"claude-code": "Claude Code",
44+
"codex": "Codex",
45+
"opencode": "OpenCode",
46+
"pi": "Pi",
47+
"cursor": "Cursor",
48+
"shell": "a plain shell, no agent",
49+
}
50+
51+
// One binding per plugin action. Herdr ignores keys declared in a plugin
52+
// manifest, so they have to be spliced into the user's config.toml.
53+
var herdrKeys = []struct{ key, action, description string }{
54+
{"prefix+shift+s", "start", "start an agent in a new CreateOS sandbox"},
55+
{"prefix+shift+c", "attach", "reattach the agent to this pane"},
56+
{"prefix+shift+y", "sync", "two-way sync this pane's sandbox"},
57+
{"prefix+shift+a", "apply", "apply sandbox changes locally"},
58+
{"prefix+shift+i", "info", "show this pane's sandbox mapping"},
59+
{"prefix+shift+x", "delete", "delete this pane's sandbox"},
60+
}
61+
62+
func newSetupHerdrCommand() *cli.Command {
63+
return &cli.Command{
64+
Name: "herdr",
65+
Usage: "Set up Herdr to run coding agents inside CreateOS Sandboxes",
66+
Description: "Installs the CreateOS Sandbox plugin into Herdr, writes its\n" +
67+
"configuration, and binds its keys. After this, one Herdr pane maps to\n" +
68+
"one sandbox with a coding agent running inside it.\n\n" +
69+
"Run with --doctor first if you only want to check the prerequisites.",
70+
Flags: []cli.Flag{
71+
&cli.BoolFlag{
72+
Name: "doctor",
73+
Usage: "Check the prerequisites and report, without changing anything",
74+
},
75+
&cli.StringFlag{
76+
Name: "local",
77+
Usage: "Link this local plugin directory instead of installing from GitHub",
78+
},
79+
&cli.StringFlag{
80+
Name: "agent",
81+
Usage: "Coding agent to run in each sandbox: " + herdrAgentNames(),
82+
Value: herdrDefaultAgent,
83+
EnvVars: []string{"CREATEOS_AGENT"},
84+
},
85+
&cli.StringFlag{
86+
Name: "shape",
87+
Usage: "Sandbox size for each agent",
88+
Value: herdrDefaultShape,
89+
EnvVars: []string{"CREATEOS_SHAPE"},
90+
},
91+
&cli.StringFlag{
92+
Name: "rootfs",
93+
Usage: "Sandbox image for each agent",
94+
EnvVars: []string{"CREATEOS_ROOTFS"},
95+
},
96+
&cli.StringFlag{
97+
Name: "auto-pause",
98+
Usage: "Pause a sandbox after this long with no activity",
99+
Value: herdrDefaultPause,
100+
},
101+
&cli.StringFlag{
102+
Name: "remote-root",
103+
Usage: "Absolute path the worktree lands on inside the sandbox",
104+
Value: herdrDefaultRemote,
105+
},
106+
&cli.BoolFlag{
107+
Name: "no-keys",
108+
Usage: "Do not add keybindings to your Herdr config.toml",
109+
},
110+
&cli.BoolFlag{
111+
Name: "force",
112+
Usage: "Overwrite an existing plugin config.json",
113+
},
114+
},
115+
Action: func(c *cli.Context) error {
116+
return runHerdrSetup(c, c.Bool("doctor"))
117+
},
118+
}
119+
}
120+
121+
func herdrAgentNames() string {
122+
names := make([]string, 0, len(herdrAgents))
123+
for name := range herdrAgents {
124+
names = append(names, name)
125+
}
126+
sort.Strings(names)
127+
return strings.Join(names, ", ")
128+
}
129+
130+
func runHerdrSetup(c *cli.Context, doctorOnly bool) error {
131+
agent := strings.TrimSpace(c.String("agent"))
132+
if _, ok := herdrAgents[agent]; !ok {
133+
return fmt.Errorf("unknown agent %q; pick one of: %s", agent, herdrAgentNames())
134+
}
135+
136+
// ---- prerequisites -----------------------------------------------------
137+
138+
client, ok := c.App.Metadata[api.SandboxClientKey].(*api.SandboxClient)
139+
if !ok {
140+
return fmt.Errorf("you're not signed in — run 'createos login' first")
141+
}
142+
if _, _, err := client.ListSandboxes(c.Context, api.ListSandboxesOpts{}); err != nil {
143+
return fmt.Errorf("your session is not usable — run 'createos login' again: %w", err)
144+
}
145+
fmt.Println("signed in to CreateOS")
146+
147+
herdrBin, err := exec.LookPath("herdr")
148+
if err != nil {
149+
return fmt.Errorf("herdr is not on PATH — install it from https://herdr.dev")
150+
}
151+
version, err := herdrVersion(herdrBin)
152+
if err != nil {
153+
return err
154+
}
155+
if herdrVersionLess(version, herdrMinVersion) {
156+
return fmt.Errorf("herdr %s is too old; the plugin needs %s or newer", version, herdrMinVersion)
157+
}
158+
fmt.Printf("herdr %s found at %s\n", version, herdrBin)
159+
160+
if _, err := exec.LookPath("bun"); err != nil {
161+
return fmt.Errorf("bun is not on PATH — the plugin runs on it; install it from https://bun.sh")
162+
}
163+
fmt.Println("bun found on PATH")
164+
165+
if _, err := exec.LookPath("git"); err != nil {
166+
return fmt.Errorf("git is not on PATH; the plugin uploads what git tracks")
167+
}
168+
fmt.Println("git found on PATH")
169+
170+
if doctorOnly {
171+
fmt.Println("\nEverything the plugin needs is present. Run this again without --doctor to install it.")
172+
return nil
173+
}
174+
175+
// ---- install -----------------------------------------------------------
176+
177+
if local := strings.TrimSpace(c.String("local")); local != "" {
178+
if err := herdrLink(herdrBin, local); err != nil {
179+
return err
180+
}
181+
} else {
182+
source := herdrPluginRepo + "/" + herdrPluginPkgPath
183+
fmt.Printf("installing %s from %s\n", herdrPluginID, source)
184+
if out, err := herdrRun(herdrBin, "plugin", "install", source, "--yes"); err != nil {
185+
return fmt.Errorf("herdr plugin install failed: %w\n%s", err, out)
186+
}
187+
fmt.Println("plugin installed")
188+
}
189+
190+
// ---- plugin config -----------------------------------------------------
191+
192+
configDir, err := herdrRun(herdrBin, "plugin", "config-dir", herdrPluginID)
193+
if err != nil {
194+
return fmt.Errorf("could not find the plugin config directory: %w", err)
195+
}
196+
configDir = strings.TrimSpace(configDir)
197+
if configDir == "" {
198+
return fmt.Errorf("herdr returned no config directory for %s", herdrPluginID)
199+
}
200+
written, err := herdrWritePluginConfig(c, configDir, agent)
201+
if err != nil {
202+
return err
203+
}
204+
if written {
205+
fmt.Printf("wrote %s\n", filepath.Join(configDir, "config.json"))
206+
} else {
207+
fmt.Printf("kept your existing %s (pass --force to replace it)\n", filepath.Join(configDir, "config.json"))
208+
}
209+
210+
// ---- keybindings -------------------------------------------------------
211+
212+
if c.Bool("no-keys") {
213+
fmt.Println("skipped keybindings (--no-keys)")
214+
} else {
215+
added, path, err := herdrWriteKeys(configDir)
216+
if err != nil {
217+
return err
218+
}
219+
switch {
220+
case added == 0:
221+
fmt.Printf("keybindings already present in %s\n", path)
222+
default:
223+
fmt.Printf("added %d keybindings to %s (undo with 'herdr config reset-keys')\n", added, path)
224+
}
225+
if out, err := herdrRun(herdrBin, "config", "check"); err != nil {
226+
return fmt.Errorf("herdr rejected the updated config: %w\n%s", err, out)
227+
}
228+
// Only a running server can reload; a failure here is not a setup failure.
229+
if _, err := herdrRun(herdrBin, "server", "reload-config"); err != nil {
230+
fmt.Println("no running Herdr server to reload — the keys apply next time you start one")
231+
} else {
232+
fmt.Println("reloaded the running Herdr server")
233+
}
234+
}
235+
236+
// ---- what to do next ---------------------------------------------------
237+
238+
fmt.Printf("\nDone. Open Herdr in a Git worktree and press %s to start %s in a new sandbox.\n",
239+
herdrKeys[0].key, herdrAgents[agent])
240+
fmt.Println("Authenticate the agent in that pane the first time it opens.")
241+
fmt.Printf("Every action is also listed by: herdr plugin action list --plugin %s\n", herdrPluginID)
242+
return nil
243+
}
244+
245+
func herdrLink(herdrBin, local string) error {
246+
dir, err := filepath.Abs(local)
247+
if err != nil {
248+
return fmt.Errorf("could not resolve %q: %w", local, err)
249+
}
250+
if _, err := os.Stat(filepath.Join(dir, "herdr-plugin.toml")); err != nil {
251+
return fmt.Errorf("%s does not look like the plugin: no herdr-plugin.toml", dir)
252+
}
253+
fmt.Printf("linking %s\n", dir)
254+
if out, err := herdrRun(herdrBin, "plugin", "link", dir); err != nil {
255+
return fmt.Errorf("herdr plugin link failed: %w\n%s", err, out)
256+
}
257+
// `plugin link` deliberately does not run build commands, so the generated
258+
// run.sh that carries the absolute bun and createos paths is missing.
259+
build := filepath.Join(dir, "build.sh")
260+
if _, err := os.Stat(build); err != nil {
261+
return fmt.Errorf("%s is missing; the plugin cannot generate its launcher", build)
262+
}
263+
fmt.Println("running the plugin build step")
264+
cmd := exec.Command("sh", build)
265+
cmd.Dir = dir
266+
cmd.Stdout = os.Stderr
267+
cmd.Stderr = os.Stderr
268+
if err := cmd.Run(); err != nil {
269+
return fmt.Errorf("%s failed: %w", build, err)
270+
}
271+
fmt.Println("plugin linked")
272+
return nil
273+
}
274+
275+
// herdrWritePluginConfig writes config.json unless one is already there and
276+
// --force was not passed. It reports whether it wrote.
277+
func herdrWritePluginConfig(c *cli.Context, configDir, agent string) (bool, error) {
278+
path := filepath.Join(configDir, "config.json")
279+
if _, err := os.Stat(path); err == nil && !c.Bool("force") {
280+
return false, nil
281+
}
282+
settings := map[string]string{
283+
"agent": agent,
284+
"shape": c.String("shape"),
285+
"autoPause": c.String("auto-pause"),
286+
"remoteRoot": c.String("remote-root"),
287+
}
288+
if rootfs := strings.TrimSpace(c.String("rootfs")); rootfs != "" {
289+
settings["rootfs"] = rootfs
290+
}
291+
body, err := json.MarshalIndent(settings, "", " ")
292+
if err != nil {
293+
return false, fmt.Errorf("could not build the plugin config: %w", err)
294+
}
295+
if err := os.MkdirAll(configDir, 0o755); err != nil {
296+
return false, fmt.Errorf("could not create %s: %w", configDir, err)
297+
}
298+
if err := os.WriteFile(path, append(body, '\n'), 0o600); err != nil {
299+
return false, fmt.Errorf("could not write %s: %w", path, err)
300+
}
301+
return true, nil
302+
}
303+
304+
// herdrWriteKeys appends the missing bindings to config.toml and reports how
305+
// many it added. It never rewrites a binding the user already has.
306+
func herdrWriteKeys(pluginConfigDir string) (int, string, error) {
307+
// pluginConfigDir is <herdr config>/plugins/config/<id>, and asking Herdr
308+
// for it beats guessing where Herdr keeps its configuration.
309+
root := filepath.Dir(filepath.Dir(filepath.Dir(pluginConfigDir)))
310+
path := filepath.Join(root, "config.toml")
311+
312+
existing, err := os.ReadFile(path)
313+
if err != nil && !os.IsNotExist(err) {
314+
return 0, path, fmt.Errorf("could not read %s: %w", path, err)
315+
}
316+
current := string(existing)
317+
318+
var block strings.Builder
319+
added := 0
320+
for _, k := range herdrKeys {
321+
command := herdrPluginID + "." + k.action
322+
if strings.Contains(current, command) {
323+
continue
324+
}
325+
fmt.Fprintf(&block, "\n[[keys.command]]\nkey = %q\ntype = \"plugin_action\"\ncommand = %q\ndescription = %q\n",
326+
k.key, command, k.description)
327+
added++
328+
}
329+
if added == 0 {
330+
return 0, path, nil
331+
}
332+
333+
if len(existing) > 0 {
334+
backup := path + ".before-createos"
335+
if err := os.WriteFile(backup, existing, 0o600); err != nil {
336+
return 0, path, fmt.Errorf("could not back up %s: %w", path, err)
337+
}
338+
fmt.Printf("backed up your config to %s\n", backup)
339+
}
340+
if err := os.MkdirAll(root, 0o755); err != nil {
341+
return 0, path, fmt.Errorf("could not create %s: %w", root, err)
342+
}
343+
updated := current
344+
if updated != "" && !strings.HasSuffix(updated, "\n") {
345+
updated += "\n"
346+
}
347+
updated += "\n# Added by 'createos sandbox setup herdr'.\n" + strings.TrimPrefix(block.String(), "\n")
348+
if err := os.WriteFile(path, []byte(updated), 0o600); err != nil {
349+
return 0, path, fmt.Errorf("could not write %s: %w", path, err)
350+
}
351+
return added, path, nil
352+
}
353+
354+
func herdrRun(bin string, args ...string) (string, error) {
355+
out, err := exec.Command(bin, args...).CombinedOutput()
356+
return string(out), err
357+
}
358+
359+
func herdrVersion(bin string) (string, error) {
360+
out, err := exec.Command(bin, "--version").Output()
361+
if err != nil {
362+
return "", fmt.Errorf("could not run 'herdr --version': %w", err)
363+
}
364+
// "herdr 0.8.2"
365+
fields := strings.Fields(string(out))
366+
if len(fields) == 0 {
367+
return "", fmt.Errorf("'herdr --version' printed nothing")
368+
}
369+
return strings.TrimSpace(fields[len(fields)-1]), nil
370+
}
371+
372+
// herdrVersionLess compares dotted numeric versions. A part that is not a
373+
// number sorts as 0, so a pre-release suffix never reads as newer.
374+
func herdrVersionLess(have, want string) bool {
375+
haveParts := strings.Split(strings.SplitN(have, "-", 2)[0], ".")
376+
wantParts := strings.Split(want, ".")
377+
for i := 0; i < len(wantParts); i++ {
378+
var h int
379+
if i < len(haveParts) {
380+
h, _ = strconv.Atoi(haveParts[i])
381+
}
382+
w, _ := strconv.Atoi(wantParts[i])
383+
if h != w {
384+
return h < w
385+
}
386+
}
387+
return false
388+
}

0 commit comments

Comments
 (0)